bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+456
View File
@@ -0,0 +1,456 @@
package main
import (
"context"
"encoding/json"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// SCHEDULER INVENTORY, the first phase of the agent-detection plane.
// The standing jobs on this box — crontabs, systemd timers, launchd jobs, the
// Windows Task Scheduler — that would run an agent or an automation. A job is
// inventoried BEFORE it fires, so the "it has been running every night for
// months" shape is visible from the schedule, not only from a lucky beat.
//
// Only entries whose command matches the agent/automation tables travel;
// everything else on the box is the user's business. What always travels is
// the SCAN: which scheduler kinds were read and how many entries they held,
// so an empty list from a box that read forty crontabs is a different
// sentence from an empty list from a box that could read none.
//
// Pure parsers live here with no build tag; each platform supplies only the
// file reads and the shell-outs (schedulers_<goos>.go), like the observe
// plane's own split.
type schedEntry struct {
Kind string `json:"kind"` // cron | systemd-timer | launchd | task-scheduler
Name string `json:"name"` // file:line, unit, label, task path
Command string `json:"command"` // the command as written, truncated
Schedule string `json:"schedule,omitempty"` // the clock spec as written
User string `json:"user,omitempty"`
Signature string `json:"signature"` // which table matched
Automation bool `json:"automation,omitempty"` // matched the automation table, not the agent table
}
type schedScanReport struct {
Supported bool `json:"supported"`
Kinds []string `json:"kinds"` // scheduler kinds this scan could read
Entries int `json:"entries"` // total entries read, matched or not
}
// classifySchedCommand decides whether a scheduled command would run an
// agent or an automation. The command is a shell line, so the rule is
// applied to every token that could be a program: after `env` assignments,
// after `cd x &&`, after `sh -c` — the first token of each `&&`/`;`/`|`
// segment, with the whole segment as the argv for the interpreter rule.
func classifySchedCommand(cmd string) (sig string, automation bool, ok bool) {
for _, seg := range splitShellSegments(cmd) {
argv := shellFields(seg)
// drop leading VAR=value assignments and sudo/nohup/timeout wrappers
for len(argv) > 0 {
t := argv[0]
if k, _, has := strings.Cut(t, "="); has && isEnvKey(k) {
argv = argv[1:]
continue
}
base := binBase(t)
if base == "sudo" || base == "nohup" || base == "env" || base == "exec" || base == "timeout" || base == "flock" || base == "chronic" || base == "&" {
argv = argv[1:]
// skip that wrapper's own flags/args that are not the command;
// -u/-k/-s take a value (sudo -u deploy, timeout -k 5, timeout -s TERM)
for len(argv) > 0 && strings.HasPrefix(argv[0], "-") {
flag := argv[0]
argv = argv[1:]
if (flag == "-u" || flag == "-k" || flag == "-s") && len(argv) > 0 {
argv = argv[1:]
}
}
if base == "timeout" || base == "flock" {
if len(argv) > 0 {
argv = argv[1:] // the duration / the lock file
}
}
continue
}
break
}
if len(argv) == 0 {
continue
}
// `sh -c "..."`, `bash -lc "..."`, `powershell -NoProfile -Command "..."`:
// the real command is the first non-flag argument, itself a shell line.
if b := binBase(argv[0]); b == "sh" || b == "bash" || b == "zsh" || b == "dash" || b == "cmd" || b == "powershell" || b == "pwsh" {
for _, a := range argv[1:] {
if strings.HasPrefix(a, "-") || strings.HasPrefix(a, "/") && b == "cmd" {
continue
}
if s, au, ok := classifySchedCommand(a); ok {
return s, au, true
}
break
}
}
if s, a, ok := matchSignature(argv); ok {
return s, a, true
}
}
return "", false, false
}
// isEnvKey: the left side of a `KEY=value` prefix is an identifier, so
// `HOME=/root` is an assignment and `--model=gpt` or `a/b=c` is not.
func isEnvKey(k string) bool {
if k == "" {
return false
}
for i, c := range k {
switch {
case c == '_', c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z':
case c >= '0' && c <= '9' && i > 0:
default:
return false
}
}
return true
}
// shellFields splits one segment into words, honouring single and double
// quotes (the quotes themselves are dropped), so a quoted Windows path with
// spaces stays one argv[0].
func shellFields(s string) []string {
out := []string{}
cur := strings.Builder{}
var quote byte
inWord := false
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
if c == quote {
quote = 0
continue
}
cur.WriteByte(c)
continue
}
switch c {
case '"', '\'':
quote = c
inWord = true
case ' ', '\t', '\n':
if inWord {
out = append(out, cur.String())
cur.Reset()
inWord = false
}
default:
cur.WriteByte(c)
inWord = true
}
}
if inWord {
out = append(out, cur.String())
}
return out
}
// splitShellSegments splits a shell line on && ; | || outside quotes.
func splitShellSegments(s string) []string {
out := []string{}
cur := strings.Builder{}
var quote byte
flush := func() {
if t := strings.TrimSpace(cur.String()); t != "" {
out = append(out, t)
}
cur.Reset()
}
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
cur.WriteByte(c)
if c == quote {
quote = 0
}
continue
}
switch c {
case '"', '\'':
quote = c
cur.WriteByte(c)
case ';', '|':
flush()
case '&':
if i+1 < len(s) && s[i+1] == '&' {
i++
}
flush()
default:
cur.WriteByte(c)
}
}
flush()
return out
}
// ── cron ────────────────────────────────────────────────────────────────────
// parseCrontab reads one crontab. system=true is the /etc/crontab and
// /etc/cron.d format (five time fields, a user, then the command); false is a
// user's own crontab (no user column). `@reboot`-style specs are one field.
// Every entry is returned; the caller matches. name carries file:line.
func parseCrontab(text, file, user string, system bool) []schedEntry {
out := []schedEntry{}
for i, line := range strings.Split(text, "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
if k, _, ok := strings.Cut(t, "="); ok && !strings.ContainsAny(k, " \t") && !strings.HasPrefix(t, "@") && !startsWithCronField(t) {
continue // VAR=value line
}
f := strings.Fields(t)
var schedFields int
if strings.HasPrefix(t, "@") {
schedFields = 1
} else {
schedFields = 5
}
need := schedFields + 1
u := user
if system {
need++
}
if len(f) < need {
continue
}
schedule := strings.Join(f[:schedFields], " ")
rest := f[schedFields:]
if system {
u = rest[0]
rest = rest[1:]
}
cmd := strings.Join(rest, " ")
out = append(out, schedEntry{Kind: "cron", Name: file + ":" + strconv.Itoa(i+1), Command: truncate(cmd, 200),
Schedule: schedule, User: u})
}
return out
}
func startsWithCronField(t string) bool {
if t == "" {
return false
}
c := t[0]
return c == '*' || (c >= '0' && c <= '9')
}
// ── systemd timers ──────────────────────────────────────────────────────────
// parseSystemdTimers reads `systemctl list-timers --all --no-legend --plain`:
// the last two columns are UNIT and ACTIVATES. Returns activated unit ->
// timer unit.
func parseSystemdTimers(out string) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 2 {
continue
}
unit, activates := f[len(f)-2], f[len(f)-1]
if !strings.HasSuffix(unit, ".timer") {
continue
}
m[activates] = unit
}
return m
}
// parseExecStart pulls the argv out of `systemctl show -p ExecStart --value`:
// `{ path=/usr/bin/x ; argv[]=/usr/bin/x --flag ; ignore_errors=no ; ... }`.
// Several ExecStart lines come back one per `{ }` block; the first is enough.
func parseExecStart(v string) string {
i := strings.Index(v, "argv[]=")
if i < 0 {
return strings.TrimSpace(v)
}
rest := v[i+len("argv[]="):]
if j := strings.Index(rest, " ; "); j >= 0 {
rest = rest[:j]
}
return strings.TrimSpace(rest)
}
// ── launchd ─────────────────────────────────────────────────────────────────
// launchdJob is the subset of a LaunchAgent/LaunchDaemon plist that says what
// runs and on what clock, as `plutil -convert json` renders it.
type launchdJob struct {
Label string `json:"Label"`
Program string `json:"Program"`
ProgramArguments []string `json:"ProgramArguments"`
StartInterval json.Number `json:"StartInterval"`
StartCalendarInterval json.RawMessage `json:"StartCalendarInterval"`
RunAtLoad bool `json:"RunAtLoad"`
KeepAlive json.RawMessage `json:"KeepAlive"`
UserName string `json:"UserName"`
WatchPaths []string `json:"WatchPaths"`
EnvironmentVariables map[string]string `json:"EnvironmentVariables"`
}
// parseLaunchdPlistJSON turns one plist (as JSON) into an entry. A plist with
// no program is skipped. The schedule string names what would start it.
func parseLaunchdPlistJSON(data []byte, file string) (schedEntry, bool) {
var j launchdJob
if err := json.Unmarshal(data, &j); err != nil {
return schedEntry{}, false
}
cmd := strings.Join(j.ProgramArguments, " ")
if cmd == "" {
cmd = j.Program
}
if cmd == "" {
return schedEntry{}, false
}
parts := []string{}
if s := j.StartInterval.String(); s != "" && s != "0" {
parts = append(parts, "every "+s+"s")
}
if len(j.StartCalendarInterval) > 0 && string(j.StartCalendarInterval) != "null" {
parts = append(parts, "calendar")
}
if j.RunAtLoad {
parts = append(parts, "at load")
}
if len(j.KeepAlive) > 0 && string(j.KeepAlive) != "null" && string(j.KeepAlive) != "false" {
parts = append(parts, "keep alive")
}
if len(j.WatchPaths) > 0 {
parts = append(parts, "watch paths")
}
name := j.Label
if name == "" {
name = file
}
return schedEntry{Kind: "launchd", Name: name, Command: truncate(cmd, 200),
Schedule: strings.Join(parts, ", "), User: j.UserName}, true
}
// ── Windows Task Scheduler ──────────────────────────────────────────────────
// winTask is the shape schedulers_windows.go asks PowerShell to emit per task.
type winTask struct {
Name string `json:"n"`
Path string `json:"p"`
User string `json:"u"`
Actions []string `json:"a"` // each `"<Execute>" <Arguments>` — the script quotes Execute
Triggers []string `json:"t"`
State string `json:"s"`
}
// parseWinTasksJSON decodes the task list. PowerShell's ConvertTo-Json emits
// a bare object for a single result and an array otherwise; both are read.
func parseWinTasksJSON(data []byte) []schedEntry {
data = []byte(strings.TrimSpace(string(data)))
if len(data) == 0 {
return nil
}
var tasks []winTask
if data[0] == '{' {
var one winTask
if err := json.Unmarshal(data, &one); err != nil {
return nil
}
tasks = []winTask{one}
} else if err := json.Unmarshal(data, &tasks); err != nil {
return nil
}
out := []schedEntry{}
for _, t := range tasks {
for _, a := range t.Actions {
a = strings.TrimSpace(a)
if a == "" {
continue
}
trig := strings.Join(t.Triggers, ", ")
out = append(out, schedEntry{Kind: "task-scheduler", Name: t.Path + t.Name,
Command: truncate(a, 200), Schedule: strings.TrimPrefix(trig, "MSFT_Task"), User: t.User})
}
}
return out
}
// matchScheduled keeps only entries that would run an agent or automation,
// filling the signature, and reports the scan. Sorted for a stable wire.
func matchScheduled(all []schedEntry, kinds []string, supported bool) ([]schedEntry, schedScanReport) {
out := []schedEntry{}
for _, e := range all {
sig, auto, ok := classifySchedCommand(e.Command)
if !ok {
continue
}
e.Signature, e.Automation = sig, auto
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].Name < out[j].Name
})
if kinds == nil {
kinds = []string{}
}
return out, schedScanReport{Supported: supported, Kinds: kinds, Entries: len(all)}
}
// schedCmdTimeout bounds every shell-out the origin walk and the scheduler
// inventory make. They sit on the heartbeat goroutine (run.go beat()), and
// systemctl talks to PID 1 over D-Bus, PowerShell's CIM queries talk to WMI:
// a wedged daemon must cost one beat a few seconds, never every beat forever
// — a box that stops beating never fetches a contain directive.
const schedCmdTimeout = 8 * time.Second
// runTimed is exec.Command(...).Output() under schedCmdTimeout.
//
// Both Windows heartbeat probes go through here (schedulers_windows.go,
// origin_windows.go), so hideConsole on this one line is what stops the pair of
// PowerShell windows flashing on screen every ten minutes. console_windows.go
// has the whole story.
func runTimed(name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), schedCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
hideConsole(cmd)
return cmd.Output()
}
// schedCache: the inventory is files and shell-outs, and the heartbeat is
// every 60 s. Ten minutes is the same cadence the managed local agent
// reconciles on; a new crontab shows within that.
var schedCache = struct {
sync.Mutex
at time.Time
entries []schedEntry
scan schedScanReport
}{}
const schedCacheFor = 10 * time.Minute
// scanSchedulers is the heartbeat's entry point: the platform inventory,
// matched against the tables, cached.
func scanSchedulers() ([]schedEntry, schedScanReport) {
schedCache.Lock()
defer schedCache.Unlock()
if !schedCache.at.IsZero() && time.Since(schedCache.at) < schedCacheFor {
return schedCache.entries, schedCache.scan
}
all, kinds, supported := platformSchedulers()
entries, scan := matchScheduled(all, kinds, supported)
schedCache.at, schedCache.entries, schedCache.scan = time.Now(), entries, scan
return entries, scan
}