Safe Penetration Testing Labs · beginner · ~12 min

Generate a Markdown finding report in C

What you will learn: - Model a single security finding as a C `struct` (`finding_t`) with the fields a real report needs. - Render that struct into a clean, skimmable Markdown section using **bounded** formatting (`snprintf`), never `sprintf`. - Detect and safely reject output that would overflow the destination buffer, always leaving the result NUL-terminated. - Apply the professional vulnerability-finding template (title, severity, affected component, evidence, impact, likelihood, remediation, retest) and understand that severity depends on exploitability and impact, not opinion. - Sort findings worst-first by mapping a severity label to a numeric rank. - Handle report output as attacker-influenced data: log safely, avoid leaking secrets into the report, and understand what a report can and cannot claim about security.

Overview

Security objective: the asset you are protecting here is the integrity and safety of your own report pipeline. A penetration-test report is the deliverable a client acts on. If the tool that builds it can be crashed or corrupted by long, hostile field values (an evidence string copied straight from a target response, for example), the report becomes unreliable — and a buffer overflow in your own tooling is exactly the kind of bug you are being paid to find in others. The threat is untrusted text flowing into a fixed-size buffer; the learner will detect and prevent that overflow, and will produce output that renders correctly in any Markdown viewer.

This lesson builds directly on C strings (you already know a C string is a char array ending in a hidden '\0' NUL byte, and that its length excludes that byte) and on The main function (you know how int main returns a status code and how a program's entry point drives the work). Here you combine both: a main that feeds structured data into a string formatter and checks its return value.

Where does this fit? After you run an authorized test — parsing an auth log, reading a pcap record, probing a lab web app — you have raw notes. A report formatter turns one note into one Markdown block. Many blocks, sorted worst-first, become a deliverable a human can read and a machine can parse. This is the reporting stage of the engagement, and it is where careless string handling most often bites.

Why it matters

In authorized, professional security work the report is the product. Clients rarely watch you test; they read what you deliver. Three things make a report trustworthy, and all three are touched by this lesson:

  • It renders correctly. A stray missing blank line collapses two sections into one; a corrupted buffer produces garbage. Deterministic, bounded formatting means the client sees exactly what you intended.
  • It is safe to generate. Evidence fields often contain raw bytes captured from a target — long, weird, sometimes deliberately malformed. If your generator overflows a stack buffer on a 5,000-byte evidence string, you have shipped the same class of bug you were hired to find. Bounded string APIs are the professional default.
  • It is skimmable and sortable. A consistent template lets a reader jump to severity, asset, and remediation in seconds, and lets tooling sort findings worst-first or diff two reports across a retest. Consistency comes from a single formatter, not hand-typed Markdown.

Every mature security team standardizes on a finding template and a machine-readable format precisely so reports are comparable across testers and across time. Learning to emit that format safely in C is a small, real slice of that discipline.

Core concepts

1. The finding_t struct — one record, grouped fields

Definition: a struct groups related values under one name. finding_t holds the fields of a single finding.

Plain explanation: instead of juggling five loose strings, you pass one pointer to a record. The formatter reads f->title, f->severity, and so on.

How it works: each field is a const char * — a pointer to a read-only C string the formatter only reads, never modifies. const documents that promise and lets the compiler catch accidental writes.

When / when not: use a struct when fields travel together and have one lifetime. Don't over-nest; a flat record of strings is perfect for a report row.

Pitfall: the struct only stores pointers. The actual text must outlive the struct. A pointer to a local buffer that has gone out of scope is a dangling read.

2. Bounded formatting with snprintf

Definition: snprintf(out, cap, fmt, ...) formats into out, writing at most cap bytes including the NUL terminator.

Plain explanation: it is sprintf with a seatbelt. You tell it how big the buffer is, and it never writes past that.

How it works: it returns the number of characters it would have written had space been unlimited (excluding the NUL). So if the return value is >= cap, the text was truncated — that is your overflow signal. If it is < cap, everything fit and the buffer is NUL-terminated.

When / when not: always prefer snprintf for fixed buffers. Use it even for "obviously short" output — field values you don't control can be arbitrarily long.

Pitfall: the return value is intended length, not bytes actually written. Comparing it against cap is the whole point; ignoring it re-creates the sprintf overflow you were avoiding.

3. The overflow check and the NUL-termination guarantee

Definition: an overflow occurs when the text you want to write does not fit in cap bytes.

Plain explanation: on overflow you must not pretend it succeeded. Return an error, and make sure whatever is in out is still a valid (terminated) string so a caller that ignores the error at least doesn't read past the end.

How it works: capture int n = snprintf(...). If n < 0 (encoding error) or (size_t)n >= cap (truncation), fail. snprintf already writes a NUL within cap when cap > 0, but on the error path you should defensively set out[0] = '\0' if you built the string in pieces.

When / when not: every bounded write needs this check. There is no case where silently truncating a security finding is acceptable — a half-written remediation is dangerous.

Pitfall: off-by-one. cap includes the NUL. A buffer of size cap holds at most cap - 1 visible characters.

4. The professional finding template and severity

Definition: a standard set of fields every finding carries so reports are comparable.

Plain explanation: a good finding answers: what is it, how bad, where, how do I know, what happens if abused, how likely, how do I fix it, and did the fix hold on retest.

How it works: map struct fields to template sections. Full template: title, severity, affected component, preconditions, safe reproduction, evidence, impact, likelihood, remediation, retest. Severity is not a vibe — it is a function of exploitability × access required × impact. A theoretical issue needing local admin is not "critical" just because it sounds scary.

When / when not: use the full template for a client deliverable. A quick lab note can be shorter, but never drop remediation.

Pitfall: rating everything "High/Critical" destroys trust and buries the finding that actually matters. Justify severity from impact and exploitability.

5. Sorting worst-first via a severity rank

Definition: a function severity_rank(const char *sev) maps a label to a number so findings can be ordered.

Plain explanation: readers want the worst finding first. Convert "Critical/High/Medium/Low/Info" to ranks, then sort descending.

Pitfall: unknown labels. Return -1 for anything unrecognized rather than silently ranking it as "Info" — a typo shouldn't hide a serious finding.

THREAT MODEL — the report generator

  [Target system]  --(authorized test)-->  raw notes / captured bytes
        (untrusted text: titles, evidence copied from responses)
                     |
   ==================|=================== trust boundary
   fixed-size out[]  v   (attacker-influenced length & content)
        +--------------------------------+
        |  render_finding(f, out, cap)   |  <-- ENTRY POINT for hostile data
        |  snprintf, bounded, checked    |
        +--------------------------------+
                     |
                     v
        Markdown report file  --> read by human client / parsed by tooling

Assets protected : integrity of the report; safety (no overflow) of the tool
Trust boundary   : the fixed-size buffer 'out' vs. arbitrary-length field text
Entry points     : f->evidence, f->title, etc. — strings you did not author
Insecure assumption to avoid: "field values are short and well-formed"

Knowledge check:

  1. What asset is protected by using snprintf instead of sprintf here? (The integrity of the report and the memory safety of your own tool.)
  2. Where is the trust boundary in the diagram, and which field is the most likely carrier of hostile-length data? (The boundary is the fixed-size out buffer; f->evidence, since it is often raw captured bytes.)
  3. What insecure assumption causes an overflow in a naive formatter? (Assuming field values are short and well-formed instead of arbitrary-length, attacker-influenced text.)

Syntax notes

The one API that carries this lesson is snprintf, plus the struct that feeds it.

#include <stdio.h>   // snprintf

typedef struct {
    const char *title;          // short label, e.g. "Missing HSTS header"
    const char *severity;       // "Critical"|"High"|"Medium"|"Low"|"Info"
    const char *asset;          // affected component, e.g. a lab URL
    const char *evidence;       // proof — may be long / attacker-influenced
    const char *recommendation; // remediation guidance
} finding_t;

// n = number of chars snprintf WOULD write (excluding the NUL).
// cap = total buffer size, INCLUDING room for the NUL.
int n = snprintf(out, cap,
                 "## %s\n**Severity:** %s\n",
                 f->title, f->severity);

// Truncation / overflow test:
if (n < 0 || (size_t)n >= cap) {
    // did NOT fit — treat as failure, do not use 'out'
}

Key rules encoded above:

  • cap includes the NUL byte; visible text is capped at cap - 1.
  • Blank lines in Markdown come from \n\n — they separate blocks. Missing them collapses sections.
  • %s with a NULL pointer is undefined behavior; guard fields that could be NULL before formatting.

Lesson

Why this matters

The hardest part of any security engagement is the writeup.

A clean Markdown template turns raw notes into a deliverable. A good template covers:

  • Title
  • Severity
  • Affected asset
  • Evidence
  • Recommendation

This exercise focuses on the formatter side. Given a struct, you produce Markdown that a human can read.

What the struct looks like

The data for one finding lives in a struct (a record that groups related fields). Each field is a string:

typedef struct {
    const char *title;         // "Missing HSTS header"
    const char *severity;      // "Low", "Medium", "High"
    const char *asset;         // "https://example.com/"
    const char *evidence;      // "Response missing 'Strict-Transport-Security'"
    const char *recommendation;// "Add HSTS with max-age >= 31536000"
} finding_t;

Your job

Implement this function:

int render_finding(const finding_t *f, char *out, size_t cap);

It must:

  • Write the Markdown into out.
  • Use at most cap - 1 bytes of text, plus one byte for the NUL terminator.
  • Return the number of bytes written.
  • Return -1 if the output would overflow the buffer.

What is a NUL terminator? C strings end with a hidden zero byte ('\0'). Every string needs room for it, which is why the text is capped at cap - 1.

Expected format

## Missing HSTS header
**Severity:** Medium
**Asset:** https://example.com/

### Evidence
Response missing 'Strict-Transport-Security'

### Recommendation
Add HSTS with max-age >= 31536000

Common mistakes

  • Using sprintf without a length check. sprintf cannot know your buffer size, so it can overflow. Use snprintf, then compare its return value against cap.
  • Missing the blank line between sections. Markdown renderers rely on blank lines to separate blocks.
  • Forgetting NUL-termination on a too-small buffer. Always make sure the result is terminated, even on the overflow path.

Code examples

Below: first a clearly-labelled insecure version, then the secure fix, then a verify step that proves the fix rejects bad input and accepts good input.

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

/* insecure_report.c — DEMONSTRATION OF A BUG. Do not ship. */
#include <stdio.h>
#include <string.h>

typedef struct { const char *title, *severity, *asset, *evidence, *recommendation; } finding_t;

/* BUG: sprintf has no idea how big 'out' is. A long evidence string
   copied straight from a target overruns the buffer and corrupts memory. */
int render_finding_bad(const finding_t *f, char *out) {
    return sprintf(out,
        "## %s\n**Severity:** %s\n**Asset:** %s\n\n### Evidence\n%s\n\n### Recommendation\n%s\n",
        f->title, f->severity, f->asset, f->evidence, f->recommendation);
}

int main(void) {
    char small[64];                 /* deliberately too small */
    finding_t f = { "XSS", "High", "http://127.0.0.1:8080/",
        /* imagine this evidence was captured from a target response */
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
        "Encode output" };
    render_finding_bad(&f, small);  /* overflows 'small' — undefined behavior */
    printf("%s\n", small);
    return 0;
}

Compiled with sanitizers, this aborts with a stack-buffer-overflow — see the VERIFY step. That is the vulnerability, reproduced in a lab so you can see it fail safely.

(2) SECURE fix — bounded and checked

/* report.c — bounded, overflow-safe finding renderer. C11. */
#include <stdio.h>
#include <string.h>

typedef struct {
    const char *title, *severity, *asset, *evidence, *recommendation;
} finding_t;

/* Returns bytes written (excluding NUL) on success, or -1 on overflow /
   error. 'out' is always left NUL-terminated when cap > 0. */
int render_finding(const finding_t *f, char *out, size_t cap) {
    if (!f || !out || cap == 0) return -1;
    out[0] = '\0';                       /* safe default on every error path */

    /* Guard NULL fields: %s on NULL is undefined behavior. */
    const char *title = f->title          ? f->title          : "(untitled)";
    const char *sev   = f->severity       ? f->severity       : "Info";
    const char *asset = f->asset          ? f->asset          : "(unknown)";
    const char *evid  = f->evidence       ? f->evidence       : "(none)";
    const char *rec   = f->recommendation ? f->recommendation : "(none)";

    int n = snprintf(out, cap,
        "## %s\n"
        "**Severity:** %s\n"
        "**Asset:** %s\n"
        "\n"
        "### Evidence\n%s\n"
        "\n"
        "### Recommendation\n%s\n",
        title, sev, asset, evid, rec);

    if (n < 0 || (size_t)n >= cap) {      /* encoding error or truncation */
        out[0] = '\0';                    /* do not hand back a half-report */
        return -1;
    }
    return n;
}

/* Map a severity label to a rank so findings sort worst-first.
   Returns -1 for an unknown label instead of guessing. */
int severity_rank(const char *sev) {
    if (!sev) return -1;
    if (strcmp(sev, "Critical") == 0) return 4;
    if (strcmp(sev, "High")     == 0) return 3;
    if (strcmp(sev, "Medium")   == 0) return 2;
    if (strcmp(sev, "Low")      == 0) return 1;
    if (strcmp(sev, "Info")     == 0) return 0;
    return -1;
}

int main(void) {
    finding_t f = {
        .title          = "Missing HSTS header",
        .severity       = "Medium",
        .asset          = "http://127.0.0.1:8080/",   /* local lab only */
        .evidence       = "Response missing 'Strict-Transport-Security'",
        .recommendation = "Add HSTS with max-age >= 31536000"
    };

    char buf[512];
    int n = render_finding(&f, buf, sizeof buf);
    if (n < 0) {
        fprintf(stderr, "render_finding: buffer too small\n");
        return 1;
    }
    fputs(buf, stdout);
    printf("[rank=%d, %d bytes]\n", severity_rank(f.severity), n);
    return 0;
}

Expected output:

## Missing HSTS header
**Severity:** Medium
**Asset:** http://127.0.0.1:8080/

### Evidence
Response missing 'Strict-Transport-Security'

### Recommendation
Add HSTS with max-age >= 31536000
[rank=2, 189 bytes]

(The exact byte count depends on the field text; the point is n >= 0.)

(3) VERIFY — prove the fix rejects bad input and accepts good input

/* verify.c — link with the report.c definitions (or #include them). */
#include <stdio.h>
#include <string.h>
#include <assert.h>

/* Same struct the renderer uses (in a real project this comes from a shared header). */
typedef struct { const char *title, *severity, *asset, *evidence, *recommendation; } finding_t;

int render_finding(const finding_t *f, char *out, size_t cap);
int severity_rank(const char *sev);

int main(void) {
    finding_t f = { "Title", "High", "http://127.0.0.1/", "evid", "fix" };

    /* ACCEPT: a large enough buffer succeeds and is NUL-terminated. */
    char big[256];
    int n = render_finding(&f, big, sizeof big);
    assert(n > 0);
    assert(big[n] == '\0');          /* terminator present */
    assert(strstr(big, "## Title")); /* content correct */

    /* REJECT: a tiny buffer returns -1 and leaves an empty, safe string. */
    char tiny[8];
    assert(render_finding(&f, tiny, sizeof tiny) == -1);
    assert(tiny[0] == '\0');         /* no half-written garbage */

    /* Severity ranking, including the unknown-label guard. */
    assert(severity_rank("Critical") == 4);
    assert(severity_rank("Low")      == 1);
    assert(severity_rank("bogus")    == -1);

    puts("all checks passed");
    return 0;
}

Build both the insecure demo (with sanitizers, to see the overflow) and the secure version:

# See the bug fail safely under a sanitizer (lab only):
cc -std=c11 -g -fsanitize=address -o insecure_report insecure_report.c
./insecure_report          # AddressSanitizer reports stack-buffer-overflow

# Build and self-test the secure version:
cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -o report report.c
./report                   # prints the report cleanly, no sanitizer errors

Line by line

Walkthrough of the secure render_finding on the sample finding with cap = 512.

Step Code What happens
1 `if (!f
2 out[0] = '\0'; Establish a safe default: if anything below fails, out is already a valid empty string.
3 title = f->title ? f->title : "(untitled)"; (and peers) Replace any NULL field with a placeholder. %s on NULL is undefined behavior, so this guard is mandatory. All five fields are non-NULL here, so the real values pass through.
4 int n = snprintf(out, cap, ...) Format the whole block. snprintf writes up to cap - 1 visible bytes plus a NUL, and returns the length it would need. For the sample that is ~189, well under 512.
5 `if (n < 0
6 error branch: out[0] = '\0'; return -1; Only on overflow: wipe to empty and report failure so a caller never emits a truncated finding. Skipped here.
7 return n; Success — return the byte count (189).

How the key value changes: n is the single source of truth. It starts as the desired length and is immediately compared to cap. That one comparison is the entire safety mechanism — it converts "I hope it fit" into "I proved it fit."

For severity_rank("Medium"): strcmp("Medium","Critical")!=0, !="High", then =="Medium" → return 2. An unrecognized label falls through every branch to the final return -1.

Common mistakes

1. Using sprintf (or strcpy/strcat) into a fixed buffer.

  • WHY wrong: none of them know the buffer size; a long field silently overruns it — memory corruption, the exact bug class you test others for.
  • CORRECTED: use snprintf with the real cap and check the return value.
  • Recognise/prevent: grep -n 'sprintf\|strcpy\|strcat' *.c; compile with -Wall -Wextra and run under -fsanitize=address.

2. Ignoring the snprintf return value.

  • WHY wrong: snprintf won't overflow, but it will silently truncate. A truncated remediation section is dangerous and looks fine.
  • CORRECTED: if (n < 0 || (size_t)n >= cap) return -1;
  • Recognise/prevent: any snprintf whose result you don't test is a smell.

3. Off-by-one on cap.

  • WHY wrong: treating cap as "visible characters" forgets the NUL; you lose one byte or write one too many.
  • CORRECTED: remember cap includes the NUL; visible text ≤ cap - 1.
  • Recognise/prevent: pass sizeof buf (not sizeof buf - 1) as cap; let snprintf reserve the NUL.

4. Passing a NULL field to %s.

  • WHY wrong: undefined behavior — often a crash.
  • CORRECTED: substitute a placeholder for any possibly-NULL field before formatting.
  • Recognise/prevent: know your data source; if a field is optional, guard it.

5. Rating every finding "Critical."

  • WHY wrong: severity is exploitability × access × impact; inflating it buries the real issue and erodes client trust.
  • CORRECTED: justify severity from evidence and impact; use severity_rank consistently.
  • Recognise/prevent: for each finding, write one sentence on how it is exploited and what an attacker gains before assigning severity.

6. Missing blank lines between sections.

  • WHY wrong: Markdown needs a blank line (\n\n) to break blocks; without it, headings and text merge.
  • CORRECTED: keep the \n \n separators in the format string.
  • Recognise/prevent: preview the output in a real Markdown renderer, not just the terminal.

Debugging tips

Symptom: crash or AddressSanitizer "stack-buffer-overflow."

  • Rebuild with -fsanitize=address,undefined -g and read the report — it names the buffer and the write. Almost always a sprintf/strcpy or an undersized cap. Switch to snprintf and pass sizeof buf.

Symptom: report is cut off mid-sentence.

  • The buffer was too small and you ignored the return value. Add the n >= cap check; either enlarge the buffer or report -1 and let the caller grow it.

Symptom: two sections rendered as one paragraph.

  • A missing \n\n. Print the raw bytes (od -c out or printf("%s", buf)) and confirm the blank line is really there before blaming the renderer.

Symptom: garbage or crash printing a field.

  • A NULL (or dangling) const char *. Check that the text backing each field outlives the struct and is non-NULL; add the placeholder guards.

Questions to ask when it fails:

  • What is the largest possible value of every field? Does cap cover the worst case, or do I correctly return -1?
  • Is out NUL-terminated on every path, including the error path?
  • Did I pass sizeof buf (bytes) and not element count or sizeof buf - 1?
  • Under ASan/UBSan with -Wall -Wextra, is the build truly clean?

Memory safety

Memory / undefined-behavior safety (C)

  • Bounds: snprintf is the guardrail, but you must honor its contract — pass the true cap and act on the return value. Never mix in sprintf/strcat "just for one field."
  • NUL termination: guarantee it on every path. snprintf terminates within cap when cap > 0; on your own error path set out[0] = '\0' so a caller that ignores -1 still reads a valid empty string.
  • NULL / dangling pointers: %s on NULL is UB; the struct stores only pointers, so the backing text must outlive the struct. Guard optional fields with placeholders.
  • Off-by-one: cap includes the NUL. Visible characters ≤ cap - 1.
  • Prove it: compile with -Wall -Wextra -fsanitize=address,undefined and run the VERIFY self-tests in CI.

Security & safety — detection and logging for the report pipeline

When a tool generates reports from attacker-influenced input, log the event, not the payload.

  • Log: timestamp; source (which engagement / scan / operator, a correlation id); resource (which target asset the finding concerns); result (success, or -1 overflow / rejected input); the security-relevant decision ("evidence truncated and rejected"); and the finding id. Example line: 2026-07-08T10:14Z op=alice corr=ENG-42 asset=127.0.0.1:8080 finding=F-3 result=RENDER_OK bytes=189.
  • Never log: passwords, API tokens, session cookies, private keys, full PANs, or unneeded PII — even if they turned up in captured evidence. Redact secrets before they reach a log or a report body; store them, if at all, only in a controlled evidence vault with placeholders like token=<redacted> in the report.
  • Events that signal abuse of the generator: a spike of -1 overflow rejections (someone or something feeding pathologically long fields), findings referencing assets outside the authorized scope, or evidence containing what looks like live credentials (a redaction failure).
  • False positives arise when legitimate long evidence (a full HTTP response) trips the overflow path — that is not an attack, it means grow the buffer or stream to a file. Distinguish "my buffer was small" from "input was hostile" by size and frequency, not a single event.
  • Authorization reminder: this generator only ever processes data from systems you own or are explicitly authorized to test. The 127.0.0.1 asset in the example is deliberate — labs run on localhost / containers / intentionally-vulnerable VMs / CTF, never real third-party hosts.

Real-world uses

Authorized real-world use case: during an engagement you scope to a client-owned staging host, you accumulate raw notes. A small C (or scripted) generator turns each note-struct into a Markdown block, sorts them worst-first with severity_rank, and concatenates them into report.md. The client reads it; your tooling can also diff this run against the previous retest to show which findings were fixed.

Professional best-practice habits:

  • Input validation: treat every field as arbitrary-length, untrusted text; bound every write; reject and log truncation rather than shipping a partial finding.
  • Least privilege: the generator needs read access to notes and write access to one output file — nothing more. Don't run it as root; don't give it network access.
  • Secure defaults: default severity to Info, default missing fields to explicit placeholders, and default to failing closed (-1) on overflow.
  • Logging: record render events and rejections with a correlation id; never write secrets into logs or the report body.
  • Error handling: always check snprintf's return; propagate -1 so nothing silently truncates.
  • Honest claims: a report says what you found and tested under given conditions. It never claims a system is "completely secure," and passing an automated scan is not proof of security — state scope and limitations.

Beginner vs advanced:

Beginner Advanced
Scope render one finding safely full pipeline: collect, dedupe, sort, emit
Format fixed Markdown template template + machine-readable (e.g. JSON/SARIF) side output
Severity pick from a fixed set justify via a scoring rubric (exploitability/impact)
Safety snprintf + return check fuzz the generator, ASan/UBSan in CI, redaction pass
Retest note fixed/not diff against prior report, track remediation status

Practice tasks

All tasks are lab-only: any evidence comes from systems you own or are explicitly authorized to test (localhost, containers, intentionally-vulnerable VMs, CTF). Each finishes with remediation and verification.

Authorization checklist (before any lab that captures evidence): (1) I own or have written authorization for the target; (2) the target is isolated (localhost / private container network); (3) scope and time window are agreed; (4) I have a cleanup/reset plan. Cleanup/reset: delete generated report.md and any captured evidence files, stop and remove lab containers, and clear scratch buffers holding sensitive text.

Beginner 1 — Render one finding.

  • Objective: call render_finding on a hard-coded finding_t and print the result.
  • Requirements: use a 512-byte buffer; check the return value; on -1 print an error to stderr and exit non-zero.
  • Output: the Markdown block for the sample finding.
  • Constraints: no sprintf/strcpy; C11; -Wall -Wextra clean.
  • Hints: pass sizeof buf as cap. Concepts: struct, snprintf, return-value check.

Beginner 2 — Severity rank + guard.

  • Objective: implement and test severity_rank.
  • Requirements: handle Critical/High/Medium/Low/Info; return -1 for anything else including NULL.
  • Input/Output: "High" -> 3, "xyz" -> -1, NULL -> -1.
  • Constraints: use strcmp; no crashes on NULL.
  • Hints: guard NULL first. Concepts: string compare, defensive defaults.

Intermediate 1 — Prove the overflow is rejected.

  • Objective: write the VERIFY harness for render_finding.
  • Requirements: one assertion that a big buffer succeeds and is NUL-terminated; one that an 8-byte buffer returns -1 and leaves out[0] == '\0'.
  • Constraints: build with -fsanitize=address,undefined; the run must be sanitizer-clean.
  • Hints: assert(big[n] == '\0'). Concepts: mitigation verification, fail-closed. Defensive conclusion: a passing REJECT test is the proof your bound holds.

Intermediate 2 — Multi-finding report, sorted worst-first.

  • Objective: render an array of findings into one buffer, ordered by severity_rank descending.
  • Requirements: append each block, tracking remaining capacity; if any append would overflow, stop and return -1 for the whole report (fail closed).
  • Input/Output: given Low+Critical, the Critical block appears first.
  • Constraints: no per-field strcat; bounded appends only.
  • Hints: keep an offset and pass cap - offset to each snprintf. Concepts: sorting, cumulative bounds.

Challenge — Redaction pass + safe logging.

  • Objective: before rendering, scan each field and replace anything matching a secret pattern (e.g. token=..., a long hex/Base64 blob) with <redacted>, then log a render event (timestamp, correlation id, asset, result, byte count) that contains no secret.
  • Requirements: redaction must run on evidence and asset; the log line must never contain the original secret; overflow still returns -1.
  • Constraints: lab-only data; no real credentials — use <development-placeholder> in tests. Do not invent hashing/crypto APIs; a simple pattern match is enough.
  • Hints: redact into a temporary bounded buffer, then render from the redacted copy. Concepts: input sanitization, detection/logging, least data. Defensive conclusion: verify with a test asserting the log and report contain <redacted> and not the original secret, then run the cleanup/reset steps.

Summary

Main concepts: a finding_t struct groups the fields of one security finding; render_finding turns it into a Markdown block using bounded, checked formatting; severity_rank orders findings worst-first. The asset you protect is the integrity and memory-safety of your own report pipeline against arbitrary-length, attacker-influenced field text.

Key syntax/commands:

  • int n = snprintf(out, cap, fmt, ...);cap includes the NUL; n is the intended length.
  • Overflow test: if (n < 0 || (size_t)n >= cap) { out[0] = '\0'; return -1; }
  • Build/prove: cc -std=c11 -Wall -Wextra -fsanitize=address,undefined ...

Common mistakes: sprintf/strcpy into fixed buffers; ignoring the snprintf return; off-by-one on cap; %s on NULL; missing \n\n between sections; rating everything Critical.

What to remember: bound every write and act on the result; keep out NUL-terminated on every path, including failure; justify severity from exploitability and impact; log the event but never the secret; a report describes what you tested in scope — it never proves a system is "completely secure," and a clean scanner run is not proof of security. Test only what you own or are authorized to test, in an isolated lab, and clean up afterward.

Practice with these exercises