CoursesAdvanced scripting for DevSecOpsText processing without forks: expansion, awk & sed

Text processing without forks: expansion, awk & sed

Parameter expansion, awk and sed that replace pipelines of cat/grep/cut.

Advanced35 min · lesson 3 of 15

Every time your script runs an external command like cut, grep, or sed, the shell does two quiet things under the hood. It calls fork (the system call that makes a near-identical copy of the current process) and then exec (the call that throws away that copy's program and loads a new one in its place). It is like hiring a temp worker for a single envelope: you fill out the paperwork, brief them, they lick one stamp, and you let them go. One envelope is fine. Put that inside a loop over a two-million-line log file and you have made two million hires. The paperwork, not the stamping, becomes your whole afternoon.

Here is the part most people miss. A large share of everyday text work never has to leave the shell at all, and the rest is usually one awk program standing in for a pipeline of five commands. Fewer processes means faster scripts, fewer moving parts that can fail in the middle of an incident, and a smaller set of external binaries that an attacker could have swapped out on a box you are trying to trust. For a defender pulling fields out of logs at three in the morning, that speed and that predictability are the whole point.

Why a Fork Is Expensive

You do not have to take the cost on faith. Measure it. Here are fifty thousand file paths, and two ways to strip each one down to its filename.

~/secopslog — bash
$ wc -l paths.txt
50000 paths.txt
$ # Version A: shell out to basename once per line (one fork per line) time while read -r p; do basename "$p"; done < paths.txt > /dev/null
real 0m48.106s user 0m9.412s sys 0m35.984s
$ # Version B: identical job, parameter expansion, zero forks time while read -r p; do echo "${p##*/}"; done < paths.txt > /dev/null
real 0m0.583s user 0m0.421s sys 0m0.158s

echo and read are builtins: the shell runs them itself, in the same process, with no hire. basename is a separate binary, so Version A forks fifty thousand times. Look at where the time went. Most of it is sys (time spent inside the kernel, the core of the operating system that manages processes and memory), and that is the cost of all the forking, not of the text work. Version B does the exact same job with parameter expansion and never leaves the shell. Same output, roughly eighty times faster.

Parameter Expansion Replaces Most of Coreutils

Instead of sending every small edit out to a hired hand, you do it at your own desk. Parameter expansion is a little language built into the ${...} shape. It trims off prefixes and suffixes, substitutes text, slices out substrings, changes case, and supplies defaults, all in-process. The trimming operators are the ones worth burning into memory. One hash (#) trims the shortest match off the front, two hashes (##) trim the longest. One percent (%) trims the shortest match off the back, two percents (%%) trim the longest.

~/secopslog — bash
$ path=/var/log/app/server.log echo "${path##*/}" # strip longest leading */ -> basename echo "${path%/*}" # strip trailing /* -> dirname echo "${path##*.}" # strip to the last dot -> extension name=server.log echo "${name%.log}.gz" # swap one suffix for another url=HTTPS://Example.COM echo "${url,,}" # lowercase everything (bash 4+) echo "${url:0:5}" # substring: offset 0, length 5 echo "${MISSING:-fallback}" # default value when unset or empty
server.log /var/log/app log server.gz https://example.com HTTPS fallback

That single block already ate basename, dirname, a couple of sed substitutions, tr for the lowercasing, and cut for the slice. Those are all coreutils (the GNU Core Utilities, the standard kit of small text commands you reach for by reflex), and not one of them had to run. Now the real work a defender actually does: pulling fields out of a log line. The same operators carve up a structured message without a single fork, and two more forms round it out. Two slashes (${var//old/new}) replace every match, not only the first, and ${#var} gives you the length.

~/secopslog — bash
$ # Pull fields out of an auth.log line, no forks line='sshd[2451]: Failed password for invalid user admin from 203.0.113.44 port 51244' echo "${line%% *}" # first token (chop at the first space) echo "${line##* }" # last token (chop everything up to the last space) pid=${line#*[}; pid=${pid%%]*} echo "$pid" # the number between the [ ] brackets ip=203.0.113.44 echo "${ip//./-}" # replace ALL dots echo "${#ip}" # length in characters
sshd[2451]: 51244 2451 203-0-113-44 12

Two habits keep this safe when the text comes from somewhere you do not control, like a log written by whoever is knocking on your SSH (Secure Shell, the encrypted remote-login service) port. Read with read -r so a backslash in the data stays a literal backslash instead of being treated as an escape. And put IFS= (the Internal Field Separator, the set of characters the shell uses to break a line into words) in front of the read, set to empty, so leading and trailing spaces are preserved exactly as they arrived: while IFS= read -r line; do ...; done.

Quote every expansion, especially attacker-controlled text
An unquoted ${line} gets word-split on spaces and then glob-expanded (matched against your filenames as a wildcard pattern), so a log value containing * can silently turn into a list of files on your disk. When you are parsing input an attacker writes (log lines, headers, filenames), an unquoted expansion is a real injection path, not a style nitpick. Always write "${var}" in double quotes.

One awk Pass Beats a Five-Stage Pipeline

awk (named after the three people who wrote it: Aho, Weinberger, and Kernighan) is a worker standing at a conveyor belt. Each row arrives already cut into columns, the worker runs your instructions on that row, and keeps a tally sheet that survives from one row to the next. Precisely: awk reads its input one record at a time (a record is a line by default), splits each record into fields on whitespace (field one is $1, field two is $2, and so on), runs your code for every record, and keeps variables and arrays alive across all of them. That memory across records is the trick. It lets a single awk filter, pick columns, sum, and group in one read of the file, where a pipeline would need grep, then another grep, then cut, then sort, then uniq.

~/secopslog — bash
$ # One pass: total bytes served by successful (200) responses # Combined log format: $9 is the status code, $10 is the byte count awk '$9 == 200 { bytes += $10 } END { print bytes }' access.log
184629301
$ # Top 5 client IPs by request count # awk counts into an associative array; sort/head only rank the result awk '{ count[$1]++ } END { for (ip in count) print count[ip], ip }' access.log \ | sort -rn | head -5
48122 203.0.113.44 20551 198.51.100.9 14903 192.0.2.17 9887 203.0.113.7 8123 198.51.100.72

That first IP (Internet Protocol address) 203.0.113.44 stands out: it made more than twice the requests of anyone else. It is the same address that showed up in the auth.log line earlier. This is what a defender is looking for, one loud source in a sea of quiet ones, and awk found it in a single read of the file. The -F flag sets the input field separator, which is how you carve up a colon-delimited file like the account database.

~/secopslog — bash
$ # Interactive human/service accounts: UID (user ID) >= 1000, minus 'nobody' awk -F: '$3 >= 1000 && $1 != "nobody" { print $1 }' /etc/passwd
ubuntu deploy svc_backup

The nobody exclusion matters and is easy to forget: on Debian and Ubuntu, nobody has UID 65534, which sails past a >= 1000 test and pollutes your list of real logins. To flip columns around and re-join them with your own separator, set OFS (the output field separator) in the BEGIN block, which runs once before any record is read.

~/secopslog — bash
$ # Keep columns 1 and 3, emit them comma-separated awk 'BEGIN { FS="\t"; OFS="," } { print $1, $3 }' data.tsv
web01,203.0.113.44 web01,198.51.100.9 web02,192.0.2.17

The tally sheet is where awk pulls ahead of any pipeline. Here it filters to failed logins, extracts the source address, counts per address, and applies a threshold, all in one pass over the file. There is no grep, no cut, no sort, no uniq. That is five processes collapsed into one, and the whole log is read exactly once.

~/secopslog — bash
$ # Flag any source with more than 100 failed SSH logins awk '/Failed password/ { split($0, a, "from ") split(a[2], b, " ") fails[b[1]]++ } END { for (ip in fails) if (fails[ip] > 100) printf "%-16s %6d failed logins\n", ip, fails[ip] }' /var/log/auth.log
203.0.113.44 2317 failed logins 198.51.100.9 184 failed logins

sed for Surgical Stream Edits

sed (short for stream editor) is the copy editor with a red pen, moving down a stream of text and making the same kind of correction over and over. It earns its keep for in-stream substitution and line-addressed edits. A few idioms cover most real jobs. First, pick a delimiter other than slash when your pattern contains file paths, so you are not drowning in backslashes.

~/secopslog — bash
$ # Use # as the delimiter so the slashes in the path need no escaping echo 'ExecStart=/opt/app/old/bin/serve' | sed 's#/opt/app/old#/opt/app/new#'
ExecStart=/opt/app/new/bin/serve

Second, edit a file in place, but keep a backup while you learn to trust the pattern. With GNU sed (the version on every systemd Linux) the suffix attaches straight to the flag.

~/secopslog — bash
$ # Rewrite the log level in place, keeping app.conf.bak as a safety copy sed -i.bak 's/^LogLevel .*/LogLevel INFO/' /etc/app/app.conf ls /etc/app/
app.conf app.conf.bak

Third, an address range scopes an edit to a region of the file. With -n (which switches off sed's habit of printing every line) plus a range that runs from one marker to the next, sed prints only the lines between the two markers, inclusive. One catch: a fullchain file stacks several certificates end to end, and the range happily fires again on each new BEGIN, so on its own it would spill every block. A q (quit) on the closing marker stops sed after the first one. That is how you carve a single certificate out of a bundle.

~/secopslog — bash
$ # Print only the first certificate block, then quit at its END line sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/{p; /-----END CERTIFICATE-----/q}' fullchain.pem
-----BEGIN CERTIFICATE----- MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh ...snip... -----END CERTIFICATE-----

Addresses can be line numbers too, and they refer to the input line count, not the count after earlier deletions. This trips people up: delete the comment on line one, and the surviving lines still carry their original numbers.

~/secopslog — bash
$ # Delete comment lines; rename foo->bar only on input lines 2 and 3 printf '# note\nfoo\nfoo\nfoo\nfoo\n' | sed '/^#/d; 2,3 s/foo/bar/'
bar bar foo foo

The moment you need arithmetic, fields, or grouping, stop reaching for sed. That is awk's job, and forcing it into sed gives you write-only line noise that the next person (probably you, in six months) cannot read.

Never parse JSON, YAML, or CSV with grep, sed, or awk
Line-oriented tools do not understand nesting, quoting, or escaping. A regex that works on today's JSON (JavaScript Object Notation, a nested data format) breaks the first time a value holds a brace, an embedded newline, or a comma inside quotes, and it fails silently, which is the worst way to fail. Use jq for JSON and yq for YAML (which stands for 'YAML Ain't Markup Language', the indented format most config files use). CSV (comma-separated values) with quoted fields needs a real CSV reader. Regex on structured data is a class of bug, not a shortcut.
Which tool for the text in front of you
What shape is the text you are processing?
one string in a variable
Parameter expansion
trim, slice, substitute, default, case change; zero forks, stays in the shell
columns, sums, grouping, running state
awk
one streaming pass over records and fields, with memory across lines
line-addressed find and replace on a stream
sed
substitution and range edits; -i.bak edits in place with a backup
nested, quoted, or escaped data
jq (JSON) or yq (YAML)
a real parser that knows the grammar; never regex
Quick check
01You have f=archive.tar.gz and want archive.tar (strip only the final .gz). Which expansion does it?
Correct — A single % trims the shortest match off the back, removing exactly .gz and leaving archive.tar.
Incorrect — %% trims the longest match off the back, so .tar.gz goes and you are left with archive.
Incorrect — # trims from the front; the shortest match of *. removes archive. and yields tar.gz.
Incorrect — ## trims the longest match from the front, leaving only the final extension: gz.
02Stripping filenames from 50,000 paths, Version A while read -r p; do basename "$p"; done took about 48s while Version B while read -r p; do echo "${p##*/}"; done took about 0.6s, and most of Version A's time showed up as sys. What made Version A slow?
Incorrect — echo is a fast builtin, and Version B actually uses echo; the difference is not the text formatting.
Incorrect — both versions read the file exactly once through the same while read loop.
Correct — 50,000 fork+exec pairs dominate, and that work happens in the kernel, which is why sys time is huge while parameter expansion stays in-process.
Incorrect — parameter expansion is not a regex engine; it is fast because it never leaves the shell, not because of a better matcher.
03You run printf '# note\nfoo\nfoo\nfoo\nfoo\n' | sed '/^#/d; 2,3 s/foo/bar/'. The /^#/d deletes the comment on line 1. What does 2,3 s/foo/bar/ then change, and what is the output?
Incorrect — this is the common misconception; sed does not renumber after a deletion, so the targeted lines are not the 2nd and 3rd survivors.
Correct — sed line addresses count the input stream, so lines 2 and 3 are the first two foos regardless of the earlier delete.
Incorrect — separating commands with ; in a single sed program is valid, as the lesson's example shows.
Incorrect — 2,3 is a line-range address, not a per-line occurrence count; only the lines in that input range are edited.

When you replace a pipeline with an expansion or one awk, prove the swap did not quietly change the answer. Run the old version and the new one against the same file and diff the two: diff <(old_pipeline access.log) <(new_awk access.log). The <(...) shape (process substitution) hands each command's output to diff as if it were a file, so you compare them without writing anything to disk. If the diff prints nothing, the output is identical and you can ship it. If it prints anything at all, your refactor changed behavior, and you want to know that on your terminal, not from an alert an hour later.

Try this

Work through “sed for Surgical Stream Edits” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: quote every expansion, especially attacker-controlled text. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related