// bash_security_test.go -- Bash 命令安全分析的单元测试. // // 覆盖场景: // - SplitCompoundCommand 复合命令分割 // - 引号内分隔符不分割 // - ExtractCommandName 命令名提取 // - 跳过 env/sudo 等前缀 // - IsDangerousCommand 危险命令检测 // - IsDangerousCommandName 危险命令名检测 // - GetCommandPrefixes 前缀提取 // - tokenizeCommand 分词 // - AnalyzeDanger 结构化危险分析(INF-7 P1) package permission import ( "strings" "testing" ) // TestSplitCompoundCommand 测试复合命令分割 func TestSplitCompoundCommand(t *testing.T) { tests := []struct { name string cmd string want []string }{ {"单命令", "npm install", []string{"npm install"}}, {"&& 分割", "npm install && npm test", []string{"npm install", "npm test"}}, {"|| 分割", "cmd1 || cmd2", []string{"cmd1", "cmd2"}}, {"管道分割", "cat file | grep pattern | wc -l", []string{"cat file", "grep pattern", "wc -l"}}, {"; 分割", "cmd1; cmd2", []string{"cmd1", "cmd2"}}, {"引号内不分割", "echo 'a && b'", []string{"echo 'a && b'"}}, {"双引号内不分割", `echo "a | b"`, []string{`echo "a | b"`}}, {"空命令", "", nil}, {"混合分割符", "a && b | c; d", []string{"a", "b", "c", "d"}}, {"反引号内不分割", "echo `a && b`", []string{"echo `a && b`"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SplitCompoundCommand(tt.cmd) if len(got) != len(tt.want) { t.Fatalf("分割数量: %d, 期望 %d\n 结果: %v\n 期望: %v", len(got), len(tt.want), got, tt.want) } for i, g := range got { if g != tt.want[i] { t.Errorf("第 %d 个: %q, 期望 %q", i, g, tt.want[i]) } } }) } } // TestExtractCommandName 测试命令名提取 func TestExtractCommandName(t *testing.T) { tests := []struct { name string cmd string wantCmd string wantSubCmd string }{ {"简单命令", "npm install", "npm", "install"}, {"跳过 env", "env FOO=bar npm i", "npm", "i"}, {"跳过 sudo", "sudo rm -rf /", "rm", ""}, {"跳过 nohup", "nohup node server.js", "node", "server.js"}, {"环境变量赋值", "FOO=bar BAZ=qux npm test", "npm", "test"}, {"空命令", "", "", ""}, {"git 子命令", "git push --force", "git", "push"}, {"只有选项的子命令", "ls -la", "ls", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd, sub := ExtractCommandName(tt.cmd) if cmd != tt.wantCmd { t.Errorf("command: %q, 期望 %q", cmd, tt.wantCmd) } if sub != tt.wantSubCmd { t.Errorf("subcommand: %q, 期望 %q", sub, tt.wantSubCmd) } }) } } // TestIsDangerousCommand 测试危险命令检测 func TestIsDangerousCommand(t *testing.T) { tests := []struct { name string cmd string want bool }{ {"rm -rf", "rm -rf /", true}, // 注意:ExtractCommandName 会跳过 sudo 前缀,提取出实际命令名 // 所以 "sudo apt install" 中提取出 "apt",不会被 dangerousPatterns 匹配 // 但 su 不在跳过列表中,ExtractCommandName("su root") = ("su", "root") // su 在 dangerousPatterns 中是无条件危险的 {"su 命令", "su root", true}, {"git push --force", "git push --force", true}, {"git reset --hard", "git reset --hard HEAD~1", true}, {"git clean -f", "git clean -fdx", true}, {"chmod 777", "chmod 777 /etc/passwd", true}, {"DROP TABLE", "mysql -e 'drop table users'", true}, {"安全命令", "npm install", false}, {"只读命令", "ls -la", false}, {"空命令", "", false}, {"操作 .bashrc", "cat .bashrc", true}, {"操作 .ssh", "cat .ssh/id_rsa", true}, {"操作 .env", "cat .env", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsDangerousCommand(tt.cmd) if got != tt.want { t.Errorf("IsDangerousCommand(%q) = %v, 期望 %v", tt.cmd, got, tt.want) } }) } } // TestIsDangerousCommandName 测试危险命令名检测 func TestIsDangerousCommandName(t *testing.T) { dangerous := []string{"rm", "rmdir", "mkfs", "dd", "shutdown", "reboot", "kill", "killall", "pkill"} safe := []string{"ls", "cat", "echo", "npm", "go", "git"} for _, cmd := range dangerous { if !IsDangerousCommandName(cmd) { t.Errorf("%q 应被识别为危险命令", cmd) } } for _, cmd := range safe { if IsDangerousCommandName(cmd) { t.Errorf("%q 不应被识别为危险命令", cmd) } } } // TestGetCommandPrefixes 测试命令前缀提取 func TestGetCommandPrefixes(t *testing.T) { tests := []struct { name string cmd string want []string }{ {"简单命令", "npm install lodash", []string{"npm", "npm install", "npm install lodash"}}, {"单个词", "ls", []string{"ls"}}, {"空命令", "", nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := GetCommandPrefixes(tt.cmd) if len(got) != len(tt.want) { t.Fatalf("前缀数量: %d, 期望 %d\n 结果: %v\n 期望: %v", len(got), len(tt.want), got, tt.want) } for i, g := range got { if g != tt.want[i] { t.Errorf("第 %d 个: %q, 期望 %q", i, g, tt.want[i]) } } }) } } // TestAnalyzeDanger 测试结构化危险分析(INF-7 P1 checkpoint_suggested) func TestAnalyzeDanger(t *testing.T) { tests := []struct { name string cmd string wantDangerous bool wantPattern string // 匹配的模式(子串即可) }{ // 安全命令 {"安全_ls", "ls -la /tmp", false, ""}, {"安全_echo", "echo hello", false, ""}, {"安全_git_status", "git status", false, ""}, {"安全_npm_install", "npm install", false, ""}, {"安全_空命令", "", false, ""}, // SQL 危险模式(优先检测) {"SQL_drop_table", "psql -c 'DROP TABLE users'", true, "drop table"}, {"SQL_drop_database", "mysql -e 'DROP DATABASE prod'", true, "drop database"}, {"SQL_truncate", "psql -c 'TRUNCATE TABLE orders'", true, "truncate table"}, {"SQL_delete_from", "sqlite3 db.sqlite 'DELETE FROM sessions'", true, "delete from"}, {"SQL_大写", "psql -c 'DROP SCHEMA public CASCADE'", true, "drop schema"}, // sudo/su 提权 {"sudo_任意", "sudo systemctl restart nginx", true, "sudo"}, {"su_任意", "su - root", true, "su"}, // rm -rf 系列 {"rm_rf", "rm -rf /tmp/work", true, "rm"}, {"rm_fr", "rm -fr /home/user", true, "rm"}, {"rm_recursive", "rm --recursive /build", true, "rm"}, {"rm_f", "rm -f /tmp/file", false, ""}, // 只有 -f 不触发(需要 -r) // git 危险操作 {"git_push_force", "git push --force origin main", true, "git"}, {"git_reset_hard", "git reset --hard HEAD~1", true, "git"}, {"git_clean_f", "git clean -f", true, "git"}, // chmod 危险 {"chmod_777", "chmod 777 /etc/passwd", true, "chmod"}, {"chmod_000", "chmod 000 .ssh/authorized_keys", true, "chmod"}, {"chmod_s", "chmod +s /usr/bin/bash", true, "chmod"}, {"chmod_755_安全", "chmod 755 ./script.sh", false, ""}, // chown -R {"chown_R", "chown -R root /etc", true, "chown"}, {"chown_simple_安全", "chown user:group file.txt", false, ""}, // 受保护文件 {"protected_bashrc", "echo 'alias ls=rm' >> ~/.bashrc", true, ".bashrc"}, {"protected_ssh_key", "cat ~/.ssh/id_rsa > /tmp/leak", true, ".ssh/id_rsa"}, {"protected_env", "cp .env /tmp/", true, ".env"}, {"protected_gitconfig", "rm ~/.gitconfig", true, ".gitconfig"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dangerous, info := AnalyzeDanger(tt.cmd) if dangerous != tt.wantDangerous { t.Errorf("cmd=%q dangerous=%v want=%v (reason=%q, pattern=%q)", tt.cmd, dangerous, tt.wantDangerous, info.Reason, info.Pattern) } if tt.wantDangerous { if info.Reason == "" { t.Errorf("cmd=%q: dangerous=true 但 Reason 为空", tt.cmd) } if info.Pattern == "" { t.Errorf("cmd=%q: dangerous=true 但 Pattern 为空", tt.cmd) } if tt.wantPattern != "" { found := false // Reason 或 Pattern 包含期望的子串即可 for _, s := range []string{info.Reason, info.Pattern} { if containsFold(s, tt.wantPattern) { found = true break } } if !found { t.Errorf("cmd=%q: 期望含 %q, got reason=%q pattern=%q", tt.cmd, tt.wantPattern, info.Reason, info.Pattern) } } } else { if info.Reason != "" || info.Pattern != "" { t.Errorf("cmd=%q: 期望安全但有 reason=%q pattern=%q", tt.cmd, info.Reason, info.Pattern) } } }) } } // containsFold 大小写不敏感的子串检测(测试辅助). func containsFold(s, sub string) bool { if len(sub) == 0 { return true } sLower := strings.ToLower(s) subLower := strings.ToLower(sub) return strings.Contains(sLower, subLower) } // TestTokenizeCommand 测试命令分词 func TestTokenizeCommand(t *testing.T) { tests := []struct { name string cmd string want []string }{ {"简单", "npm install", []string{"npm", "install"}}, {"单引号", "echo 'hello world'", []string{"echo", "hello world"}}, {"双引号", `echo "hello world"`, []string{"echo", "hello world"}}, {"转义", `echo hello\ world`, []string{"echo", "hello world"}}, {"多空格", "npm install", []string{"npm", "install"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tokenizeCommand(tt.cmd) if len(got) != len(tt.want) { t.Fatalf("token 数量: %d, 期望 %d\n 结果: %v", len(got), len(tt.want), got) } for i, g := range got { if g != tt.want[i] { t.Errorf("第 %d 个: %q, 期望 %q", i, g, tt.want[i]) } } }) } } // TestIsDangerousCommandNestedSubstitution 验证命令替换内的危险命令能被递归检测到 func TestIsDangerousCommandNestedSubstitution(t *testing.T) { tests := []struct { name string cmd string want bool }{ // 命令替换内的危险命令(核心场景:以前的盲区) {"dollar_paren_rm", "echo $(rm -rf /)", true}, {"backtick_rm", "echo `rm -rf /`", true}, {"dollar_paren_sudo", "echo $(sudo cat .ssh/authorized_keys)", true}, {"double_nested_rm", "echo $(echo $(rm -rf /))", true}, // 命令替换内的安全命令不误报 {"safe_nested_date", "echo $(date +%Y)", false}, {"safe_nested_ls", "log=$(ls /tmp)", false}, // 命令替换内的受保护文件操作 {"nested_write_bashrc", "x=$(cat .bashrc)", true}, // cat .bashrc 操作受保护文件 } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsDangerousCommand(tt.cmd) if got != tt.want { t.Errorf("IsDangerousCommand(%q) = %v, 期望 %v", tt.cmd, got, tt.want) } }) } } // TestAnalyzeDangerNestedSubstitution 验证 AnalyzeDanger 能对命令替换内命令给出结构化原因 func TestAnalyzeDangerNestedSubstitution(t *testing.T) { tests := []struct { name string cmd string wantDangerous bool wantPattern string }{ {"echo_nested_rm", "echo $(rm -rf /)", true, "rm"}, {"backtick_sudo", "result=`sudo id`", true, "sudo"}, {"safe_nested", "echo $(date)", false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dangerous, info := AnalyzeDanger(tt.cmd) if dangerous != tt.wantDangerous { t.Errorf("cmd=%q dangerous=%v want=%v (reason=%q, pattern=%q)", tt.cmd, dangerous, tt.wantDangerous, info.Reason, info.Pattern) } if tt.wantDangerous { if info.Reason == "" { t.Errorf("cmd=%q: dangerous=true 但 Reason 为空", tt.cmd) } if tt.wantPattern != "" { if !containsFold(info.Reason, tt.wantPattern) && !containsFold(info.Pattern, tt.wantPattern) { t.Errorf("cmd=%q: 期望含 %q, got reason=%q pattern=%q", tt.cmd, tt.wantPattern, info.Reason, info.Pattern) } } } }) } } // --------------------------------------------------------------------------- // Dangerous env-var prefix regression guards (bash.Assignment.Name wire) // --------------------------------------------------------------------------- // TestIsDangerousCommand_DangerousEnvVarPrefix locks the Assignment.Name // wire: a command like "LD_PRELOAD=./evil.so curl ..." is classified as // dangerous because the env-var prefix can hijack the dynamic linker // even though the command itself (curl) is benign. Without this wire, // the injection would slip past every other check (ExtractCommandName // strips the prefix, name=curl, args contain no danger pattern). // // TestIsDangerousCommand_DangerousEnvVarPrefix 锁 Assignment.Name wire: // 命令 "LD_PRELOAD=./evil.so curl ..." 因 env-var 前缀可劫持动态链接器 // 被判危险, 即便命令本身 (curl) 无害. 无此 wire, 注入会绕过所有其他 // 检查 (ExtractCommandName 剥前缀, name=curl, args 不含危险模式). func TestIsDangerousCommand_DangerousEnvVarPrefix(t *testing.T) { cases := []struct { name string cmd string want bool }{ {"ld_preload", "LD_PRELOAD=./evil.so curl http://x", true}, {"ld_library_path", "LD_LIBRARY_PATH=/tmp/evil ls", true}, {"dyld_insert", "DYLD_INSERT_LIBRARIES=/tmp/evil.dylib ls", true}, {"ifs_redef", "IFS=: echo a", true}, {"path_override", "PATH=/tmp/evil echo x", true}, {"pythonpath", "PYTHONPATH=/tmp/evil python3 script.py", true}, {"node_options", "NODE_OPTIONS=--require=/tmp/evil node app.js", true}, {"git_ssh_command", "GIT_SSH_COMMAND=/tmp/evil git pull", true}, {"bash_env", "BASH_ENV=/tmp/evil bash script.sh", true}, // Benign env-var prefixes must NOT trip the check — no // over-blocking of normal workflows. // 良性 env-var 前缀必不触发 -- 正常工作流不被过度拦截. {"harmless_node_env", "NODE_ENV=production node app.js", false}, {"harmless_debug", "DEBUG=1 npm test", false}, {"harmless_custom", "MY_CONFIG=/etc/app.conf ./myapp", false}, // Case-insensitive match so mixed-case evasion fails. // 大小写无关匹配 -- 混合大小写绕过失败. {"case_insensitive_ld", "ld_preload=./evil.so ls", true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := IsDangerousCommand(tc.cmd); got != tc.want { t.Errorf("IsDangerousCommand(%q) = %v, want %v", tc.cmd, got, tc.want) } }) } } // TestAnalyzeDanger_DangerousEnvVarPrefix_ExposesName verifies the // structured path: AnalyzeDanger returns a DangerInfo whose Reason // contains the specific env-var name and whose Pattern is the full // "NAME=VALUE" prefix, so TUI / audit logs can render actionable context // rather than a generic "dangerous command" string. // // TestAnalyzeDanger_DangerousEnvVarPrefix_ExposesName 验证结构化路径: // AnalyzeDanger 返回的 DangerInfo.Reason 含具体 env-var 名, Pattern 是 // 完整 "NAME=VALUE" 前缀, TUI / 审计日志可渲染可行动上下文而非笼统 // "dangerous command". func TestAnalyzeDanger_DangerousEnvVarPrefix_ExposesName(t *testing.T) { dangerous, info := AnalyzeDanger("LD_PRELOAD=./evil.so curl http://x") if !dangerous { t.Fatal("expected dangerous=true") } if !containsFold(info.Reason, "LD_PRELOAD") { t.Errorf("Reason = %q, want containing env-var name", info.Reason) } if !containsFold(info.Pattern, "LD_PRELOAD=./evil.so") { t.Errorf("Pattern = %q, want containing full NAME=VALUE prefix", info.Pattern) } } // ================== Heredoc-body security regression tests ================== // 一 sub-claim 一 test (d)(e)(h), 锁 IsDangerousCommand / AnalyzeDanger 对 // heredoc body 的实际行为. Quoted gate, 递归 danger, dangerous-file 字面匹 // 配 - 全部对 godoc 承诺的闭环, 不是 scanner 形式过关. // // One test per godoc sub-claim (d)(e)(h). Locks security behavior on // heredoc body: Quoted gate, nested-danger recursion, dangerous-file // literal match. Each asserts the godoc promise, not just that some // field is read. // Sub-claim (d): HeredocQuoted=true gates out body expansion, so // `$(rm -rf /)` nested in a quoted-delimiter heredoc must NOT trigger // IsDangerousCommand (runtime won't expand it -> no actual danger). func TestIsDangerousCommand_HeredocQuoted_NoFalsePositive(t *testing.T) { cmd := "cat <<'EOF'\n$(rm -rf /)\nEOF" if IsDangerousCommand(cmd) { t.Errorf("IsDangerousCommand(%q) = true, want false (quoted delim inert)", cmd) } } // Sub-claim (e): HeredocQuoted=false means body expands at runtime, so // nested `$(rm -rf /)` MUST trigger IsDangerousCommand. Mirror test to // (d) so the gate behavior is observable from both sides. func TestIsDangerousCommand_HeredocUnquoted_NestedDanger(t *testing.T) { cmd := "cat < out < len(cmd) || info.HeredocBodyStart >= info.HeredocBodyEnd { t.Fatalf("DangerInfo.HeredocBody{Start,End} = [%d,%d) invalid for len(cmd)=%d", info.HeredocBodyStart, info.HeredocBodyEnd, len(cmd)) } slice := cmd[info.HeredocBodyStart:info.HeredocBodyEnd] if !strings.Contains(slice, "authorized_keys") { t.Errorf("cmd[%d:%d] = %q, want containing 'authorized_keys' (slice should be the heredoc body)", info.HeredocBodyStart, info.HeredocBodyEnd, slice) } wantClause := "source bytes" if !strings.Contains(info.Reason, wantClause) { t.Errorf("DangerInfo.Reason = %q, want containing %q", info.Reason, wantClause) } } // Sub-claim (a): AnalyzeDanger 真实消费 redir.HeredocTag. // 当 heredoc body 内触发 dangerous-file 时, DangerInfo.Pattern // 带 < permission round-trip. // // 锁 sub-claim 3: ANSI-C 引号 $'...' 的 arg 必须视作 literal // (Quoted=true), 产出与单引号相同的 "literal arg -- no runtime // expansion" 注解 -- parser.go:1394 对 ANSI-C 置 Quoted=true, // 该 regime 必须走完 extract -> permission 的往返不失真. func TestAnalyzeDanger_ArgQuoted_ANSIC_LiteralInReason(t *testing.T) { ansic := `rm $'.ssh/authorized_keys'` ok, info := AnalyzeDanger(ansic) if !ok { t.Fatalf("AnalyzeDanger(%q) = false, want true", ansic) } if !strings.Contains(info.Reason, "literal arg -- no runtime expansion") { t.Errorf("AnalyzeDanger(%q).Reason = %q, want containing 'literal arg -- no runtime expansion' (ANSI-C is Quoted=true)", ansic, info.Reason) } } // ============================================================ // CommandInfo 5 context fields wire (alpha.9 D-B drain). // Each sub-claim is one regression test; amplifySyntaxContext // reads the field via SelectorExpr and projects it into // DangerInfo.Reason / Pattern so operators / auditors / TUI see // full syntactic surround, not just "dangerous pattern". // // CommandInfo 5 字段上下文真 wire (alpha.9 D-B drain). 一 sub-claim 一 // 测试; amplifySyntaxContext 经 SelectorExpr 读字段并映射到 // DangerInfo.Reason / Pattern, 运维 / 审计 / TUI 能看到完整语法 // 环境, 不止"dangerous pattern". // ============================================================ // Sub-claim A: InSubshell surfaces in Reason and Pattern prefix. // A dangerous command inside `(...)` must get a "in subshell" clause // and a "(subshell) " pattern prefix, so TUI / audit can distinguish // foreground `rm -rf /` from one running in a subshell (different // process group, different signal reachability). // // Sub-claim A: InSubshell 表现在 Reason 和 Pattern 前缀. `(...)` 内的 // 危险命令应有 "in subshell" clause 和 "(subshell) " pattern 前缀, TUI // / 审计据此区分前台 `rm -rf /` 和 subshell 内执行 (不同进程组, 信号 // 可达性不同). func TestAnalyzeDanger_InSubshell_Annotated(t *testing.T) { sub := "(rm -rf /)" okSub, infoSub := AnalyzeDanger(sub) if !okSub { t.Fatalf("AnalyzeDanger(%q) = false, want true", sub) } if !strings.Contains(infoSub.Reason, "in subshell") { t.Errorf("AnalyzeDanger(subshell).Reason = %q, want containing 'in subshell'", infoSub.Reason) } if !strings.Contains(infoSub.Pattern, "(subshell) ") { t.Errorf("AnalyzeDanger(subshell).Pattern = %q, want containing '(subshell) '", infoSub.Pattern) } // Counter: foreground must NOT carry subshell annotation. // 对照: 前台命令不应有 subshell 标注. fg := "rm -rf /" _, infoFg := AnalyzeDanger(fg) if strings.Contains(infoFg.Reason, "in subshell") { t.Errorf("AnalyzeDanger(fg).Reason = %q, must NOT contain 'in subshell'", infoFg.Reason) } } // Sub-claim B: InPipeline + PipePosition together produce a // "pipe stage N" clause and "| " pattern prefix. InPipeline is the // reliable discriminator (non-pipe commands default PipePosition=0 // which collides with "first stage"). // // Sub-claim B: InPipeline + PipePosition 一起产生 "pipe stage N" clause // 和 "| " pattern 前缀. InPipeline 是可靠区分器 (非管道命令 // PipePosition 默认 0 会与"第一阶段"值冲突). func TestAnalyzeDanger_InPipeline_Annotated(t *testing.T) { pipe := "cat /tmp/file | rm -rf /" okPipe, infoPipe := AnalyzeDanger(pipe) if !okPipe { t.Fatalf("AnalyzeDanger(%q) = false, want true", pipe) } if !strings.Contains(infoPipe.Reason, "pipe stage") { t.Errorf("AnalyzeDanger(pipe).Reason = %q, want containing 'pipe stage'", infoPipe.Reason) } if !strings.Contains(infoPipe.Pattern, "| ") { t.Errorf("AnalyzeDanger(pipe).Pattern = %q, want containing '| '", infoPipe.Pattern) } // Counter: standalone command must NOT carry pipe annotation. // 对照: 独立命令不应有 pipe 标注. standalone := "rm -rf /" _, infoStandalone := AnalyzeDanger(standalone) if strings.Contains(infoStandalone.Reason, "pipe stage") { t.Errorf("AnalyzeDanger(standalone).Reason = %q, must NOT contain 'pipe stage'", infoStandalone.Reason) } } // Sub-claim C: Operator (&&, ||, |, ;) surfaces as "preceded by X" in // Reason. The first command in a list / pipeline inherits parent ctx // (outermost is empty), so only i > 0 commands get this clause; the // Operator != "" gate in amplifySyntaxContext enforces that. // // Sub-claim C: Operator (&&, ||, |, ;) 以 "preceded by X" 在 Reason // 中 surface. list / pipeline 内第一条从父 ctx 继承 (最外层为空), 因此 // 只有 i > 0 的命令得到此 clause; amplifySyntaxContext 内的 // Operator != "" 守护保证这一点. func TestAnalyzeDanger_Operator_Annotated(t *testing.T) { chained := "true && rm -rf /" okCh, infoCh := AnalyzeDanger(chained) if !okCh { t.Fatalf("AnalyzeDanger(%q) = false, want true", chained) } if !strings.Contains(infoCh.Reason, "preceded by &&") { t.Errorf("AnalyzeDanger(chained).Reason = %q, want containing 'preceded by &&'", infoCh.Reason) } // Counter: outermost single command has empty Operator, no annotation. // 对照: 最外层单命令 Operator 为空, 不附 annotation. alone := "rm -rf /" _, infoAlone := AnalyzeDanger(alone) if strings.Contains(infoAlone.Reason, "preceded by") { t.Errorf("AnalyzeDanger(alone).Reason = %q, must NOT contain 'preceded by'", infoAlone.Reason) } } // Sub-claim D: Position > 0 surfaces as "position N" clause. Guards // against spurious "position 0" annotations on the first / only // command; only 2nd+ commands in a list / pipeline get the clause, // disambiguating which command in the chain triggered the alert. // // Sub-claim D: Position > 0 以 "position N" clause 在 Reason 中 surface. // 守护避免"第一条/唯一命令"也被打上 "position 0" 标记; 只有 list / // pipeline 的第 2 条起才获得此 clause, 用于在链中定位具体触发告警的命令. func TestAnalyzeDanger_Position_Annotated(t *testing.T) { // First command safe, second dangerous: `rm -rf /` is at position 1. // 第一条安全, 第二条危险: `rm -rf /` 在 position 1. list := "echo safe; rm -rf /" okList, infoList := AnalyzeDanger(list) if !okList { t.Fatalf("AnalyzeDanger(%q) = false, want true", list) } if !strings.Contains(infoList.Reason, "position 1") { t.Errorf("AnalyzeDanger(list).Reason = %q, want containing 'position 1'", infoList.Reason) } // Counter: standalone dangerous command at Position 0, no clause. // 对照: 独立危险命令 Position 0, 无 clause. alone := "rm -rf /" _, infoAlone := AnalyzeDanger(alone) if strings.Contains(infoAlone.Reason, "position ") { t.Errorf("AnalyzeDanger(alone).Reason = %q, must NOT contain 'position '", infoAlone.Reason) } } // Sub-claim E: Multiple context fields compose cleanly. A command like // `(true && rm -rf /)` exercises Operator (&&) + InSubshell + Position // simultaneously; verifies amplifySyntaxContext joins them with "; " // in Reason and stacks pattern prefixes (structural first: "(subshell) // " before other prefixes). Ensures fields don't clobber each other. // // Sub-claim E: 多字段组合时干净拼接. 命令 `(true && rm -rf /)` 同时 // 触发 Operator (&&) + InSubshell + Position; 验证 amplifySyntaxContext // 在 Reason 中用 "; " 合并, pattern 前缀叠加 (结构性字段在前: "(subshell) " // 在其他前缀之前). 保证字段不互相覆盖. func TestAnalyzeDanger_MultipleContextFields_Annotated(t *testing.T) { combo := "(true && rm -rf /)" ok, info := AnalyzeDanger(combo) if !ok { t.Fatalf("AnalyzeDanger(%q) = false, want true", combo) } // Reason must contain at least "in subshell" and "preceded by &&". // Reason 必含 "in subshell" 和 "preceded by &&". if !strings.Contains(info.Reason, "in subshell") { t.Errorf("combo Reason missing 'in subshell': %q", info.Reason) } if !strings.Contains(info.Reason, "preceded by &&") { t.Errorf("combo Reason missing 'preceded by &&': %q", info.Reason) } // Pattern must carry the "(subshell) " structural prefix. // Pattern 必带 "(subshell) " 结构前缀. if !strings.Contains(info.Pattern, "(subshell) ") { t.Errorf("combo Pattern missing '(subshell) ' prefix: %q", info.Pattern) } // Separator "; " must appear between joined clauses. // 连接符 "; " 必在拼接的 clauses 之间出现. if !strings.Contains(info.Reason, "; ") { t.Errorf("combo Reason missing '; ' separator (multiple clauses not joined): %q", info.Reason) } }