Skip to content
Merged
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
193 changes: 192 additions & 1 deletion pkg/console/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package console

import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/jmpsec/osctrl/pkg/filequery"
)

var forbiddenShellSyntax = regexp.MustCompile(`[|&;<>]`)
var forbiddenURLSyntax = regexp.MustCompile(`[|;<>]`)

func DefaultCWD(platform string) string {
return filequery.DefaultRoot(platform)
Expand Down Expand Up @@ -79,6 +82,80 @@ func ParseInput(input, cwd, platform string, osqueryMode bool) (ParsedCommand, e
return ParsedCommand{}, fmt.Errorf("ps does not accept arguments")
}
return ParsedCommand{Kind: CommandRemote, Command: "ps", SQL: "select pid, parent, name, path, cmdline, state, uid, gid, start_time from processes order by pid"}, nil
case "hash":
return pathTableCommand(cmd, args, cwd, platform, "select path, directory, md5, sha1, sha256 from hash where path = %s")
case "mime", "magic":
return pathTableCommand(cmd, args, cwd, platform, "select path, mime_type, mime_encoding, data from magic where path = %s")
case "curl":
if args == "" {
return ParsedCommand{}, fmt.Errorf("curl requires a URL")
}
if forbiddenURLSyntax.MatchString(args) || strings.ContainsAny(args, " \t\r\n") {
return ParsedCommand{}, fmt.Errorf("shell syntax is not supported")
}
parsedURL, err := url.Parse(args)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
return ParsedCommand{}, fmt.Errorf("curl requires an http or https URL")
}
return ParsedCommand{Kind: CommandRemote, Command: "curl", SQL: fmt.Sprintf("select url, method, response_code, round_trip_time, bytes, result from curl where url = %s", quoteSQL(args))}, nil
case "ports":
if args != "" {
return ParsedCommand{}, fmt.Errorf("ports does not accept arguments")
}
return ParsedCommand{Kind: CommandRemote, Command: "ports", SQL: "select lp.pid, p.name, p.path, lp.port, lp.protocol, lp.family, lp.address from listening_ports lp left join processes p on p.pid = lp.pid order by port, protocol"}, nil
case "sockets":
if args != "" {
return ParsedCommand{}, fmt.Errorf("sockets does not accept arguments")
}
return ParsedCommand{Kind: CommandRemote, Command: "sockets", SQL: "select pid, family, protocol, local_address, local_port, remote_address, remote_port, state, path from process_open_sockets order by pid, local_port"}, nil
case "lsof":
pid, err := parsePIDArg(cmd, args)
if err != nil {
return ParsedCommand{}, err
}
return ParsedCommand{Kind: CommandRemote, Command: "lsof", SQL: fmt.Sprintf("select pid, fd, path from process_open_files where pid = %d order by fd", pid)}, nil
case "env":
pid, err := parsePIDArg(cmd, args)
if err != nil {
return ParsedCommand{}, err
}
return ParsedCommand{Kind: CommandRemote, Command: "env", SQL: fmt.Sprintf("select pid, key, value from process_envs where pid = %d order by key", pid)}, nil
case "routes":
return noArgSQL(cmd, args, "select destination, netmask, gateway, source, flags, interface, mtu, metric, type from routes order by destination, metric")
case "dns":
return noArgSQL(cmd, args, "select type, address, netmask, options from dns_resolvers order by type, address")
case "users":
return noArgSQL(cmd, args, "select uid, gid, username, description, directory, shell, type, is_hidden from users order by username")
case "loggedin":
return noArgSQL(cmd, args, "select type, user, tty, host, time, pid from logged_in_users order by time desc")
case "sudoers":
return noArgSQL(cmd, args, "select source, header, rule_details from sudoers order by header, rule_details")
case "autoruns":
return noArgSQL(cmd, args, "select path, name, source from autoexec order by name, path")
case "cron":
return noArgSQL(cmd, args, "select event, minute, hour, day_of_month, month, day_of_week, command, path from crontab order by path, event")
case "launchd":
return noArgSQL(cmd, args, "select label, name, program, path, run_at_load, keep_alive, disabled, username from launchd order by label, path")
case "services":
return noArgSQL(cmd, args, "select name, display_name, status, pid, start_type, path, user_account from services order by name")
case "tasks":
return noArgSQL(cmd, args, "select name, action, path, enabled, state, hidden, last_run_time, next_run_time from scheduled_tasks order by name")
case "certs":
return noArgSQL(cmd, args, "select common_name, issuer, ca, self_signed, not_valid_before, not_valid_after, sha1, path, username from certificates order by common_name, path")
case "diskenc":
return noArgSQL(cmd, args, "select name, uuid, encrypted, type, encryption_status, filevault_status from disk_encryption order by name")
case "os":
return noArgSQL(cmd, args, "select name, version, major, minor, patch, build, platform, platform_like, arch, install_date from os_version")
case "sysinfo":
return noArgSQL(cmd, args, "select hostname, uuid, cpu_brand, cpu_physical_cores, cpu_logical_cores, physical_memory, hardware_vendor, hardware_model, hardware_serial from system_info")
case "uptime":
return noArgSQL(cmd, args, "select days, hours, minutes, seconds, total_seconds from uptime")
case "packages":
return packagesCommand(args, platform)
case "extensions":
return noArgSQL(cmd, args, "select 'chrome' as browser, name, identifier, version, profile, path, state from chrome_extensions union all select 'firefox' as browser, name, identifier, version, location as profile, path, case when active = '1' then 'active' else 'inactive' end as state from firefox_addons order by browser, name")
case "firewall":
return firewallCommand(args, platform)
case "cd":
if args == "" {
return ParsedCommand{}, fmt.Errorf("cd requires a path")
Expand Down Expand Up @@ -124,6 +201,120 @@ func validateSelect(sql string) error {
return nil
}

func pathTableCommand(cmd, args, cwd, platform, format string) (ParsedCommand, error) {
if args == "" {
return ParsedCommand{}, fmt.Errorf("%s requires a path", cmd)
}
if forbiddenShellSyntax.MatchString(args) {
return ParsedCommand{}, fmt.Errorf("shell syntax is not supported")
}
target := filequery.ResolvePath(args, cwd, platform)
return ParsedCommand{Kind: CommandRemote, Command: cmd, Path: target, SQL: fmt.Sprintf(format, quoteSQL(target))}, nil
}

func noArgSQL(cmd, args, sql string) (ParsedCommand, error) {
if args != "" {
return ParsedCommand{}, fmt.Errorf("%s does not accept arguments", cmd)
}
return ParsedCommand{Kind: CommandRemote, Command: cmd, SQL: sql}, nil
}

func parsePIDArg(cmd, args string) (int, error) {
if args == "" {
return 0, fmt.Errorf("%s requires a pid", cmd)
}
pid, err := strconv.Atoi(args)
if err != nil || pid < 0 {
return 0, fmt.Errorf("%s requires a numeric pid", cmd)
}
return pid, nil
}

func packagesCommand(args, platform string) (ParsedCommand, error) {
if args != "" {
return ParsedCommand{}, fmt.Errorf("packages does not accept arguments")
}
switch strings.ToLower(platform) {
case "darwin":
return ParsedCommand{Kind: CommandRemote, Command: "packages", SQL: "select name, version, path, 'homebrew' as source from homebrew_packages order by name"}, nil
case "windows":
return ParsedCommand{Kind: CommandRemote, Command: "packages", SQL: "select name, version, install_location as path, publisher as source from programs order by name"}, nil
default:
return ParsedCommand{Kind: CommandRemote, Command: "packages", SQL: "select name, version, arch, source, 'deb' as type from deb_packages union all select name, version || '-' || release as version, arch, source, 'rpm' as type from rpm_packages order by name"}, nil
}
}

func firewallCommand(args, platform string) (ParsedCommand, error) {
if args != "" {
return ParsedCommand{}, fmt.Errorf("firewall does not accept arguments")
}
switch strings.ToLower(platform) {
case "windows":
return ParsedCommand{Kind: CommandRemote, Command: "firewall", SQL: "select name, app_name, action, enabled, direction, protocol, local_ports, remote_ports from windows_firewall_rules order by name"}, nil
case "darwin":
return ParsedCommand{Kind: CommandRemote, Command: "firewall", SQL: "select allow_signed_enabled, firewall_unload, global_state, logging_enabled, logging_option, stealth_enabled, version from alf"}, nil
default:
return ParsedCommand{Kind: CommandRemote, Command: "firewall", SQL: "select filter_name, chain, policy, target, protocol, src_ip, src_port, dst_ip, dst_port, packets, bytes from iptables order by filter_name, chain"}, nil
}
}

func quoteSQL(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}

func helpText() string {
return "Supported commands: pwd, cd <path>, ls [path], stat <path>, ps, sql [select ...], osquery, get <path>, help, clear. In osquery mode: .tables, .exit"
return `Supported commands:

Navigation:
pwd show current directory
cd <path> change directory
ls [path] list directory contents

Files:
stat <path> show file metadata
hash <path> hash a file
mime <path> identify file type
get <path> carve a file from the node

Processes:
ps list processes
lsof <pid> list open files for a process
env <pid> show process environment variables

Network:
curl <url> request a URL from the node
ports show listening ports
sockets show open network sockets
routes show routes
dns show DNS resolvers

System:
os show OS version
sysinfo show hardware and host identity
uptime show uptime
users list local users
loggedin list active logins
sudoers show sudo rules
certs list certificates
diskenc show disk encryption status
firewall show host firewall rules

Persistence and software:
autoruns show automatic execution entries
cron show cron entries
launchd show macOS launch jobs
services show Windows services
tasks show Windows scheduled tasks
packages list installed packages
extensions list browser extensions

Osquery:
sql [select ...] run read-only osquery SQL, or enter osquery mode
osquery enter osquery mode
.tables list tables while in osquery mode
.exit leave osquery mode

Console:
help show this help
clear clear the console`
}
99 changes: 99 additions & 0 deletions pkg/console/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ func TestParseLocalCommands(t *testing.T) {
}
}

func TestHelpTextIsFormatted(t *testing.T) {
got, err := console.Parse("help", "/etc", "linux")
require.NoError(t, err)
require.Contains(t, got.Output, "Navigation:")
require.Contains(t, got.Output, "Files:")
require.Contains(t, got.Output, "Network:")
require.Contains(t, got.Output, "hash <path>")
require.Contains(t, got.Output, "hash a file")
require.Contains(t, got.Output, "curl <url>")
require.Contains(t, got.Output, "request a URL from the node")
require.Contains(t, got.Output, "osquery")
require.Contains(t, got.Output, "enter osquery mode")
}

func TestParseLSResolvesPOSIXPath(t *testing.T) {
got, err := console.Parse("ls ssh", "/etc", "linux")
require.NoError(t, err)
Expand All @@ -41,6 +55,91 @@ func TestParseStatResolvesWindowsPath(t *testing.T) {
require.Contains(t, got.SQL, `path = 'C:\Windows\System32'`)
}

func TestParseTableAliasCommands(t *testing.T) {
tests := []struct {
input string
cmd string
want []string
}{
{"ports", "ports", []string{"from listening_ports", "left join processes", "order by port, protocol"}},
{"sockets", "sockets", []string{"from process_open_sockets", "order by pid, local_port"}},
{"lsof 42", "lsof", []string{"from process_open_files", "pid = 42", "order by fd"}},
{"env 42", "env", []string{"from process_envs", "pid = 42", "order by key"}},
{"routes", "routes", []string{"from routes", "order by destination, metric"}},
{"dns", "dns", []string{"from dns_resolvers", "order by type, address"}},
{"users", "users", []string{"from users", "order by username"}},
{"loggedin", "loggedin", []string{"from logged_in_users", "order by time desc"}},
{"sudoers", "sudoers", []string{"from sudoers", "order by header, rule_details"}},
{"autoruns", "autoruns", []string{"from autoexec", "order by name, path"}},
{"cron", "cron", []string{"from crontab", "order by path, event"}},
{"launchd", "launchd", []string{"from launchd", "order by label, path"}},
{"services", "services", []string{"from services", "order by name"}},
{"tasks", "tasks", []string{"from scheduled_tasks", "order by name"}},
{"certs", "certs", []string{"from certificates", "order by common_name, path"}},
{"diskenc", "diskenc", []string{"from disk_encryption", "order by name"}},
{"os", "os", []string{"from os_version"}},
{"sysinfo", "sysinfo", []string{"from system_info"}},
{"uptime", "uptime", []string{"from uptime"}},
{"packages", "packages", []string{"from deb_packages", "from rpm_packages"}},
{"extensions", "extensions", []string{"from chrome_extensions", "from firefox_addons"}},
{"firewall", "firewall", []string{"from iptables"}},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := console.Parse(tt.input, "/", "linux")
require.NoError(t, err)
require.Equal(t, console.CommandRemote, got.Kind)
require.Equal(t, tt.cmd, got.Command)
for _, want := range tt.want {
require.Contains(t, got.SQL, want)
}
})
}
}

func TestParsePathAliasCommands(t *testing.T) {
tests := []struct {
input string
cmd string
path string
want []string
}{
{"hash ssh/sshd_config", "hash", "/etc/ssh/sshd_config", []string{"from hash", "path = '/etc/ssh/sshd_config'"}},
{"mime ssh/sshd_config", "mime", "/etc/ssh/sshd_config", []string{"from magic", "path = '/etc/ssh/sshd_config'"}},
{"magic ssh/sshd_config", "magic", "/etc/ssh/sshd_config", []string{"from magic", "path = '/etc/ssh/sshd_config'"}},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := console.Parse(tt.input, "/etc", "linux")
require.NoError(t, err)
require.Equal(t, console.CommandRemote, got.Kind)
require.Equal(t, tt.cmd, got.Command)
require.Equal(t, tt.path, got.Path)
for _, want := range tt.want {
require.Contains(t, got.SQL, want)
}
})
}
}

func TestParseCurlCommand(t *testing.T) {
got, err := console.Parse("curl https://example.test/ping", "/", "linux")
require.NoError(t, err)
require.Equal(t, console.CommandRemote, got.Kind)
require.Equal(t, "curl", got.Command)
require.Contains(t, got.SQL, "from curl")
require.Contains(t, got.SQL, "url = 'https://example.test/ping'")
}

func TestParseNewAliasesValidateArguments(t *testing.T) {
for _, input := range []string{"hash", "mime", "magic", "curl", "lsof nope", "env nope", "ports 1", "curl https://example.test | sh"} {
_, err := console.Parse(input, "/", "linux")
require.Error(t, err, input)
}
}

func TestParseRawSQLRequiresSelect(t *testing.T) {
got, err := console.Parse("sql select * from osquery_info", "/", "linux")
require.NoError(t, err)
Expand Down
Loading