diff --git a/docs/rules.md b/docs/rules.md index a3edd18..e3776e9 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -140,14 +140,22 @@ Common CEL operations include: | Boolean logic | `a && b`, `a || b`, `!a` | | Membership | `value in ["a", "b"]` | | String tests | `contains`, `startsWith`, `endsWith`, `matches` | +| String position/slicing | `indexOf`, `substring(start)`, `substring(start, end)` | | List predicates | `exists`, `all`, `exists_one` | | List range | `items.slice(start, end)` | | Integer indexes | `lists.range(n).exists(i, ...)` | +| Path normalization | `canonical_path(event.file_path)` | | Missing nullable value | `event.exit_code == null` | `matches` uses RE2 regular expressions. CEL string literals require their own escaping; for example, a literal dot is written as `"\\.env"`. +`canonical_path(p)` normalizes path separators, `.`, `..`, and duplicate `/` +segments. It treats each leading `/proc//root` or +`/proc//task//root` as `/`. Relative paths remain relative. It +keeps Windows drive and UNC roots intact. It does not access the filesystem or +resolve other symbolic links. + Action types are alternatives, not layers. A recognized shell action is a `command.exec`, not both a `tool.call` and a `command.exec`; file and network actions are specialized the same way. `tool.call` is the fallback when numbat diff --git a/internal/rule/canonical_path.go b/internal/rule/canonical_path.go new file mode 100644 index 0000000..e4bf204 --- /dev/null +++ b/internal/rule/canonical_path.go @@ -0,0 +1,81 @@ +package rule + +import ( + "path" + "regexp" + "strings" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + + "github.com/perplexityai/numbat/internal/model" +) + +var procRootPath = regexp.MustCompile(`^/proc/(?:(?:self|[1-9][0-9]*)/task/[1-9][0-9]*|self|thread-self|[1-9][0-9]*)/root(?:/+|$)`) + +func canonicalPathBinding(arg ref.Val) ref.Val { + value, ok := arg.(types.String) + if !ok { + return types.MaybeNoSuchOverloadErr(arg) + } + return types.String(canonicalPath(string(value))) +} + +func canonicalPath(value string) string { + value = model.NormalizeEventPath(value) + if value == "" { + return "" + } + if len(value) >= 7 && value[:4] == "//?/" && isWindowsDrivePath(value[4:]) { + value = value[4:] + } else if len(value) >= 8 && strings.EqualFold(value[:8], "//?/UNC/") { + value = "//" + value[8:] + } + if len(value) > 2 && strings.HasPrefix(value, "//") && value[2] != '/' { + return canonicalUNCPath(value) + } + volume := "" + if isWindowsDrivePath(value) { + volume = value[:2] + value = value[2:] + } + for { + if volume == "" { + prefix := procRootPath.FindStringIndex(value) + if prefix != nil { + if prefix[1] == len(value) { + return "/" + } + value = value[prefix[1]-1:] + continue + } + } + clean := path.Clean(value) + if clean == value { + return volume + clean + } + value = clean + } +} + +func isWindowsDrivePath(value string) bool { + return len(value) >= 3 && + ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && + value[1] == ':' && value[2] == '/' +} + +func canonicalUNCPath(value string) string { + parts := strings.SplitN(strings.TrimPrefix(value, "//"), "/", 3) + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return value + } + root := "//" + parts[0] + "/" + parts[1] + if len(parts) == 2 { + return root + } + rest := path.Clean("/" + parts[2]) + if rest == "/" { + return root + } + return root + rest +} diff --git a/internal/rule/canonical_path_test.go b/internal/rule/canonical_path_test.go new file mode 100644 index 0000000..a3c4173 --- /dev/null +++ b/internal/rule/canonical_path_test.go @@ -0,0 +1,178 @@ +package rule + +import ( + "testing" + + "github.com/perplexityai/numbat/internal/model" +) + +func TestCanonicalPathCollapsesTraversalForFileEvents(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.protect_rule_file", + Severity: model.SeverityHigh, + Expr: `event.event_type in ["file.write", "file.delete"] && canonical_path(event.file_path) == "/etc/numbat/rules/protect_numbat.yaml"`, + }) + cases := []struct { + name string + path string + want bool + }{ + {"plain", "/etc/numbat/rules/protect_numbat.yaml", true}, + {"dot_segment", "/etc/numbat/./rules/protect_numbat.yaml", true}, + {"shallow_traversal", "/etc/numbat/rules/x/../protect_numbat.yaml", true}, + {"deep_traversal_depth4", "/etc/numbat/rules/d0/d1/d2/d3/../../../../protect_numbat.yaml", true}, + {"proc_root_prefix", "/proc/self/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_task_root_pid", "/proc/4321/task/8765/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_task_root_self", "/proc/self/task/8765/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_task_root_dot_segment", "/proc/4321/task/./8765/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_task_root_parent_traversal", "/proc/4321/task/8765/root/../etc/numbat/rules/protect_numbat.yaml", true}, + {"nested_proc_root_prefix", "/proc/self/root/proc/thread-self/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"nested_proc_task_root_prefix", "/proc/4321/task/8765/root/proc/self/task/8765/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_root_pid_traversal", "/proc/4321/root/etc/numbat/rules/d0/d1/../../protect_numbat.yaml", true}, + {"proc_root_parent_traversal", "/proc/self/root/../etc/numbat/rules/protect_numbat.yaml", true}, + {"proc_root_dot_prefix", "/proc/./self/root/etc/numbat/rules/protect_numbat.yaml", true}, + {"duplicate_slash", "/etc/numbat//rules/protect_numbat.yaml", true}, + {"three_leading_slashes", "///etc/numbat/rules/protect_numbat.yaml", true}, + {"windows_separators", `\etc\numbat\rules\protect_numbat.yaml`, true}, + {"UNC_path_does_not_become_local", `\\etc\numbat\rules\protect_numbat.yaml`, false}, + {"zero_is_not_a_pid", "/proc/0/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"zero_padded_pid", "/proc/04321/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"task_zero_tid", "/proc/4321/task/0/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"task_zero_padded_tid", "/proc/4321/task/08765/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"task_non_numeric_tid", "/proc/4321/task/current/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"task_self_is_not_a_tid", "/proc/4321/task/self/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"thread_self_has_no_task_segment", "/proc/thread-self/task/8765/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"missing_task_tid", "/proc/4321/task/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"task_root_name_boundary", "/proc/4321/task/8765/rooted/etc/numbat/rules/protect_numbat.yaml", false}, + {"different_proc_entry", "/proc/not-a-pid/root/etc/numbat/rules/protect_numbat.yaml", false}, + {"similar_proc_entry", "/proc/self/rooted/etc/numbat/rules/protect_numbat.yaml", false}, + {"relative_path_stays_relative", "etc/numbat/rules/protect_numbat.yaml", false}, + {"leading_parent_stays_relative", "../etc/numbat/rules/protect_numbat.yaml", false}, + {"unprotected_sibling", "/etc/numbat/rules.example/protect_numbat.yaml", false}, + {"escapes_out", "/etc/numbat/rules/../ordinary.txt", false}, + {"whitespace_is_path_content", " /etc/numbat/rules/protect_numbat.yaml ", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev := model.Event{EventID: "e", EventType: model.EventFileWrite, FilePath: tc.path} + matches, err := eng.Eval(ev) + if err != nil { + t.Fatalf("Eval: %v", err) + } + got := len(matches) == 1 + if got != tc.want { + t.Fatalf("path %q: matched=%v want=%v", tc.path, got, tc.want) + } + }) + } +} + +func TestCanonicalPathKeepsMissingPathEmpty(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.empty_path", + Severity: model.SeverityLow, + Expr: `canonical_path(event.file_path) == ""`, + }) + matches, err := eng.Eval(model.Event{EventType: model.EventCommandExec}) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("matches = %d, want 1", len(matches)) + } +} + +func TestCanonicalPathPreservesWindowsDriveRoot(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.windows_drive_root", + Severity: model.SeverityHigh, + Expr: `canonical_path(event.file_path) == "C:/ProgramData/ssh/administrators_authorized_keys"`, + }) + for _, filePath := range []string{ + `C:\..\ProgramData\ssh\administrators_authorized_keys`, + `\\?\C:\ProgramData\ssh\administrators_authorized_keys`, + } { + ev := model.Event{ + EventID: "e", + EventType: model.EventFileWrite, + FilePath: filePath, + } + matches, err := eng.Eval(ev) + if err != nil { + t.Fatalf("Eval(%q): %v", filePath, err) + } + if len(matches) != 1 { + t.Fatalf("Eval(%q) matches = %d, want 1", filePath, len(matches)) + } + } +} + +func TestCanonicalPathPreservesUNCRoot(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.unc_root", + Severity: model.SeverityLow, + Expr: `canonical_path(event.file_path) == "//server/share/keys/authorized_keys"`, + }) + for _, filePath := range []string{ + `\\server\share\keys\old\..\authorized_keys`, + `\\?\UNC\server\share\keys\authorized_keys`, + `//server/share/../../keys/authorized_keys`, + } { + matches, err := eng.Eval(model.Event{EventType: model.EventFileWrite, FilePath: filePath}) + if err != nil { + t.Fatalf("Eval(%q): %v", filePath, err) + } + if len(matches) != 1 { + t.Fatalf("Eval(%q) matches = %d, want 1", filePath, len(matches)) + } + } +} + +func TestCanonicalPathRejectsNonStringEventField(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.non_string_path", + Severity: model.SeverityLow, + Expr: `canonical_path(event.exit_code) == ""`, + }) + exitCode := 1 + matches, err := eng.Eval(model.Event{EventType: model.EventCommandExec, ExitCode: &exitCode}) + if err == nil { + t.Fatal("Eval error = nil, want type error") + } + if len(matches) != 0 { + t.Fatalf("matches = %d, want 0", len(matches)) + } +} + +func TestCanonicalPathCollapsesTraversalForShellArgv(t *testing.T) { + eng := mustEngine(t, Rule{ + ID: "t.protect_rule_rm", + Severity: model.SeverityHigh, + Enforce: boolPtr(true), + Expr: `event.event_type == "command.exec" && shell_commands.exists(command, command.name in ["rm", "unlink"] && command.argv.exists(a, canonical_path(a) == "/usr/local/bin/numbat"))`, + }) + cases := []struct { + name string + command string + want bool + }{ + {"plain", "rm -f /usr/local/bin/numbat", true}, + {"deep_traversal_depth5", "rm -f /usr/local/bin/d0/d1/d2/d3/d4/../../../../../numbat", true}, + {"proc_root", "rm -f /proc/thread-self/root/usr/local/bin/numbat", true}, + {"proc_task_root", "rm -f /proc/4321/task/8765/root/usr/local/bin/numbat", true}, + {"benign_other", "rm -f /tmp/numbat", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev := model.Event{EventID: "e", EventType: model.EventCommandExec, Command: tc.command} + matches, err := eng.Eval(ev) + if err != nil { + t.Fatalf("Eval: %v", err) + } + got := len(matches) == 1 && matches[0].EnforcementMatch + if got != tc.want { + t.Fatalf("command %q: matched=%v want=%v", tc.command, got, tc.want) + } + }) + } +} diff --git a/internal/rule/engine.go b/internal/rule/engine.go index a3b08a7..91749f4 100644 --- a/internal/rule/engine.go +++ b/internal/rule/engine.go @@ -117,8 +117,15 @@ func newEnv() (*cel.Env, error) { ext.ParseStructTags(true), ), ext.Lists(), + ext.Strings(), cel.Variable("event", cel.MapType(cel.StringType, cel.DynType)), cel.Variable(shellCommandsVariable, cel.ListType(cel.ObjectType("rule.ShellCommand"))), + cel.Function("canonical_path", + cel.Overload("canonical_path_string", + []*cel.Type{cel.StringType}, cel.StringType, + cel.UnaryBinding(canonicalPathBinding), + ), + ), ) } diff --git a/rules/catalog_test.go b/rules/catalog_test.go index 13f7784..d2ad9e1 100644 --- a/rules/catalog_test.go +++ b/rules/catalog_test.go @@ -410,6 +410,7 @@ func TestPersistenceRules(t *testing.T) { {"authorized_keys write", write("/Users/dev/.ssh/authorized_keys"), "persistence.ssh_authorized_keys"}, {"Linux authorized_keys write", write("/home/dev/.ssh/authorized_keys"), "persistence.ssh_authorized_keys"}, + {"Linux authorized_keys proc task-root alias", write("/proc/4321/task/8765/root/home/dev/.ssh/authorized_keys"), "persistence.ssh_authorized_keys"}, {"Windows user authorized_keys write", write("C:/Users/dev/.ssh/authorized_keys"), "persistence.ssh_authorized_keys"}, {"Windows administrator authorized_keys write", write("C:/ProgramData/ssh/administrators_authorized_keys"), "persistence.ssh_authorized_keys"}, {"authorized_keys append", cmd("cat key.pub >> ~/.ssh/authorized_keys"), "persistence.ssh_authorized_keys_command"}, @@ -458,6 +459,8 @@ func TestPrivilegeRules(t *testing.T) { eng := builtinEngine(t) runCases(t, eng, []ruleCase{ {"sudoers file write", write("/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"sudoers dot-segment alias", write("/etc/sudoers.d/./agent"), "privilege.sudoers_tamper"}, + {"sudoers proc task-root alias", write("/proc/4321/task/8765/root/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, {"append nopasswd sudoers", cmd("echo 'agent ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, {"tee sudoers", cmd("printf 'agent ALL=(ALL) NOPASSWD:ALL' | tee -a /etc/sudoers"), "privilege.sudoers_tamper"}, {"visudo command", cmd("EDITOR=tee visudo"), "privilege.sudoers_tamper"}, diff --git a/rules/internal/checked/158bc6790b156ee22867286aa46e5a7cf335aa111e435a2f7dbd6e83e23fcbf7.pb b/rules/internal/checked/158bc6790b156ee22867286aa46e5a7cf335aa111e435a2f7dbd6e83e23fcbf7.pb deleted file mode 100644 index 6fd3f16..0000000 Binary files a/rules/internal/checked/158bc6790b156ee22867286aa46e5a7cf335aa111e435a2f7dbd6e83e23fcbf7.pb and /dev/null differ diff --git a/rules/internal/checked/15aec85709b643c408fb7b9cb43c08579691ec863b9c284f2e3849a569e998ec.pb b/rules/internal/checked/15aec85709b643c408fb7b9cb43c08579691ec863b9c284f2e3849a569e998ec.pb deleted file mode 100644 index 046c414..0000000 Binary files a/rules/internal/checked/15aec85709b643c408fb7b9cb43c08579691ec863b9c284f2e3849a569e998ec.pb and /dev/null differ diff --git a/rules/internal/checked/311dd9c749c7038eccae6818621577f5e4dfa64f4697de12a8b48d8894ead92c.pb b/rules/internal/checked/311dd9c749c7038eccae6818621577f5e4dfa64f4697de12a8b48d8894ead92c.pb new file mode 100644 index 0000000..a70cae1 Binary files /dev/null and b/rules/internal/checked/311dd9c749c7038eccae6818621577f5e4dfa64f4697de12a8b48d8894ead92c.pb differ diff --git a/rules/internal/checked/68d870135b729e90352abf91c7db5a6ab416c693062093e9772decde2de31556.pb b/rules/internal/checked/68d870135b729e90352abf91c7db5a6ab416c693062093e9772decde2de31556.pb deleted file mode 100644 index 437bf9f..0000000 Binary files a/rules/internal/checked/68d870135b729e90352abf91c7db5a6ab416c693062093e9772decde2de31556.pb and /dev/null differ diff --git a/rules/internal/checked/8008a7f1d1a8caeebe5c0137d010ffaa2fc29993e28f0203fbfd0b270ca296a6.pb b/rules/internal/checked/8008a7f1d1a8caeebe5c0137d010ffaa2fc29993e28f0203fbfd0b270ca296a6.pb new file mode 100644 index 0000000..ca8f48e Binary files /dev/null and b/rules/internal/checked/8008a7f1d1a8caeebe5c0137d010ffaa2fc29993e28f0203fbfd0b270ca296a6.pb differ diff --git a/rules/internal/checked/e6634233a808b79f131e95b5a12927ae303b766563660e10f9a6088495bfdec2.pb b/rules/internal/checked/e6634233a808b79f131e95b5a12927ae303b766563660e10f9a6088495bfdec2.pb new file mode 100644 index 0000000..8c26346 Binary files /dev/null and b/rules/internal/checked/e6634233a808b79f131e95b5a12927ae303b766563660e10f9a6088495bfdec2.pb differ diff --git a/rules/persistence/ssh_authorized_keys.yaml b/rules/persistence/ssh_authorized_keys.yaml index 58869be..8e34104 100644 --- a/rules/persistence/ssh_authorized_keys.yaml +++ b/rules/persistence/ssh_authorized_keys.yaml @@ -1,5 +1,5 @@ id: persistence.ssh_authorized_keys -version: "1.2" +version: "1.3" enabled: true title: SSH authorized_keys modification description: |- @@ -9,7 +9,7 @@ severity: critical expr: |- event.event_type == "file.write" && ( - event.file_path.matches("(?i)^(~|\\$HOME|\\$env:(USERPROFILE|HOME)|%USERPROFILE%|/home/[^/]+|/Users/[^/]+|/root|/var/root|/private/var/root|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?$") || - event.file_path.matches("(?i)^([A-Z]:/ProgramData|\\$env:ProgramData|%ProgramData%)/ssh/administrators_authorized_keys$") + canonical_path(event.file_path).matches("(?i)^(~|\\$HOME|\\$env:(USERPROFILE|HOME)|%USERPROFILE%|/home/[^/]+|/Users/[^/]+|/root|/var/root|/private/var/root|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?$") || + canonical_path(event.file_path).matches("(?i)^([A-Z]:/ProgramData|\\$env:ProgramData|%ProgramData%)/ssh/administrators_authorized_keys$") ) tags: [attack.t1098.004] diff --git a/rules/persistence/ssh_authorized_keys_command.yaml b/rules/persistence/ssh_authorized_keys_command.yaml index 1e144d4..67fb7a6 100644 --- a/rules/persistence/ssh_authorized_keys_command.yaml +++ b/rules/persistence/ssh_authorized_keys_command.yaml @@ -1,5 +1,5 @@ id: persistence.ssh_authorized_keys_command -version: "1.6" +version: "1.7" enabled: true title: Command targeted SSH authorized_keys description: |- @@ -11,12 +11,12 @@ expr: |- shell_commands.exists(command, command.redirects.exists(redirect, redirect.op in ["write", "append"] && - redirect.target.matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:[\\\\/]Users[\\\\/][^\\\\/]+)[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:[\\\\/]ProgramData)[\\\\/]ssh[\\\\/]administrators_authorized_keys)$") + canonical_path(redirect.target).matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))/\\.ssh/authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:/ProgramData)/ssh/administrators_authorized_keys)$") ) || ( command.name.matches("(?i)^(tee|truncate|rm|sed)$") && command.argv.exists(arg, - arg.matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:[\\\\/]Users[\\\\/][^\\\\/]+)[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:[\\\\/]ProgramData)[\\\\/]ssh[\\\\/]administrators_authorized_keys)$") + canonical_path(arg).matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))/\\.ssh/authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:/ProgramData)/ssh/administrators_authorized_keys)$") ) && ( !command.name.matches("(?i)^sed$") || @@ -27,7 +27,7 @@ expr: |- command.name.matches("(?i)^(set-content|add-content|clear-content|out-file|remove-item|ri|new-item)$") && lists.range(command.argv.size()).exists(i, i > 0 && - command.argv[i].matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:[\\\\/]Users[\\\\/][^\\\\/]+)[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:[\\\\/]ProgramData)[\\\\/]ssh[\\\\/]administrators_authorized_keys)$") && + canonical_path(command.argv[i]).matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))/\\.ssh/authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:/ProgramData)/ssh/administrators_authorized_keys)$") && ( i == 1 || command.argv[i - 1].matches("(?i)^-(path|literalpath|filepath)$") || @@ -42,10 +42,10 @@ expr: |- command.name.matches("(?i)^(cp|mv|install|copy-item|move-item)$") && command.argv.size() > 2 && ( - command.argv[command.argv.size() - 1].matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:[\\\\/]Users[\\\\/][^\\\\/]+)[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:[\\\\/]ProgramData)[\\\\/]ssh[\\\\/]administrators_authorized_keys)$") || + canonical_path(command.argv[command.argv.size() - 1]).matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))/\\.ssh/authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:/ProgramData)/ssh/administrators_authorized_keys)$") || lists.range(command.argv.size() - 1).exists(i, command.argv[i].matches("(?i)^-destination$") && - command.argv[i + 1].matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:[\\\\/]Users[\\\\/][^\\\\/]+)[\\\\/]\\.ssh[\\\\/]authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:[\\\\/]ProgramData)[\\\\/]ssh[\\\\/]administrators_authorized_keys)$") + canonical_path(command.argv[i + 1]).matches("(?i)^((~|\\$HOME|\\$\\{HOME\\}|/(home/[^/]+|Users/[^/]+|root|var/root|private/var/root))/\\.ssh/authorized_keys2?|(\\$env:USERPROFILE|%USERPROFILE%|[A-Z]:/Users/[^/]+)/\\.ssh/authorized_keys2?|(\\$env:ProgramData|%ProgramData%|[A-Z]:/ProgramData)/ssh/administrators_authorized_keys)$") ) ) ) diff --git a/rules/privilege/sudoers_tamper.yaml b/rules/privilege/sudoers_tamper.yaml index 5e1a28f..9e5996a 100644 --- a/rules/privilege/sudoers_tamper.yaml +++ b/rules/privilege/sudoers_tamper.yaml @@ -1,5 +1,5 @@ id: privilege.sudoers_tamper -version: "1.3" +version: "1.4" enabled: true title: Agent targeted sudoers policy for modification description: |- @@ -10,42 +10,61 @@ severity: high expr: |- ( (event.event_type == "file.write" || event.event_type == "file.delete") && - event.file_path.matches("^/etc/sudoers$|^/etc/sudoers\\.d/[^/]+$") + canonical_path(event.file_path).matches("^/etc/sudoers$|^/etc/sudoers\\.d/[^/]+$") ) || ( (event.event_type == "command.exec" || (event.source_type == "otel" && event.event_type == "command.result")) && shell_commands.exists(command, ( command.name.matches("(?i)^visudo$") && !command.argv.exists(arg, - arg.matches("(?i)^(-c|--check|--help|-h|--version|-V|--export)(=.*)?$") + arg in ["--check", "--help", "--version", "-h", "-V"] || + arg == "--export" || + arg.startsWith("--export=") || + arg.matches("^-[IOPqs]*c") || + arg.matches("^-[IOPqs]*x") ) && ( ( - !command.argv.exists(arg, arg.matches("(?i)^(-f|--file)(=.*)?$")) && command.argv.slice(1, command.argv.size()).all(arg, - arg.matches("(?i)^(-q|-s|--quiet|--strict)$") + arg.matches("^-[IOPqs]+$") || + arg in ["--no-includes", "--owner", "--perms", "--quiet", "--strict"] ) ) || lists.range(command.argv.size()).exists(i, i > 0 && ( - command.argv[i].matches("(?i)^(-f|--file)=/etc/sudoers(\\.d/[^/]+)?$") || ( - command.argv[i].matches("(?i)^(-f|--file)$") && + command.argv[i].startsWith("--file=") && + canonical_path(command.argv[i].substring(7)).matches("^/etc/sudoers(\\.d/[^/]+)?$") + ) || + ( + command.argv[i].matches("^-[IOPqs]*f.+$") && + canonical_path(command.argv[i].substring(command.argv[i].indexOf("f") + 1)).matches("^/etc/sudoers(\\.d/[^/]+)?$") + ) || + ( + (command.argv[i] == "--file" || command.argv[i].matches("^-[IOPqs]*f$")) && i + 1 < command.argv.size() && - command.argv[i + 1].matches("(?i)^/etc/sudoers(\\.d/[^/]+)?$") + canonical_path(command.argv[i + 1]).matches("^/etc/sudoers(\\.d/[^/]+)?$") ) ) + ) || + ( + command.argv.size() > 1 && + canonical_path(command.argv[command.argv.size() - 1]).matches("^/etc/sudoers(\\.d/[^/]+)?$") && + command.argv.slice(1, command.argv.size() - 1).all(arg, + arg.matches("^-[IOPqs]+$") || + arg in ["--no-includes", "--owner", "--perms", "--quiet", "--strict"] + ) ) ) ) || command.redirects.exists(redirect, redirect.op in ["write", "append"] && - redirect.target.matches("(?i)^/etc/sudoers(\\.d/[^/]+)?$") + canonical_path(redirect.target).matches("(?i)^/etc/sudoers(\\.d/[^/]+)?$") ) || lists.range(command.argv.size()).exists(i, i > 0 && - command.argv[i].matches("(?i)^/etc/sudoers(\\.d/[^/]+)?$") && + canonical_path(command.argv[i]).matches("(?i)^/etc/sudoers(\\.d/[^/]+)?$") && ( command.name.matches("(?i)^(rm|truncate|tee)$") || ( diff --git a/rules/release_precision_policy_test.go b/rules/release_precision_policy_test.go index b92bf88..545f7ca 100644 --- a/rules/release_precision_policy_test.go +++ b/rules/release_precision_policy_test.go @@ -19,19 +19,34 @@ func TestReleasePrecisionPrivilegePolicy(t *testing.T) { {"Windows protected path in an unrelated value stays quiet", cmd(`Write-Output C:\Windows\System32\config\SAM /grant Everyone:F`), ""}, {"visudo validation is not a modification", cmd("visudo -c"), ""}, {"combined visudo validation flags are not a modification", cmd("visudo -cf /etc/sudoers"), ""}, + {"clustered attached visudo validation is not a modification", cmd("visudo -qcf/etc/sudoers"), ""}, + {"clustered no-includes validation is not a modification", cmd("visudo -Icf/etc/sudoers"), ""}, {"visudo help is not a modification", cmd("visudo --help"), ""}, {"visudo export is not a modification", cmd("visudo --export=/tmp/sudoers.json"), ""}, + {"short visudo export is not a modification", cmd("visudo -f /etc/sudoers -x /tmp/sudoers.json"), ""}, {"visudo against a repository fixture stays quiet", cmd("visudo -f ./testdata/sudoers"), ""}, + {"short visudo file argument beginning with equals stays relative", cmd("visudo -f=/etc/sudoers"), ""}, {"sudoers backup path stays quiet", cmd("rm /etc/sudoers.backup"), ""}, {"repository sudoers structured write stays quiet", write("/repo/etc/sudoers"), ""}, {"active sudoers structured delete", model.Event{EventType: model.EventFileDelete, FilePath: "/etc/sudoers"}, "privilege.sudoers_tamper"}, + {"sudoers redirect through proc task-root alias", cmd("printf policy > /proc/4321/task/8765/root/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, {"sudoers PowerShell WhatIf still records intent", cmd("Set-Content /etc/sudoers test -WhatIf"), "privilege.sudoers_tamper"}, {"visudo against active policy", cmd("visudo --file=/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo attached short file against active policy", cmd("visudo -f/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo clustered file option against active policy", cmd("visudo -qf /etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo clustered attached file against active policy", cmd("visudo -qf/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo clustered edit options target default policy", cmd("visudo -qs"), "privilege.sudoers_tamper"}, + {"visudo no-includes option targets default policy", cmd("visudo -I"), "privilege.sudoers_tamper"}, + {"visudo clustered no-includes and file option", cmd("visudo -If/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo positional active policy", cmd("visudo /etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo combined file through proc task-root alias", cmd("visudo --file=/proc/4321/task/8765/root/etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"visudo combined file through traversal alias", cmd("visudo --file=/etc/numbat/../sudoers"), "privilege.sudoers_tamper"}, {"later visudo validation cannot suppress active policy edit", cmd("visudo --file=/etc/sudoers.d/agent; visudo -c"), "privilege.sudoers_tamper"}, {"later visudo help cannot suppress active policy edit", cmd("EDITOR=tee visudo; visudo --help"), "privilege.sudoers_tamper"}, {"quoted sudoers fixture is not a modification", cmd(`echo "tee /etc/sudoers.d/agent"`), ""}, {"quoted sudoers example cannot suppress a real mutation", cmd(`printf policy > /etc/sudoers.d/agent; echo "printf example > /etc/sudoers"`), "privilege.sudoers_tamper"}, {"sudoers copy target", cmd("cp /tmp/policy /etc/sudoers.d/agent"), "privilege.sudoers_tamper"}, + {"cp suffix beginning with t is not target-directory", cmd("cp -St/etc/sudoers.d /tmp/policy /tmp/dest"), ""}, {"moving active sudoers away is tampering", cmd("mv /etc/sudoers /tmp/sudoers"), "privilege.sudoers_tamper"}, {"moving active sudoers away with PowerShell is tampering", cmd("Move-Item -Path /etc/sudoers -Destination /tmp/sudoers"), "privilege.sudoers_tamper"}, {"sudoers path used as PowerShell value stays quiet", cmd(`Set-Content -Path /tmp/note -Value '/etc/sudoers.d/agent'`), ""}, @@ -59,6 +74,8 @@ func TestReleasePrecisionPersistencePolicy(t *testing.T) { {"repository authorized-keys fixture stays quiet", cmd("printf key >> /repo/testdata/.ssh/authorized_keys"), ""}, {"home-shaped authorized-keys fixture stays quiet", cmd("printf key >> /repo/home/dev/.ssh/authorized_keys"), ""}, {"home authorized-keys target", cmd("printf key >> ~/.ssh/authorized_keys"), "persistence.ssh_authorized_keys_command"}, + {"home authorized-keys duplicate separator", cmd("printf key >> /home/dev/.ssh//authorized_keys"), "persistence.ssh_authorized_keys_command"}, + {"home authorized-keys proc task-root alias", cmd("printf key >> /proc/self/task/8765/root/home/dev/.ssh/authorized_keys"), "persistence.ssh_authorized_keys_command"}, {"authorized-keys PowerShell WhatIf still records intent", cmd("Set-Content ~/.ssh/authorized_keys key -WhatIf"), "persistence.ssh_authorized_keys_command"}, {"repository dotfile stays quiet", write("/repo/.zshrc"), ""}, {"repository dotfile command stays quiet", cmd("printf x > /repo/.zshrc"), ""}, @@ -108,6 +125,9 @@ func TestReleasePrecisionPersistencePolicy(t *testing.T) { {"authorized keys is the PowerShell copy source stays quiet", cmd(`Copy-Item ~/.ssh/authorized_keys /tmp/keys`), ""}, {"authorized keys is the named PowerShell copy source stays quiet", cmd(`Copy-Item -Path ~/.ssh/authorized_keys /tmp/keys`), ""}, {"authorized keys is the PowerShell copy destination", cmd(`Copy-Item /tmp/keys ~/.ssh/authorized_keys`), "persistence.ssh_authorized_keys_command"}, + {"authorized keys path used as mv backup suffix stays quiet", cmd(`mv --suffix ~/.ssh/authorized_keys /tmp/source /tmp/dest`), ""}, + {"cp suffix beginning with t is not an SSH target directory", cmd(`cp -St~/.ssh /tmp/authorized_keys /tmp/dest`), ""}, + {"Windows drive root traversal reaches administrator authorized keys", write(`C:\..\ProgramData\ssh\administrators_authorized_keys`), "persistence.ssh_authorized_keys"}, {"commandless authorized-keys redirect", cmd("> ~/.ssh/authorized_keys"), "persistence.ssh_authorized_keys_command"}, {"quoted authorized-keys example cannot suppress a real mutation", cmd(`printf key > ~/.ssh/authorized_keys; echo "printf example > ~/.ssh/authorized_keys"`), "persistence.ssh_authorized_keys_command"}, {"commandless git-hook redirect", cmd("> .git/hooks/pre-commit"), "persistence.git_hook_write"},