---
title: 'Command-Line Auditing'
source: 'https://academia.sh/en/courses/endpoint-security/command-line-auditing'
course: 'Endpoint and System Hardening'
language: en
updated: '2026-09-26T03:02:51+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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"
---

# 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.

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

```js
// 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

```js
// 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.
