Safe Penetration Testing Labs · intermediate · ~15 min

Count HIGH-severity entries in a mock CVE feed

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

Overview

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.

Why it matters

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.

Core concepts

1. The vulnerability feed as untrusted input

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.

2. Line-by-line scanning

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.

3. Pinned substring matching

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.

4. The sweep is triage, not truth

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.

  1. What asset is this counter ultimately protecting, and how does a wrong count harm it?
  2. Where is the trust boundary in the diagram, and what does that imply about how you handle the json pointer?
  3. Which insecure assumption would make "HIGHER" inflate your HIGH count, and how does a pinned match remove it?

Syntax notes

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:

  • The needle is a normal C string literal. Inside C source, each " 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.
  • Both functions stop at the first '\0'. That is why a valid, terminated C string is a precondition — an unterminated buffer is undefined behavior.

Lesson

Why this matters

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.

What we will build

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

What the file looks like

{"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.

Your job

Implement:

int count_high_severity(const char *json)

Follow these rules:

  • Walk the input line by line.
  • On each line that contains "severity":"HIGH" exactly, increment the counter.
  • Return the final count.
  • Return 0 if the input is NULL.

Common mistakes

  • Loose matching. Matching HIGH against a line that contains "HIGHER". Pin the match to "severity":"HIGH" — include both the start and the end of the value.
  • Wrong key. Treating "HIGH" in any field's value as a hit. Only the severity key counts.

What this is NOT

  • Not a JSON parser. Comments, escape sequences, and whitespace variations are all ignored.
  • Not a CVSS scorer. It only counts; it does not compute severity scores.

Code examples

The task is defensive tooling, but the first version shows the common insecure/loose shortcut so you can see exactly what to fix.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

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.

(2) SECURE fix — pinned match, line by line, NULL-safe

/* 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;
}

(3) VERIFY — prove it REJECTS bad input and ACCEPTS good input

/* 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.

Line by line

Walking through count_high_severity on the good fixture:

  1. if (json == NULL) return 0; — the input crossed the trust boundary, so we guard first. Good input passes through.
  2. needle = "\"severity\":\"HIGH\""; — the pinned pattern. In memory this is the 16 bytes "severity":"HIGH".
  3. line = json; — start of record 1.
  4. Loop iteration for each line: strchr(line, '\n') finds the record boundary; len is the record length (or strlen on the last, newline-less record).
  5. 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.
  6. 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.

Common mistakes

  • WRONG: matching bare 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.
  • WRONG: scanning the whole string with a repeated 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.
  • WRONG: no 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.
  • WRONG: acting only when a '\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.
  • WRONG: trusting the sweep as the security verdict. Why wrong: whitespace ("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.

Debugging tips

  • Count is too high. Suspect loose matching. Grep your fixture for HIGH and see whether any hit is HIGHER or a non-severity field. Print each matched line to confirm which records the counter accepted.
  • Count is too low / off by one. Check the last record. Does your fixture end without a \n? Temporarily print len and the tail of line on the final iteration to confirm the newline-less segment is processed.
  • Crash / segfault on run. Almost always a missing 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.
  • Questions to ask when it fails: Is the input actually one-record-per-line? Did the feed use spaces inside objects? Is the string '\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 safety

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:

  • Log: a UTC timestamp; the feed source or filename; the record count read; the HIGH count produced; the security decision ("paged" / "deferred"); and a correlation id tying this run to the ingest job. Log parse anomalies too — records the sweep could not interpret (unexpected whitespace, missing severity key) so silent misses become visible.
  • Never log: API keys or tokens used to fetch the feed (use 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.
  • Signals of abuse / trouble: a sudden spike to thousands of HIGH records (possible poisoned or malformed feed), a feed size far larger than usual, or repeated "could not parse" anomalies — all warrant a human look before acting on the count.
  • False positives arise when the sweep's fixed-format assumption breaks: a feed that adds a space after the colon makes valid HIGH entries invisible (false "all clear"), and an unrelated field containing "severity":"HIGH"-like bytes could over-count. Cross-checking a sample against a real parser is how you keep the counter honest.

Real-world uses

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.

  • Validate input first: guard NULL, handle empty and newline-less input.
  • Pin your matches; test both a rejection (HIGHER) and an acceptance (real HIGH).
  • Label the output as a triage estimate, never as a verdict.
  • Log the source, counts, and decision with a timestamp and correlation id.

Advanced best practices.

  • Replace the sweep with a real JSON parser once the fixture assumption no longer holds (pretty-printed feeds, escapes, reordered keys), and count on the parsed severity field.
  • Cross-reference the CVSS vector, not just the label, before escalating — severity depends on exploitability and exposure in your environment, not the raw score alone.
  • Apply least privilege to the ingest job: read-only feed access, its own service account, and a separate, minimal credential (API_KEY=<development-placeholder> in the checked-in config, real value from a secrets manager).
  • Add secure defaults: fail closed on fetch errors, alert on anomalous feed sizes, and keep the parser and the sweep in agreement via a periodic sample audit.

Practice tasks

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.

  • Objective: prove your counter never crashes on missing input.
  • Requirements: call count_high_severity(NULL) and count_high_severity("").
  • Expected output: both return 0.
  • Constraints: no changes to the function; only add asserts.
  • Hints: fail closed — treat absent input as "nothing to count."
  • Concepts: untrusted input, NULL guarding.

Beginner 2 — Reject the loose match.

  • Objective: confirm "HIGHER" and a non-severity HIGH do not count.
  • Requirements: build a two-record fixture, one with "note":"HIGHER RISK", one with "label":"HIGH" and "severity":"LOW".
  • Input/Output: fixture in, count 0 out.
  • Constraints: one record per line.
  • Hints: if the count is 2, your needle is not pinned.
  • Concepts: pinned matching, false positives.

Intermediate 1 — Count by any severity level.

  • Objective: generalize to int count_severity(const char *json, const char *level) where level is e.g. "CRITICAL".
  • Requirements: build the pinned needle at runtime (e.g. into a stack buffer) as "severity":"<level>"; keep line scoping and the NULL guard.
  • Input/Output: feed + "CRITICAL" returns the CRITICAL count.
  • Constraints: bound the buffer; reject a level too long to fit.
  • Hints: use snprintf and check its return value.
  • Concepts: safe string building, bounds checking.

Intermediate 2 — Report parse anomalies.

  • Objective: alongside the HIGH count, count records that contain no "severity": key at all.
  • Requirements: return both counts (via an out-parameter or struct); log each anomalous line's index.
  • Input/Output: a feed with one key-less record yields anomaly count 1.
  • Constraints: still one pass, line-scoped.
  • Hints: a missing key is a signal the sweep may be silently missing HIGH entries.
  • Concepts: detection, logging what the sweep cannot interpret.

Challenge — Sweep vs. parser disagreement audit.

  • Objective: show, in a controlled lab, one input where the substring sweep and a correct interpretation disagree, then remediate.
  • Requirements: craft a valid-but-spaced fixture ("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.
  • Constraints: lab fixtures only; no external feeds.
  • Hints: whitespace after the colon is the classic sweep blind spot.
  • Concepts to use: limits of substring matching, mitigation verification, defensive fallback.
  • Defensive conclusion: a sweep is triage, not truth — verify the fix rejects the blind spot (the previously-missed HIGH now counts) and keep the parser as the authority for real decisions.

Summary

  • Core idea: walk a newline-delimited CVE fixture line by line and run one pinned strstr per line for "severity":"HIGH". That count is a fast triage signal for what deserves human attention.
  • Key syntax/commands: 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.
  • Common mistakes: loose HIGH matching (counts HIGHER), ignoring line boundaries, no NULL guard, dropping the final newline-less record, and trusting the sweep as a verdict.
  • What to remember: treat the feed as untrusted (fail closed on 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.

Practice with these exercises