Lesson 15 / 16
Command-Line Auditing
In a 5,110-record process creation sample, a rule that looks only at the child process name flags 1,702 records; parent-process context brings the same set down to 40 records and 9 pairs, and a new-and-sparse criterion separates out all five suspicious items along with one legitimate addition. Three of the five items have no trace in any other source. Across the fleet the record runs to 10.18 million lines and 4.55 GiB a day; keeping only non-baseline pairs divides the volume by 128 but leaves three of four investigation questions unanswered.
Published Updated
In this lesson
- Reads process creation log entries as a tree using parent-process context and compares it with a name-based rule
- Counts how many suspicious items leave a trace in another source
- Measures the record's volume at fleet scale and the questions each of three retention policies leaves unanswered
Contents
The previous lesson measured the authentication log on its own and showed that it cannot see what happens after a session opens. In the first lesson’s coverage table there was a source that wrote eight of the twelve foothold point types by name on its own, and it was off by default: the process creation log. This lesson turns that record on and asks two questions together — what the record can separate out, and how much space it takes.
The key to separating things out is a single field: the parent process. If a process creation record carries only the name of the running item, what you have is a flat list; if it also carries the parent process identity, the same records turn into a process tree, and the question stops being “did this tool run” and becomes “what ran this tool.” The measurement below is a model, the regional measurement network is fictional, and the process names are type names, not products. Suspicious items are referred to only as pairs; no command, script, or usage pattern is written down.
- SL11. The sample is 5,110 lines from one day’s process creation log and covers 8 machines. Fleet volume scales from the first lesson’s assumption of 38,000 records per machine.
- SL12. The baseline is 16 (parent, child) pairs seen during a learning period. If a pair is not in the baseline, it counts as a “new pair.”
- SL13. Rule 1 looks only at the child process’s name and treats three names as noteworthy. Rule 2 looks at whether the (parent, child) pair is in the baseline. Neither rule sees the label field.
- SL14. What the model knows to be true: four new pairs are legitimate additions, five new pairs are suspicious. Some of the suspicious items have a trace in another source; that trace is read from the first lesson’s coverage table.
- SL15. The record size is 480 bytes, and the retention comparison is made over 90 days.
Flat List and Process Tree
// process.mjs — process creation log sample and two rules: name list vs. parent-process pair. // THIS IS A MODEL; process names are TYPE names, not products. The rules only read the parent/child/machine fields; // the label field is the model's ground truth and is not given to the rules. export const SEED = 20260401; const generator = (t) => { let s = t >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; }; const r = generator(SEED); // Baseline: (parent, child) pair seen during the learning period, and its daily count. export const BASELINE = [ ["session-manager", "shell", 620], ["shell", "metrics-client", 840], ["shell", "archiver", 180], ["shell", "network-transfer", 210], ["scheduler", "script-interpreter", 460], ["scheduler", "backup", 120], ["management-agent", "package-manager", 90], ["management-agent", "script-interpreter", 260], ["service-manager", "metrics-collector", 40], ["metrics-collector", "metrics-client", 1300], ["document-handler", "print-helper", 70], ["mail-client", "document-handler", 150], ["scanner", "document-handler", 130], ["package-manager", "install-hook", 210], ["backup", "archiver", 260], ["install-hook", "shell", 130], ]; // Pair not in the baseline: four legitimate additions, five suspicious items. // The "trace" field: the OTHER source that sees the item's effect (the first lesson's coverage table), or null if none. export const NEW = [ ["management-agent", "network-transfer", 14, "legitimate-addition", null], ["scheduler", "archiver", 6, "legitimate-addition", null], ["shell", "package-manager", 9, "legitimate-addition", "package and installation log"], ["backup", "network-transfer", 2, "legitimate-addition", null], ["document-handler", "script-interpreter", 2, "suspicious", null], ["mail-client", "shell", 1, "suspicious", null], ["metrics-collector", "archiver", 3, "suspicious", null], ["print-helper", "shell", 1, "suspicious", "scheduler log"], ["scanner", "script-interpreter", 2, "suspicious", "system event log"], ]; export const MACHINE = 8; const records = []; const add = (parent, child, label) => records.push({ t: Math.floor(r() * 86400), machine: `ep-${String(1 + Math.floor(r() * MACHINE)) .padStart(3, "0")}`, parent, child, label }); for (const [parent, child, n] of BASELINE) for (let i = 0; i < n; i++) add(parent, child, "baseline"); for (const [parent, child, n, kind] of NEW) for (let i = 0; i < n; i++) add(parent, child, kind); records.sort((a, b) => a.t - b.t); export const RECORD = records; // Rule 1: looks only at the child process's name. export const INTEREST = ["shell", "script-interpreter", "network-transfer"]; // Rule 2: is the (parent, child) pair in the baseline. const baselineSet = new Set(BASELINE.map(([p, c]) => `${p}>${c}`)); export const pair = (k) => `${k.parent}>${k.child}`; export const newPair = (k) => !baselineSet.has(pair(k)); export const suspiciousPair = new Set(NEW.filter((y) => y[3] === "suspicious") .map(([p, c]) => `${p}>${c}`)); if (import.meta.url === `file://${process.argv[1]}`) { const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); const line1 = row([42, 8, 10, 18, 30]); console.log(`model (seed ${SEED}): ${RECORD.length} process creation log sample, ` + `${MACHINE} machines, ${new Set(RECORD.map(pair)).size} distinct (parent, child) pairs (fictional)`); const byName = RECORD.filter((k) => INTEREST.includes(k.child)); const tree = RECORD.filter(newPair); console.log(`rule 1 (child process name only): ${byName.length} records flagged, ` + `${new Set(byName.map(pair)).size} distinct pairs`); console.log(`rule 2 (non-baseline pair): ${tree.length} records, ` + `${new Set(tree.map(pair)).size} distinct pairs`); console.log("\n" + line1(["non-baseline pair", "record", "machine", "rule 1 sees it", "trace in another source"])); for (const [p, c, , kind, trace] of NEW) { const l = RECORD.filter((k) => pair(k) === `${p}>${c}`); console.log(line1([`${p} > ${c}`, l.length, new Set(l.map((k) => k.machine)).size, INTEREST.includes(c) ? "yes" : "no", trace ?? "none"])); } const sparse = [...new Set(tree.map(pair))] .filter((c) => tree.filter((k) => pair(k) === c).length <= 3); console.log(`\nnew AND sparse (3 records or fewer) pairs: ${sparse.length}; of these, ` + `suspicious items: ${sparse.filter((c) => suspiciousPair.has(c)).length}, ` + `legitimate additions: ${sparse.filter((c) => !suspiciousPair.has(c)).length}`); console.log(`${NEW.filter((y) => y[3] === "suspicious" && y[4]).length} of the five ` + `suspicious items have a trace in another source; ` + `${NEW.filter((y) => y[3] === "suspicious" && !y[4]).length} appear only in the process ` + `creation log`); }
model (seed 20260401): 5110 process creation log sample, 8 machines, 25 distinct (parent, child) pairs (fictional) rule 1 (child process name only): 1702 records flagged, 11 distinct pairs rule 2 (non-baseline pair): 40 records, 9 distinct pairs non-baseline pair record machine rule 1 sees it trace in another source management-agent > network-transfer 14 6 yes none scheduler > archiver 6 5 no none shell > package-manager 9 6 no package and installation log backup > network-transfer 2 2 yes none document-handler > script-interpreter 2 1 yes none mail-client > shell 1 1 yes none metrics-collector > archiver 3 3 no none print-helper > shell 1 1 yes scheduler log scanner > script-interpreter 2 2 yes system event log new AND sparse (3 records or fewer) pairs: 6; of these, suspicious items: 5, legitimate additions: 1 2 of the five suspicious items have a trace in another source; 3 appear only in the process creation log
The ratio between the two rules’ output is the lesson’s first number. The rule that looks only at the child process’s name flags 1,702 of 5,110 records — a third of them. That is not because the flagged names are genuinely suspicious, but because the organization uses these tools legitimately all day: the shell starts from the session manager 620 times, the script interpreter starts from the scheduler 460 times, the network transfer tool starts from the shell 210 times. As an alert list, this number is useless.
When the same record set is read together with the parent process, 1,702 records drop to 40, and eleven pairs drop to nine. The only thing that changes is the number of fields in the criterion: two instead of one. What the process tree provides is the ability to count not that a tool ran, but that it ran from an unexpected parent.
The table shows these nine pairs split into two groups. Four are legitimate additions — a new deployment step, a new backup job, an administrator doing a manual install. Five are suspicious, and what they share is their shape: an interpreter or a shell under a process that handles documents, receives mail, or scans; an archiver under a service that touches external data. None of these individually contains any forbidden command; what is suspicious is the location.
The last two lines write two separate limits. The new-and-sparse criterion gives six pairs; five are suspicious items, one is a legitimate addition — so this criterion separates out all five items and produces one needless review in exchange. The second limit is heavier: three of the five suspicious items have no trace in any other source at all. If the record is off, nothing is left for those three items. One of the remaining two appears only in the scheduler log, and that source, too, is off by default in the first lesson’s table.
This criterion’s cost to legitimate work does not sit in the table as a fourth column, but it can be counted. In one day, nine new pairs came out, and four of them are legitimate additions that should be added to the baseline: a new deployment tool, a new backup job, an administrator’s manual install. Roughly half the alerts are not a threat but a maintenance item, and each one needs a person to look at it and decide. The baseline is not a file written once and left alone; every software change in the organization ages it. If that cost goes unpaid, the criterion degrades on its own — accumulated legitimate additions crowd the list, and after a while no one looks at nine rows.
The Record’s Volume and the Retention Decision
// volume.mjs — the volume of three retention policies, the suspicious items each keeps, and the questions each answers. import { RECORD, INTEREST, newPair, pair, suspiciousPair } from "./process.mjs"; const FLEET = 268, DAILY = 38000, BYTES = 480; // per-machine record count from the previous lesson const POLICY = [ ["keep everything", () => true], ["name of interest only", (k) => INTEREST.includes(k.child)], ["non-baseline pair only", newPair], ]; // Questions are answered on the full set; if the filtered set gives the same answer, it is "yes". const QUESTION = [ ["what else did document-handler run", (l) => new Set(l.filter((k) => k.parent === "document-handler").map((k) => k.child)).size], ["archiver pair's first machine", (l) => l.filter((k) => pair(k) === "metrics-collector>archiver") .sort((a, b) => a.t - b.t)[0]?.machine ?? "-"], ["how many times did metrics-client run", (l) => l.filter((k) => k.child === "metrics-client").length], ["distinct parents that ran the shell", (l) => new Set(l.filter((k) => k.child === "shell").map((k) => k.parent)).size], ]; const fullAnswer = QUESTION.map(([, f]) => f(RECORD)); const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); const line1 = row([25, 10, 9, 15, 14, 13, 11]); const toGiB = (n) => (n * BYTES) / 1024 ** 3; console.log(`fleet ${FLEET} machines x ${DAILY} records/day = ` + `${(FLEET * DAILY).toLocaleString("en-US")} records/day, record ${BYTES} bytes`); console.log(line1(["policy", "sample", "share", "fleet rec/day", "GiB/day", "90-day GiB", "suspicious"])); const answers = []; for (const [name, filter] of POLICY) { const l = RECORD.filter(filter); const share = l.length / RECORD.length; const s = new Set(l.filter((k) => suspiciousPair.has(pair(k))).map(pair)).size; answers.push(QUESTION.map(([, f], i) => f(l) === fullAnswer[i])); console.log(line1([name, l.length, `${(100 * share).toFixed(1)}%`, Math.round(FLEET * DAILY * share).toLocaleString("en-US"), toGiB(FLEET * DAILY * share).toFixed(2), Math.round(toGiB(FLEET * DAILY * share) * 90), `${s}/${suspiciousPair.size}`])); } const line2 = row([38, 15, 23, 24]); console.log("\n" + line2(["investigation question", ...POLICY.map((p) => p[0])])); for (const [i, [name]] of QUESTION.entries()) console.log(line2([name, ...answers.map((y) => (y[i] ? "yes" : "no"))])); console.log(POLICY.map((p, j) => `${p[0]}: ` + `${answers[j].filter(Boolean).length}/${QUESTION.length}`).join("; ")); const fullSize = toGiB(FLEET * DAILY), narrowSize = toGiB(FLEET * DAILY * RECORD.filter(newPair).length / RECORD.length); console.log(`\nkeeping only non-baseline pairs divides the volume by ` + `${Math.round(fullSize / narrowSize)} and still keeps all five suspicious items; the cost is that three of four investigation questions go unanswered`);
fleet 268 machines x 38000 records/day = 10,184,000 records/day, record 480 bytes policy sample share fleet rec/day GiB/day 90-day GiB suspicious keep everything 5110 100.0% 10,184,000 4.55 410 5/5 name of interest only 1702 33.3% 3,392,009 1.52 136 4/5 non-baseline pair only 40 0.8% 79,718 0.04 3 5/5 investigation question keep everything name of interest only non-baseline pair only what else did document-handler run yes no no archiver pair's first machine yes no yes how many times did metrics-client run yes no no distinct parents that ran the shell yes yes no keep everything: 4/4; name of interest only: 1/4; non-baseline pair only: 1/4 keeping only non-baseline pairs divides the volume by 128 and still keeps all five suspicious items; the cost is that three of four investigation questions go unanswered
The volume row explains why this record is off by default. The fleet produces 10.18 million process creation log entries a day; that is 4.55 GiB a day and 410 GiB over ninety days. The number is not unaffordable on its own, but it is large next to the sum of the other six sources from the first lesson, and this record has to be turned on machine by machine.
The middle row shows what narrowing retention by name gives you: volume drops to a third, and four of the five suspicious items remain. The item that is lost is an archiver whose name is not on the interest list — narrowing by name means deleting exactly the tool that was not written into the list. The bottom row uses the pair criterion: volume divides by 128, and all five items remain.
The table below measures what this cheapness costs. Four investigation questions get answered on the full set; the policy that keeps only non-baseline pairs answers just one. It cannot answer the rest, because what it deletes is the expected records: what else a parent process ran that day, how many times an item ran, and how many distinct parents launched a tool all sit only inside the expected records.
Together, these two tables give the record’s real decision. Non-baseline pairs are enough for detection, and they are very cheap; investigation needs the full record, and that costs 410 GiB over ninety days. These are not the same decision: producing an alert and being able to ask questions after an alert are priced separately, and narrowing by name is the worst option on both counts — it pays a third of the volume, answers one of four questions, and loses one of five items.
Summary
- A rule that looks only at the child process name flags 1,702 of 5,110 records; reading the same set with parent-process context brings it down to 40 records and 9 pairs.
- Four of the nine non-baseline pairs are legitimate additions, five are suspicious; what is suspicious is not the command but the location — which parent process an item runs under.
- The new-and-sparse criterion gives six pairs: all five suspicious items are among them, at the cost of one needless review.
- Three of the five suspicious items have no trace in any other source; one appears only in the scheduler log, which is also off by default.
- The fleet produces 10.18 million records and 4.55 GiB a day; keeping only non-baseline pairs divides the volume by 128 and keeps all five items, but leaves three of four investigation questions unanswered.
Next Step
All three lessons in this topic carried the same silent assumption: the record sits there, and it is correct. But every record measured here is produced on the local disk of the very machine the item is running on. Once code has run on the endpoint, those records sit where it can reach them. The last lesson tests that assumption: it detects, with a hash chain, how many records in a tampered log set were deleted and how many were altered, names the case the chain cannot see, and measures the window centralization closes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.