262 lines
10 KiB
Go
262 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ORIGIN — the first phase of the agent-detection plane. Every observed
|
|
// process now says HOW IT WAS STARTED, walked from the parent map the plane
|
|
// already builds, and carries the typed evidence that produced every field.
|
|
//
|
|
// The name tables answer "which product is this". They cannot answer the
|
|
// question the headlines are about: an agent that has been running for months
|
|
// with nobody at the keyboard. That shape is "model traffic, a scheduler
|
|
// ancestor, no terminal" — and it needs no new table to see, only lineage.
|
|
//
|
|
// interactive a controlling terminal (Linux/macOS) or a logged-in session
|
|
// (Windows): a person is plausibly present.
|
|
// remote an ssh / mosh / screen-sharing ancestor: a person, elsewhere.
|
|
// scheduler cron, at, a systemd timer, a launchd interval job, the
|
|
// Windows Task Scheduler: it fired on a clock.
|
|
// service a daemon with no session: systemd unit, launchd daemon,
|
|
// a Windows service or session 0.
|
|
// agent started by a process that is itself an observed agent.
|
|
// unknown the box could not read enough to say. Never guessed.
|
|
//
|
|
// Nearest ancestor wins: a shell under sshd is remote even though it has a
|
|
// tty; an agent forked by an agent is `agent` even if the root was cron. The
|
|
// walk is over facts each platform supplies (procFacts); every rule with a
|
|
// decision in it lives here, under test, exactly like agents_parse.go.
|
|
|
|
const (
|
|
originInteractive = "interactive"
|
|
originRemote = "remote"
|
|
originScheduler = "scheduler"
|
|
originService = "service"
|
|
originAgent = "agent"
|
|
originUnknown = "unknown"
|
|
)
|
|
|
|
// Evidence tiers mirror the local-inference tiers in agents.go: verified is
|
|
// read from this box's own state or an OS-flagged fact; known is a published
|
|
// default distinctive enough to stand alone; ambiguous annotates and never
|
|
// promotes.
|
|
const (
|
|
tierVerified = "verified"
|
|
tierKnown = "known"
|
|
tierAmbiguous = "ambiguous"
|
|
)
|
|
|
|
// evidence is one typed reason on the wire. The host renders the list on the
|
|
// card; a reader sees exactly why a process is what it is.
|
|
type evidence struct {
|
|
Signal string `json:"signal"`
|
|
Tier string `json:"tier"`
|
|
Value string `json:"value,omitempty"`
|
|
}
|
|
|
|
// procFacts is what a platform can say about one process without judgement.
|
|
// Comm is the executable's basename (binBase'd). Interactive is a controlling
|
|
// tty on Linux/macOS or a non-zero session on Windows. Unit is the systemd
|
|
// unit / launchd label / Windows service the process belongs to, when known.
|
|
type procFacts struct {
|
|
PID int
|
|
PPID int
|
|
Comm string
|
|
Interactive bool
|
|
Unit string
|
|
// Exe is the executable path AS THE PLATFORM'S ORIGIN SOURCE REPORTS IT,
|
|
// and the three are not the same grade of fact: Linux reads the kernel's
|
|
// link (readlink /proc/pid/exe, origin_linux.go:84), Windows takes
|
|
// Toolhelp32's basename with no directory (origin_windows.go:50), and macOS
|
|
// takes `ps -o comm=`, WHICH IS argv[0] — Apple's ps maps comm to
|
|
// just_command (adv_cmds ps/keyword.c:101 → ps/print.c getproclline, whose
|
|
// own comment skips the saved exec_path). This field was once described as
|
|
// "macOS comm is a full path from the kernel"; it is usually a full path and
|
|
// it is not from the kernel.
|
|
//
|
|
// So it is fit for what it is used for here — matching the launchd
|
|
// scheduler inventory, where a wrong answer costs an origin label — and NOT
|
|
// fit to be an identity. agentObs.Exe (agents.go) is the identity-grade
|
|
// value, populated per platform by procExe.
|
|
Exe string
|
|
// Detail is what made Interactive true ("pts/3", "session 1"), for the
|
|
// evidence line. Empty when not interactive.
|
|
Detail string
|
|
}
|
|
|
|
// Ancestor executables that decide an origin outright. Basenames, compared
|
|
// after binBase(), so `crond`, `/usr/sbin/crond` and `cron.exe` all read once.
|
|
var (
|
|
schedulerComms = map[string]bool{
|
|
"cron": true, "crond": true, "anacron": true, "fcron": true,
|
|
"atd": true, "at": true,
|
|
}
|
|
remoteComms = map[string]bool{
|
|
"sshd": true, "sshd-session": true, "dropbear": true,
|
|
"mosh-server": true, "screensharingd": true, "x11vnc": true, "vncserver": true,
|
|
"tmate": true,
|
|
}
|
|
// Init-class parents: a process directly under one of these with no
|
|
// session is a service. Linux systemd is pid 1; launchd is pid 1; on
|
|
// Windows services.exe (and wininit.exe above it) play the part.
|
|
initComms = map[string]bool{
|
|
"systemd": true, "init": true, "launchd": true,
|
|
"services": true, "wininit": true,
|
|
}
|
|
)
|
|
|
|
// maxOriginHops bounds the ancestor walk. Real trees are shallow; a chain
|
|
// this long is a corrupt parent map and the walk stops rather than loops.
|
|
const maxOriginHops = 32
|
|
|
|
// originExtra lets a platform add signals the shared walk cannot see: the pid
|
|
// of the Windows Task Scheduler's svchost, a systemd unit known to be
|
|
// timer-activated. Both are answered from facts collected once per scan.
|
|
type originExtra struct {
|
|
// SchedulerPIDs: processes that ARE the scheduler on this box even though
|
|
// their basename is generic (svchost.exe hosting Schedule).
|
|
SchedulerPIDs map[int]bool
|
|
// TimerUnits: systemd .service units a .timer activates (unit -> timer).
|
|
TimerUnits map[string]string
|
|
// ScheduledExes: executables the scheduler inventory would start on a
|
|
// clock (exe path -> job name). launchd is the parent of everything on
|
|
// macOS, so an interval job is told apart from a daemon only this way.
|
|
ScheduledExes map[string]string
|
|
}
|
|
|
|
// classifyOrigin walks from pid to the root and returns the origin plus the
|
|
// evidence that decided it. facts answers for any pid it can read; observed
|
|
// is the set of pids the same scan classified as agents (kind agent or
|
|
// automation), so a child of one reads as `agent`.
|
|
func classifyOrigin(pid int, facts func(int) (procFacts, bool), observed map[int]bool, extra originExtra) (string, []evidence) {
|
|
self, ok := facts(pid)
|
|
if !ok {
|
|
return originUnknown, []evidence{{Signal: "origin", Tier: tierAmbiguous, Value: "process facts unreadable"}}
|
|
}
|
|
// The process's own unit can settle "scheduler" before any walk: a
|
|
// systemd service that a timer activates fired on a clock, whatever its
|
|
// parent chain looks like (systemd is the parent of everything).
|
|
if self.Unit != "" {
|
|
if timer, ok := extra.TimerUnits[self.Unit]; ok {
|
|
return originScheduler, []evidence{{Signal: "systemd-timer", Tier: tierVerified, Value: timer + " → " + self.Unit}}
|
|
}
|
|
}
|
|
seen := map[int]bool{pid: true}
|
|
cur := self
|
|
for hops := 0; hops < maxOriginHops; hops++ {
|
|
pp := cur.PPID
|
|
if pp <= 0 || seen[pp] {
|
|
break
|
|
}
|
|
seen[pp] = true
|
|
if observed[pp] {
|
|
return originAgent, []evidence{{Signal: "parent-agent", Tier: tierVerified, Value: "pid " + strconv.Itoa(pp)}}
|
|
}
|
|
if extra.SchedulerPIDs[pp] {
|
|
return originScheduler, []evidence{{Signal: "task-scheduler", Tier: tierVerified, Value: "pid " + strconv.Itoa(pp)}}
|
|
}
|
|
pf, ok := facts(pp)
|
|
if !ok {
|
|
break // unreadable ancestor: decide from what we hold
|
|
}
|
|
if schedulerComms[pf.Comm] {
|
|
return originScheduler, []evidence{{Signal: "scheduler-parent", Tier: tierVerified, Value: pf.Comm + " pid " + strconv.Itoa(pp)}}
|
|
}
|
|
if remoteComms[pf.Comm] {
|
|
return originRemote, []evidence{{Signal: "remote-parent", Tier: tierVerified, Value: pf.Comm + " pid " + strconv.Itoa(pp)}}
|
|
}
|
|
if initComms[pf.Comm] || pp == 1 {
|
|
// Directly under init with no session of its own: a service. With
|
|
// a session it is an interactive login that init spawned (a
|
|
// display manager's shell, a launchd user agent with a tty), and
|
|
// the interactive rule below answers.
|
|
if !self.Interactive {
|
|
// launchd is the parent of everything on macOS, so an interval
|
|
// job is told from a daemon only by its executable matching the
|
|
// inventory. Tiered `known`: one interpreter can serve two jobs.
|
|
if job, ok := scheduledJobFor(self, extra); ok {
|
|
return originScheduler, []evidence{{Signal: "scheduled-job", Tier: tierKnown, Value: job}}
|
|
}
|
|
ev := evidence{Signal: "service-parent", Tier: tierVerified, Value: pf.Comm + " pid " + strconv.Itoa(pp)}
|
|
if self.Unit != "" {
|
|
ev.Value = self.Unit
|
|
}
|
|
return originService, []evidence{ev}
|
|
}
|
|
break
|
|
}
|
|
cur = pf
|
|
}
|
|
if self.Interactive {
|
|
return originInteractive, []evidence{{Signal: "session", Tier: tierVerified, Value: self.Detail}}
|
|
}
|
|
if job, ok := scheduledJobFor(self, extra); ok {
|
|
return originScheduler, []evidence{{Signal: "scheduled-job", Tier: tierKnown, Value: job}}
|
|
}
|
|
if self.Unit != "" {
|
|
return originService, []evidence{{Signal: "unit", Tier: tierVerified, Value: self.Unit}}
|
|
}
|
|
return originUnknown, []evidence{{Signal: "origin", Tier: tierAmbiguous, Value: "no session, no unit, no known ancestor"}}
|
|
}
|
|
|
|
// signatureEvidence is the evidence line for the signal that put a process
|
|
// in the report in the first place. A name-table hit is `known`: distinctive,
|
|
// but a name is something a process chooses.
|
|
func signatureEvidence(a agentObs) evidence {
|
|
switch a.Kind {
|
|
case kindClient:
|
|
return evidence{Signal: "model-traffic", Tier: tierVerified, Value: joinMax(a.LLMEndpoints, 3)}
|
|
case kindApp:
|
|
return evidence{Signal: "app-signature", Tier: tierKnown, Value: a.Signature}
|
|
case kindAutomation:
|
|
return evidence{Signal: "automation-signature", Tier: tierKnown, Value: a.Signature}
|
|
}
|
|
return evidence{Signal: "agent-signature", Tier: tierKnown, Value: a.Signature}
|
|
}
|
|
|
|
// endpointEvidence adds one line per classified endpoint. A cloud host or a
|
|
// verified/known local port is verified traffic; a "?"-suffixed ambiguous
|
|
// port stays ambiguous, exactly as classifyConn labelled it.
|
|
func endpointEvidence(eps []string) []evidence {
|
|
out := make([]evidence, 0, len(eps))
|
|
for _, e := range eps {
|
|
tier := tierVerified
|
|
if len(e) >= 2 && e[len(e)-2:] == "?)" {
|
|
tier = tierAmbiguous
|
|
}
|
|
out = append(out, evidence{Signal: "model-endpoint", Tier: tier, Value: e})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func joinMax(s []string, n int) string {
|
|
if len(s) == 0 {
|
|
return ""
|
|
}
|
|
out := ""
|
|
for i, v := range s {
|
|
if i == n {
|
|
out += ", …"
|
|
break
|
|
}
|
|
if i > 0 {
|
|
out += ", "
|
|
}
|
|
out += v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// scheduledJobFor: the inventory job whose executable this non-interactive
|
|
// process runs, if any. Consulted only once the ancestor walk has found no
|
|
// stronger answer (an observed parent agent, a scheduler or remote ancestor).
|
|
func scheduledJobFor(f procFacts, extra originExtra) (string, bool) {
|
|
if f.Exe == "" || f.Interactive {
|
|
return "", false
|
|
}
|
|
job, ok := extra.ScheduledExes[strings.ToLower(f.Exe)]
|
|
return job, ok
|
|
}
|