Safe Penetration Testing Labs · intermediate · ~15 min
- Walk a newline-delimited, JSON-like vulnerability feed one record at a time in C. - Use a *pinned* substring match (`strstr`) to count only the `"severity":"HIGH"` entries, without a full JSON parser. - Avoid the classic loose-match and wrong-key false positives that make triage counts lie. - Handle `NULL` and empty input safely, with no out-of-bounds reads. - Understand where a substring sweep is a legitimate triage shortcut and where it is *unsafe* to trust — and why real severity decisions still need a proper parser and CVSS data.
Security objective. The asset you are protecting is your team's attention and patch time. A vulnerability feed (NVD, GitHub Security Advisories, or a vendor PSIRT) can list thousands of CVEs. The threat is alert overload: if you cannot quickly count how many HIGH-severity items landed today, real emergencies get buried. In this lesson you build a tiny, fast triage counter that scans a fixture feed and reports how many records are marked HIGH — the first filter that decides what a human looks at next.
This builds directly on your two prerequisites. C strings taught you that a C string is a char array ending in a '\0', and that you must never read past that terminator. substring-search taught you strstr, which finds a fixed pattern inside a larger string. Here we combine them: walk the feed line by line, and on each line run one pinned strstr for the exact value "severity":"HIGH".
We deliberately do not write a JSON parser. A parser understands nesting, escapes, and whitespace; a substring sweep just looks for fixed bytes. The sweep is faster to write and fast to run, which is exactly what you want for a first-pass triage over a flat, one-record-per-line fixture. The trade-off — and the security lesson — is that a sweep can be fooled by inputs a real parser would handle correctly, so it must never be the final authority on whether something is safe.
In authorized, professional work, security engineers live inside feeds. A vendor's PSIRT drops a new advisory bundle; your SBOM scanner emits a JSON report; NVD publishes the day's CVEs. Before anyone reads a single entry, someone (or some script) answers "how many are HIGH?" That number drives whether the on-call engineer keeps sleeping or gets paged.
Writing the counter yourself teaches three durable habits. First, precision of matching: a count that includes "HIGHER" or a "HIGH" from an unrelated field is worse than no count, because people act on it. Second, knowing the limits of your tool: a substring sweep is a triage aid, not ground truth — treating it as ground truth is how teams miss a CRITICAL that was formatted slightly differently. Third, safe C over untrusted text: feeds come from outside your trust boundary, so your loop must survive NULL, empty lines, and missing terminators without crashing. These are the same instincts you will reuse when you write log parsers, config validators, and SBOM checkers later on.
Definition. A feed is text produced by someone else — a vendor, a public database, a scanner — that your program reads.
Plain explanation. You did not write the feed, so you cannot assume it is well-formed. It might have blank lines, a truncated final record, or fields in a different order than you expect.
How it works. You receive one big string (or a file you read into one). Your job is to extract a fact — the HIGH count — without trusting the structure more than you must.
When / when not. Treat any external feed as untrusted. The only time you can relax is a fixture file you control inside a test — and even then, writing defensive code keeps the habit sharp.
Pitfall. Assuming the feed is valid JSON with keys in a fixed order. Real feeds drift; a brittle assumption becomes a silent miscount.
Definition. Splitting the input at '\n' so each record is handled on its own.
Plain explanation. Our fixture puts one CVE per line. Scanning line by line keeps a match "local": a "severity":"HIGH" on line 5 belongs to line 5's record and cannot accidentally pair a key from one record with a value from another.
How it works. You track the start of the current line, find the next '\n', treat the bytes between as one record, then advance past the newline.
When / when not. Line scanning is correct only when records really are one-per-line. Pretty-printed JSON (indented over many lines) breaks this assumption — there you need a parser.
Pitfall. Forgetting the last record has no trailing '\n'. If your loop only acts when it sees a newline, you silently drop the final entry.
Definition. Searching for a pattern that includes enough surrounding characters to be unambiguous — here, the opening "severity":" and the closing " around HIGH.
Plain explanation. Matching bare HIGH is loose: it also hits HIGHER and HIGHLIGHT. Matching "severity":"HIGH" pins both ends of the value, so only an exact HIGH severity counts.
How it works. strstr(line, needle) returns a pointer to the first occurrence of needle, or NULL. Because the fixture has no spaces inside objects, the literal needle "severity":"HIGH" matches the whole key-value pair as one blob.
When / when not. Pinned substring matching is fine for a fixed, space-free fixture. It is not robust against whitespace ("severity": "HIGH"), key reordering, or escaped characters — a parser handles those.
Pitfall. Two failure directions: too loose (counts HIGHER) and too tight (misses a valid entry that has a space after the colon). Know which fixture you are matching against.
Definition. The count is a hint that focuses a human, not a verified security verdict.
Plain explanation. Passing a sweep does not mean the feed is safe, and a sweep miss does not mean nothing is HIGH. It means "this many lines matched these exact bytes." A real decision needs a parser and the CVSS vector behind the label.
Pitfall. Reporting the sweep count as if it were authoritative. Always label it as a triage estimate.
THREAT MODEL — CVE feed triage counter
[ External sources ] (untrusted)
NVD / GHSA / vendor PSIRT JSON
|
v feed downloaded / provided as fixture
======================= TRUST BOUNDARY =======================
| (bytes cross into your program here)
v
ENTRY POINT: count_high_severity(const char *json)
- json may be NULL, empty, or truncated
- lines may lack a trailing newline
|
v
[ Your process ] line-by-line + pinned strstr
|
v
ASSET PROTECTED: analyst attention / patch prioritization
output: HIGH count -> triage decision (page or defer)
Insecure assumption to avoid: "the feed is well-formed JSON,
keys never reorder, values never have spaces, matching HIGH is enough."
Knowledge check.
json pointer?"HIGHER" inflate your HIGH count, and how does a pinned match remove it?The two building blocks are strstr (find a fixed pattern) and manual line walking with a pointer.
#include <string.h> /* strstr, strchr */
#include <stddef.h> /* NULL, size_t */
/* strstr: returns a pointer to the first occurrence of `needle`
inside `haystack`, or NULL if not found. Never modifies either. */
char *hit = strstr(line, "\"severity\":\"HIGH\"");
/* ^-- the needle is PINNED: it includes the
opening key, the colon, and both quotes
around the value, so HIGHER cannot match. */
/* Walking lines without copying: find the next '\n' from `start`. */
const char *nl = strchr(start, '\n'); /* NULL on the final line */
Key points:
" in the feed becomes \", so "severity":"HIGH" is written "\"severity\":\"HIGH\"".strstr and strchr treat their arguments as read-only; you never write through the returned pointer here.'\0'. That is why a valid, terminated C string is a precondition — an unterminated buffer is undefined behavior.Vulnerability feeds — such as NVD, GHSA, and vendor PSIRTs — are published as JSON.
To read them correctly, you need a real JSON parser.
But to read them quickly in a triage pipeline, a substring sweep is often enough.
A "substring sweep" simply scans the text for a fixed pattern, without understanding the JSON structure.
We will not write a JSON parser here.
Instead, we will write the substring-sweep version. This lets an auditor spot HIGH-severity entries in a fixture file without pulling in a library.
(A fixture file is a fixed sample file used for testing.)
{"id":"CVE-2024-0001","severity":"LOW"}
{"id":"CVE-2024-0002","severity":"HIGH"}
{"id":"CVE-2024-0003","severity":"CRITICAL"}
{"id":"CVE-2024-0004","severity":"HIGH"}
Each line is one record.
Implement:
int count_high_severity(const char *json)
Follow these rules:
"severity":"HIGH" exactly, increment the counter.HIGH against a line that contains "HIGHER". Pin the match to "severity":"HIGH" — include both the start and the end of the value."HIGH" in any field's value as a hit. Only the severity key counts.The task is defensive tooling, but the first version shows the common insecure/loose shortcut so you can see exactly what to fix.
This loose version over-counts and can miscount. It is here only to demonstrate the failure.
/* insecure_count.c -- LAB ONLY. Loose matching miscounts. */
#include <string.h>
/* BUG 1: matches bare "HIGH", so "HIGHER" and a HIGH in any other
field both count.
BUG 2: scans the WHOLE string once, so it cannot even tell you
per-record counts and double-counts a line with two hits. */
int count_high_bad(const char *json) {
int n = 0;
const char *p = json; /* BUG 3: no NULL check -> crash */
while ((p = strstr(p, "HIGH")) != NULL) {
n++;
p += 4; /* advance past the match */
}
return n;
}
Given a feed with one "severity":"HIGH" and one "note":"HIGHER RISK", this returns 2 — a false HIGH. On NULL input it dereferences a null pointer and crashes.
/* secure_count.c -- pinned, line-scoped, NULL-safe HIGH counter. */
#include <string.h>
#include <stddef.h>
/* Count records whose line contains the exact value "severity":"HIGH".
Returns 0 for NULL input. Reads only; never writes to json. */
int count_high_severity(const char *json) {
if (json == NULL) {
return 0; /* untrusted input: fail closed */
}
const char *needle = "\"severity\":\"HIGH\"";
int count = 0;
const char *line = json;
while (*line != '\0') {
/* Find the end of this line (or the end of the string). */
const char *nl = strchr(line, '\n');
size_t len = (nl != NULL) ? (size_t)(nl - line)
: strlen(line);
/* Search only within [line, line+len). We can use strstr here
because the needle has no '\n', so a match that starts inside
the line must also END inside the line — as long as its start
offset leaves room for its length. */
const char *hit = strstr(line, needle);
if (hit != NULL && (size_t)(hit - line) + strlen(needle) <= len) {
count++; /* pinned + line-scoped hit */
}
if (nl == NULL) {
break; /* final line had no newline */
}
line = nl + 1; /* advance past the '\n' */
}
return count;
}
/* test_count.c -- compile: cc -std=c11 -Wall -Wextra \
secure_count.c test_count.c -o test_count */
#include <assert.h>
#include <stddef.h>
#include <stdio.h>
int count_high_severity(const char *json);
int main(void) {
/* GOOD: two real HIGH records among four -> expect 2 */
const char *good =
"{\"id\":\"CVE-2024-0001\",\"severity\":\"LOW\"}\n"
"{\"id\":\"CVE-2024-0002\",\"severity\":\"HIGH\"}\n"
"{\"id\":\"CVE-2024-0003\",\"severity\":\"CRITICAL\"}\n"
"{\"id\":\"CVE-2024-0004\",\"severity\":\"HIGH\"}"; /* no final \n */
assert(count_high_severity(good) == 2);
/* REJECT loose match: HIGHER must NOT count -> expect 0 */
const char *tricky =
"{\"note\":\"HIGHER RISK\",\"severity\":\"MEDIUM\"}\n";
assert(count_high_severity(tricky) == 0);
/* REJECT wrong key: HIGH in another field must NOT count -> 0 */
const char *wrongkey =
"{\"label\":\"HIGH\",\"severity\":\"LOW\"}\n";
assert(count_high_severity(wrongkey) == 0);
/* Edge cases: NULL and empty -> 0, no crash. */
assert(count_high_severity(NULL) == 0);
assert(count_high_severity("") == 0);
printf("all checks passed\n");
return 0;
}
Expected output when you build and run ./test_count:
all checks passed
If any assert fails, the program aborts and names the failing line — that is the signal your matching logic drifted.
Walking through count_high_severity on the good fixture:
if (json == NULL) return 0; — the input crossed the trust boundary, so we guard first. Good input passes through.needle = "\"severity\":\"HIGH\""; — the pinned pattern. In memory this is the 16 bytes "severity":"HIGH".line = json; — start of record 1.strchr(line, '\n') finds the record boundary; len is the record length (or strlen on the last, newline-less record).strstr(line, needle) searches for the pinned value. The bounds check (hit - line) + strlen(needle) <= len confirms the match lies entirely inside this record — belt-and-suspenders, since the needle has no newline.line = nl + 1; advances to the next record; break ends the loop on the final line.Trace over the four records:
| Record | Line contents (abbrev.) | strstr hit? |
In-line? | count after |
|---|---|---|---|---|
| 1 | ..."severity":"LOW"} |
no | — | 0 |
| 2 | ..."severity":"HIGH"} |
yes | yes | 1 |
| 3 | ..."severity":"CRITICAL"} |
no | — | 1 |
| 4 | ..."severity":"HIGH"} (no \n) |
yes | yes | 2 |
After record 4, nl == NULL, so the loop breaks and returns count == 2. On the tricky fixture, strstr never finds "severity":"HIGH" (the value is "MEDIUM"; HIGHER lacks the pinned quotes and key), so the count stays 0 — exactly the rejection we want.
HIGH. Why wrong: it also matches HIGHER, HIGHLIGHT, and a HIGH sitting in an unrelated field, inflating the count. Corrected: pin both ends with the full value "severity":"HIGH". Recognise/prevent: add a test whose feed contains HIGHER and assert the count is 0.strstr loop, ignoring line boundaries. Why wrong: a single physical record with two hits double-counts, and a key on one line could visually pair with a value on the next in pretty-printed input. Corrected: scope each search to one line and count at most once per record. Recognise/prevent: test a line that legitimately contains the needle twice; the record should count once.NULL check. Why wrong: feeds are external; a missing file or failed download hands you NULL, and strstr(NULL, ...) is undefined behavior (typically a crash). Corrected: return 0 on NULL — fail closed. Recognise/prevent: assert count_high_severity(NULL) == 0.'\n' is seen. Why wrong: the last record often has no trailing newline, so it is silently dropped and a real HIGH is missed. Corrected: handle the final, newline-less segment before breaking. Recognise/prevent: end a test fixture without a trailing \n and confirm the last record still counts."severity": "HIGH") or escaped bytes make a valid HIGH invisible to the sweep, so "count 0" is misread as "nothing urgent." Corrected: label the output as triage and confirm real decisions against a parser + CVSS. Recognise/prevent: document the fixture format the sweep assumes.HIGH and see whether any hit is HIGHER or a non-severity field. Print each matched line to confirm which records the counter accepted.\n? Temporarily print len and the tail of line on the final iteration to confirm the newline-less segment is processed.NULL guard or an unterminated buffer. Rebuild with -fsanitize=address -g and rerun the tests; AddressSanitizer will point at the exact bad read.strstr never matches even for a real HIGH. Print the needle and a suspect line side by side. Look for hidden whitespace after the colon or smart quotes copied from a document — the pinned literal must match byte-for-byte.'\0'-terminated? Did I test both a rejection case (HIGHER) and an acceptance case (a real HIGH)?Build with warnings on and fix them: cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -g *.c -o test && ./test.
Memory & UB safety (C). The needle contains no '\n', so a strstr match that starts inside a line also ends inside it — but only if the line has room for the full needle. The explicit bounds check (hit - line) + strlen(needle) <= len makes that guarantee visible and survives future edits. Every pointer read here stays inside a '\0'-terminated string: strchr, strstr, and strlen all stop at the terminator, which is why an unterminated buffer would be undefined behavior. The NULL guard prevents dereferencing a null feed pointer. There are no allocations, so there is nothing to free; the function is read-only and never writes through json. Validate with -fsanitize=address,undefined.
Security & safety — detection & logging. When this counter feeds a real triage pipeline, log around it so abuse and errors are visible:
severity key) so silent misses become visible.API_KEY=<development-placeholder> in configs), session cookies, private keys, or full contents of unrelated PII. A CVE feed is public data, but the credentials that fetch it are not."severity":"HIGH"-like bytes could over-count. Cross-checking a sample against a real parser is how you keep the counter honest.Authorized real-world use case. A platform team ingests the daily NVD JSON feed and a vendor PSIRT bundle into a triage job that runs on their own build server. A lightweight counter like this produces the top-line "N HIGH today" number that decides whether the on-call engineer is paged. It runs entirely on infrastructure the team owns, over public feed data — no third-party systems are touched.
Beginner best practices.
NULL, handle empty and newline-less input.HIGHER) and an acceptance (real HIGH).Advanced best practices.
severity field.API_KEY=<development-placeholder> in the checked-in config, real value from a secrets manager).All tasks are lab-only: run them on your own machine against fixture files you create. Never point any of this at a system you do not own or are not explicitly authorized to test. Authorization checklist: (1) the feed is public data or your own fixture; (2) the code runs on localhost or a container you control; (3) no third-party host is contacted. Cleanup/reset: delete any fixture files you generated and clear scratch output when done.
Beginner 1 — Empty and NULL safety.
count_high_severity(NULL) and count_high_severity("").Beginner 2 — Reject the loose match.
"HIGHER" and a non-severity HIGH do not count."note":"HIGHER RISK", one with "label":"HIGH" and "severity":"LOW".Intermediate 1 — Count by any severity level.
int count_severity(const char *json, const char *level) where level is e.g. "CRITICAL"."severity":"<level>"; keep line scoping and the NULL guard."CRITICAL" returns the CRITICAL count.level too long to fit.snprintf and check its return value.Intermediate 2 — Report parse anomalies.
"severity": key at all.Challenge — Sweep vs. parser disagreement audit.
"severity": "HIGH", note the space) where the sweep returns 0 though a HIGH exists; document the miss; then remediate by either normalizing whitespace before the sweep or switching that path to a real JSON parser, and add a test proving the HIGH is now counted.strstr per line for "severity":"HIGH". That count is a fast triage signal for what deserves human attention.strstr(line, "\"severity\":\"HIGH\"") for the pinned match, strchr(line, '\n') to find record boundaries; build and check with cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -g *.c -o test.HIGH matching (counts HIGHER), ignoring line boundaries, no NULL guard, dropping the final newline-less record, and trusting the sweep as a verdict.NULL), pin both ends of the value, test a rejection and an acceptance, log source/counts/decision with a timestamp — and never claim the count is authoritative. A substring sweep is triage; a real parser plus CVSS context is the truth. Decoding a label is not the same as verifying the risk.