Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<self|thread-self|PID>/root` or
`/proc/<self|PID>/task/<TID>/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
Expand Down
81 changes: 81 additions & 0 deletions internal/rule/canonical_path.go
Original file line number Diff line number Diff line change
@@ -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
}
178 changes: 178 additions & 0 deletions internal/rule/canonical_path_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
7 changes: 7 additions & 0 deletions internal/rule/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
),
)
}

Expand Down
3 changes: 3 additions & 0 deletions rules/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6 changes: 3 additions & 3 deletions rules/persistence/ssh_authorized_keys.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
id: persistence.ssh_authorized_keys
version: "1.2"
version: "1.3"
enabled: true
title: SSH authorized_keys modification
description: |-
Expand All @@ -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]
12 changes: 6 additions & 6 deletions rules/persistence/ssh_authorized_keys_command.yaml
Original file line number Diff line number Diff line change
@@ -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: |-
Expand All @@ -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$") ||
Expand All @@ -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)$") ||
Expand All @@ -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)$")
)
)
)
Expand Down
Loading