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_.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 `"" ` — 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 }