privexec

package
v0.54.3 Latest Latest
Warning

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

Go to latest
Published: Sep 6, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package privexec is the only way the agent runs a command as root.

Every privileged command is declared in a registry. Run refuses any command that is not declared, so "the agent cannot invent a root command" is true by construction rather than by discipline. The same registry generates /etc/sudoers.d/ghostpsy, so the grant on the host and the agent's behaviour cannot drift apart.

Index

Constants

View Source
const GrantPath = "/etc/sudoers.d/ghostpsy"

GrantPath is where the grant lives once a host has one. It is named here because this package writes it, and read back through GrantFile.

Variables

View Source
var (
	InstallDropInStep = InstallDropIn(placeholderChange)
	ShowDropInStep    = ShowDropIn(placeholderChange)
	RemoveDropInStep  = RemoveDropIn(placeholderChange.Setting)
)

The three templates a catalogue step can name, for the case where the setting and the value arrive with the request rather than being fixed by the action.

View Source
var ErrBadParam = errors.New("privexec: parameter is not allowed")

ErrBadParam means a value did not match what the command declared, or a declared value was missing, or one arrived that nothing declared.

View Source
var ErrNotDeclared = errors.New("privexec: command is not declared")

ErrNotDeclared is returned when a caller asks for an ID the registry does not know. It is the package's whole reason to exist.

View Source
var ErrNotInstalled = errors.New("privexec: command is not installed on this host")

ErrNotInstalled means the declared binary is not on this host. It is a normal situation — not every server runs nginx — so callers treat it as "no data" rather than as a failure.

Functions

func AnyDeclaredMatches

func AnyDeclaredMatches(pattern *regexp.Regexp) bool

AnyDeclaredMatches reports whether any declared command's ID matches pattern.

It lets a caller check at startup that a command chosen from a person's values could resolve to something, instead of finding out half way through a fix on a customer's server that a template has a typo in it.

func Applies

func Applies(id ID) (bool, string)

Applies reports whether a declared command can do anything on this host, and says why not when it cannot.

The question is the one NeedsPath already answers for the grant file: is the software this command drives actually here? It was only ever asked when writing the sudo rules. Asking it again before running turns "this failed" into "this was not part of the work", which on some machines is the truth.

An Ubuntu 14.04 host is the case. The apt action writes two drop-in files and then enables a systemd unit. There is no systemd, and there is nothing to enable: /etc/cron.daily/apt reads APT::Periodic and does the work. The two files were the whole fix, and failing on the third step undid them.

A missing *binary* is deliberately not covered here. That is a real failure — the software is declared to be here and its command is not — and it should be reported as one.

func Change

func Change(s confedit.Setting) confedit.Change

Change is the setting at its first allowed value.

Every value of a setting goes to the same destination, so any of them gives the path the undo needs. Taking the first is not a choice about which value to use — there is only one path to name.

func Declared

func Declared(id ID) bool

Declared reports whether id names a command in the registry.

It lets a caller check its own wiring at startup instead of finding out half way through a fix on a customer's server that a step names a command the sudo grant does not cover.

func Display

func Display(id ID, values Values) string

Display renders a declared command as a person would type it, so the terminal output a customer reads matches what actually ran.

It shows sudo, because that is the truth of it, and it shows the filled-in values, because the whole point of showing the command is that the reader can check it. It is for reading only: nothing parses this back.

func Installed

func Installed(binary string) bool

Installed reports whether a binary is on this machine, where sudo will look for it.

This is how the runtime picks the variant of an action that fits the distro, so it has to search the same directories the grant pins and the command runs with — never the caller's PATH. See resolve for what that mistake cost the first time.

func Sudoers

func Sudoers(user string) string

Sudoers renders the privilege grant for user, from the same registry Run consults. One source, two consumers: the file on disk and the agent's behaviour cannot drift apart.

func UnitPath

func UnitPath(unit string) string

UnitPath is the unit file a grant depends on, so the file grants nothing for software this server does not have.

A unit may live in either directory depending on whether the distribution shipped it or somebody added it, and NeedsPath takes one path — so this returns the packaged location, which is where every unit we name comes from. A hand-written unit in /etc/systemd/system is not something ghostpsy configures.

Types

type Command

type Command struct {
	Binary string

	// Args is the fixed argument list. An argument may contain a {name}
	// placeholder, which must be matched by an entry in Params — see params.go
	// for why a filled-in value is the most closely checked part of a command.
	Args []string

	// Params declares which values a caller fills in, and the exact shape each
	// one accepts. Empty for every read: those commands are fixed text.
	Params []Param

	// Unprivileged marks a command that needs no privilege at all. It runs
	// directly, never through sudo, and no grant is written for it.
	//
	// A fix needs to check its own work — is the service running, did the
	// setting take — and most of those checks need no privilege. Declaring them
	// here anyway keeps one readable list of everything an action can run;
	// granting them root would break the rule that a shorter grant file is a
	// more trustworthy one.
	Unprivileged bool

	// Why says, in plain words, what this command is for. It is printed as a
	// comment above the grant, because a privilege file a sysadmin cannot read
	// is not the promise we made.
	Why string

	// NeedsPath is a directory or file that must exist on this host for the grant
	// to be written. Empty means the command applies anywhere.
	//
	// The grant file promises it "grants nothing for software you do not have", and
	// checking the binary is not enough to keep that promise. Found on rocky-9: it
	// has no /etc/apt/apt.conf.d, but /usr/bin/install exists on every Linux, so the
	// two apt grants were written on a machine where they can never apply.
	NeedsPath string

	// Env is the environment the command needs to behave predictably, for
	// example LC_ALL=C so its output stays parseable. It must be declared here
	// rather than set at the call site: sudo deletes the environment by
	// default, so the sudoers generator has to emit a matching per-command
	// "Defaults! env_keep" line. A caller cannot know to ask for that.
	Env []string
}

Command is one declared privileged command.

type ID

type ID string

ID names a declared command. Collectors reference commands by ID, never by building an argument list of their own.

const (
	// Firewall — the biggest gap measured: 76% of this section is lost
	// without privilege, including every rule count and both default policies.
	FirewallIptablesSave     ID = "firewall.iptables_save"
	FirewallNftListRuleset   ID = "firewall.nft_list_ruleset"
	FirewallUfwStatusVerbose ID = "firewall.ufw_status_verbose"

	// systemd — measured: these three lose data as an unprivileged user.
	SystemdDefaultTarget ID = "systemd.default_target"
	SystemdFailedUnits   ID = "systemd.failed_units"
	SystemdListTimers    ID = "systemd.list_timers"

	// Services — measured: nginx loses its whole TLS posture without this.
	NginxDumpConfig ID = "nginx.dump_config"

	// SSHDumpConfig is the effective sshd configuration, which only sshd itself
	// can work out: it is the main file, plus every drop-in, plus the built-in
	// defaults, resolved in the order sshd applies them. Reading sshd_config
	// would answer a different question and answer it wrongly — a drop-in shipped
	// by cloud-init routinely overrides what that file says.
	SSHDumpConfig ID = "ssh.dump_config"

	// Scheduling — found by tracing, not by scanning: this one hides inside a
	// [][]string literal and no search for exec.Command would show it.
	CrontabListRoot ID = "cron.crontab_list_root"
	CrontabListSelf ID = "cron.crontab_list_self"

	// Privileged reads, every one a command a reviewer already knows.
	//
	// These used to be `ghostpsy read-shadow` and friends: our own binary,
	// printing a summary. It was safe, and it was unreadable. A security team
	// auditing /etc/sudoers.d/ghostpsy could not tell what ran as root without
	// reading our source, and a team that has to read your source to approve you
	// does not approve you.
	//
	// So each one is now a standard command whose output is already safe to hand
	// over, and the counting happens afterwards, unprivileged. `passwd -S -a`
	// prints an account status per line and never a hash; that is the whole
	// argument, and it applies to all four.
	GrantFile ID = "read.grant_file"

	// ShadowAccountStatus and LastlogAll together answer what read-shadow did:
	// how many accounts are locked, how many have no password, how many have
	// never logged in. Neither prints password material of any kind.
	ShadowAccountStatus ID = "read.shadow_account_status"
	LastlogAll          ID = "read.lastlog_all"

	// SudoersText is every sudo rule on the host, with the file each came from.
	// `.` matches any non-empty line, so this is "print these files, numbered".
	SudoersText ID = "read.sudoers_text"

	// AuthorizedKeysFiles finds which accounts still have an SSH key, which is
	// what stops a hardening change locking the operator out of their own
	// server. /etc/passwd is world-readable, so the agent already knows the home
	// directories; the only thing it needs root for is whether the key file is
	// there and not empty.
	AuthorizedKeysFiles ID = "read.authorized_keys_files"

	// Services. Restarting, stopping and switching on are declared one command per
	// service in service.go, from the list of services ghostpsy actually configures —
	// `systemctl restart *` was a grant to restart anything on the machine.
	ServiceStatus    ID = "service.status"
	ServiceIsActive  ID = "service.is_active"
	ServiceIsEnabled ID = "service.is_enabled"

	// SSH. Checking the config before reloading is what stops a bad edit from
	// leaving a server nobody can log into.
	SSHTestConfig ID = "ssh.test_config"

	// Firewall, ufw — Debian and Ubuntu. `ufw --dry-run` is a real dry run: it
	// prints the exact rules it would install without installing them.
	UfwDryRunEnable    ID = "firewall.ufw_dry_run_enable"
	UfwDryRunAllowPort ID = "firewall.ufw_dry_run_allow_port"
	UfwAllowPort       ID = "firewall.ufw_allow_port"
	UfwEnable          ID = "firewall.ufw_enable"
	UfwDisable         ID = "firewall.ufw_disable"

	// Firewall, firewalld — the RHEL family. It has no dry run, so the preview
	// shows what is configured now and the run adds the port to the permanent
	// rules *before* the firewall starts.
	FirewalldState        ID = "firewall.firewalld_state"
	FirewalldListAll      ID = "firewall.firewalld_list_all"
	FirewalldAddPort      ID = "firewall.firewalld_add_port"
	FirewalldRemovePort   ID = "firewall.firewalld_remove_port"
	FirewalldReload       ID = "firewall.firewalld_reload"
	FirewalldRuntimePorts ID = "firewall.firewalld_runtime_ports"

	// Automatic security updates.
	UnattendedUpgradeDryRun ID = "updates.unattended_upgrade_dry_run"

	// Time. The package is named in the command, not passed to it — see the
	// declarations for why that matters.
	// Whether the clock daemon is running, asked of the process table rather than
	// of systemd. `systemctl is-active` cannot answer it on a machine that has no
	// systemd, and Ubuntu 14.04 is exactly the machine this action is for.
	NTPDaemonRunning    ID = "time.ntp_daemon_running"
	ChronyDaemonRunning ID = "time.chrony_daemon_running"

	AptInstallNTP            ID = "time.apt_install_ntp"
	AptSimulateInstallNTP    ID = "time.apt_simulate_install_ntp"
	DnfInstallChrony         ID = "time.dnf_install_chrony"
	DnfSimulateInstallChrony ID = "time.dnf_simulate_install_chrony"

	// Setting the clock, as opposed to installing something that will get round
	// to it. A freshly installed ntpd needs several poll cycles before it will
	// step a badly wrong clock, so a machine can be "fixed" and still be hours
	// out — which is a fix that reports success and leaves the finding standing.
	NTPServiceStop  ID = "time.ntp_service_stop"
	NTPServiceStart ID = "time.ntp_service_start"
	NTPStepClock    ID = "time.ntp_step_clock"
	ChronyStepClock ID = "time.chrony_step_clock"
)

The catalogue of privileged commands.

Every entry here was confirmed by measurement, not assumed: the agent was run as root and as an unprivileged ghostpsy user on docker/debian-13 and the two payloads were diffed. See internal-doc/agent-privilege-inventory.md §9 in the ghostpsy/ghostpsy repository.

Adding an entry grants a real privilege on a customer's server. Two rules:

  1. Measure before you add. If a command works unprivileged, it does not belong here — a shorter grant file is a more trustworthy one. Equally: remove an entry when its last caller goes. The file claims to list what we use, so a grant nothing calls makes that claim false.
  2. Write Why in plain words. It is printed above the grant, and the person reading it is a busy sysadmin deciding whether to trust us.
const APTEffectiveConfig ID = "config.effective.apt"

APTEffectiveConfig asks apt what configuration it is really using.

const ReadSSHConfig ID = "config.read.sshd_config"

ReadSSHConfig reads the main SSH configuration.

Privileged, measured rather than assumed: as a normal user on rocky-9 this fails, because it ships sshd_config as 0600. debian-13 ships it 0644 but its 50-cloud-init.conf drop-in as 0600, so a complete read needs root there too.

const SSHEffectiveConfig ID = "config.effective.sshd"

SSHEffectiveConfig asks sshd what it actually believes.

This is what decides whether a fix worked. Reading the file back would prove nothing: sshd_config pulls in other files with Include, the first value of a keyword wins, and a distribution can ship a drop-in nobody remembers.

func EffectiveConfig

func EffectiveConfig(s confedit.Setting) ID

EffectiveConfig is the command that asks the service behind this setting what it is really running with.

The style decides it, not the caller. A check that named SSHEffectiveConfig itself could only ever judge an SSH setting, which is how "did the change take effect?" became a question only half the settings could be asked.

func InstallDropIn

func InstallDropIn(c confedit.Change) ID

InstallDropIn names the command that makes one change.

func RemoveDropIn

func RemoveDropIn(s confedit.Setting) ID

RemoveDropIn names the command that undoes every value of one setting.

func ServiceDisableNow

func ServiceDisableNow(unit string) ID

ServiceDisableNow stops a service and stops it starting at boot.

func ServiceEnableNow

func ServiceEnableNow(unit string) ID

ServiceEnableNow starts a service and makes it start at boot.

func ServiceReload

func ServiceReload(unit string) ID

ServiceReload names the command that makes a service re-read its configuration.

Passing a placeholder builds the template a step uses, so the catalogue and the grant cannot disagree about the shape of an ID.

func ServiceRestart

func ServiceRestart(unit string) ID

ServiceRestart names the command that stops and starts a service.

func ServiceStop

func ServiceStop(unit string) ID

ServiceStop names the command that stops a service, which is how a restart is undone.

func ShowDropIn

func ShowDropIn(c confedit.Change) ID

ShowDropIn names the command that prints what would be written.

type Param

type Param struct {
	// Name is what the placeholder in Args is written as: {name}.
	Name string

	// Why says, in plain words, what may go here. The grant file can only show
	// sudo a wildcard, so this sentence is the only place a sysadmin reading it
	// can learn what the agent will actually put there.
	Why string

	// Allow is the only shape accepted. It is required, and it must be anchored
	// — an unanchored pattern matches a substring, which would let anything
	// through as long as it contained something allowed.
	Allow *regexp.Regexp
}

Param is one value a caller fills in when running a declared command.

type Result

type Result struct {
	Stdout []byte
	Stderr []byte

	// ExitCode is what the command returned. A scan only ever needed to know
	// whether a command worked, but a fix has to be reported to the person who
	// approved it, and "exit 7" is the part they will paste to a colleague.
	//
	// It is -1 when the command never ran or was killed by a signal.
	ExitCode int
}

Result is the outcome of one declared command.

func Run

func Run(ctx context.Context, id ID) (Result, error)

Run executes the declared command named by id. It takes no parameters, which is every command the scan uses.

func RunWith

func RunWith(ctx context.Context, id ID, values Values) (Result, error)

RunWith executes the declared command named by id, filling in the values it declared. It refuses an undeclared ID, and refuses a value that does not match the shape the command declared for it.

type Values

type Values map[string]string

Values are the parameter values for one invocation, by parameter name.

Jump to

Keyboard shortcuts

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