Reporting & Professional Practice · intermediate · ~11 min
**What you will learn** - Write a self-contained penetration-test finding using a complete finding template (title, severity, affected component, preconditions, safe reproduction, evidence, impact, likelihood, remediation, retest). - Rate severity as **impact x likelihood**, adjusted for business context, instead of guessing or copying a scanner label. - Use CVSS correctly: as a standardized *input* you calculate from metrics, not as the final verdict — and record your rationale when you override it. - Recognize and avoid the two ratings that destroy report trust: severity **inflation** (everything is Critical) and **deflation** (real risk buried as Low). - Merge duplicates, deconflict overlapping issues, and write titles a busy engineer can triage in five seconds. - Separate what a scanner *reported* from what you *verified*, so every severity you assign is defensible.
Security objective: the asset you are protecting here is the client's remediation budget and attention. The threat is a misprioritized report — where a trivial issue is rated Critical and a business-ending one is rated Medium, so the client fixes the wrong thing first. A good severity model detects and prevents that misallocation. Your deliverable is not an exploit; it is a decision aid.
A penetration test produces findings. Each finding is a self-contained unit: the client can read it, understand it, triage it, and fix it without reading the rest of the report. The severity you attach to that finding is the single number that decides what gets fixed on Monday morning and what waits a quarter. That is a large responsibility, and it rests on clear writing plus a defensible rating method.
This lesson builds directly on The penetration test report: structure (your prereq). There you learned where findings sit inside the overall report — executive summary, methodology, scope, then the findings section. Here we zoom into a single finding: what fields it must contain, how to phrase them, and how to score severity so the ranking is honest. The next lesson, Evidence, reproduction, and remediation, goes deeper on proving the finding and writing the fix; this lesson focuses on the anatomy and the rating.
Severity is not a feeling. It is impact times likelihood, adjusted for the business context that a generic score cannot see. CVSS (the Common Vulnerability Scoring System) gives you a consistent starting number; your judgment turns that number into a priority. By the end you will be able to take a raw observation — 'the /account endpoint returns any user's record if you change the id' — and turn it into a finding that a developer can act on and a manager can prioritize.
In authorized professional work, the report is the product. The client did not pay for you to find bugs; they paid for a ranked, actionable list of what to fix and in what order. Severity is the ranking key.
Get it wrong in the inflation direction and the client learns to distrust you. If your report says nine Criticals and eight are cosmetic, the real one gets the same shrug as the rest, and next year they hire someone else. Get it wrong in the deflation direction and a genuinely dangerous flaw sits in a backlog labeled 'Low' until an attacker finds it first — now your report is evidence that the risk was known and mis-rated.
Credible severity also protects you. When a finding is disputed in a remediation call — and Criticals almost always are — a rating you can trace to concrete impact and likelihood, plus a CVSS vector string anyone can recompute, ends the argument. 'I felt it was Critical' does not. A written rationale converts a subjective call into a reviewable one.
Severity ratings feed real machinery downstream: SLA clocks ('Criticals patched within 7 days'), risk registers, compliance evidence, and sometimes contractual penalties. Teams schedule sprints around your labels. Treating the severity column as decoration wastes their time and yours; treating it as a careful, documented judgment is what separates a professional report from a scanner dump.
Definition. A finding is one security issue, written so it can be read, understood, and fixed in isolation.
Plain explanation. Imagine the developer assigned to fix it never reads the rest of the report. Everything they need — what is wrong, where, how to see it, how bad it is, how to fix it, how you will confirm the fix — lives inside that one finding.
How it works. You fill a fixed template (see below) for every issue. Consistency lets the client sort, filter, and assign findings mechanically.
When / when not. Every distinct root cause gets its own finding. Do not split one root cause across ten findings (e.g., the same missing auth check on ten endpoints is usually one finding with ten affected components), and do not merge two unrelated bugs because they look similar.
Pitfall. Writing the finding as a story of your testing session ('First I tried..., then I noticed...') instead of a stable description of the issue. The reader wants the conclusion, not the diary.
A professional finding has these fields:
| Field | What it answers |
|---|---|
| Title | What is wrong, and where, in one line |
| Severity | The final priority (with rationale) |
| Affected component | Exact endpoints, hosts, parameters, versions |
| Preconditions | What an attacker needs first (auth? network position? a valid account?) |
| Reproduction | Safe, minimal steps to observe it |
| Evidence | Redacted proof it is real (request/response, screenshot) |
| Impact | What an attacker gains — in business terms |
| Likelihood | How easy/probable exploitation is |
| Remediation | The concrete fix |
| Retest | How you will confirm the fix works |
Preconditions and retest are the fields beginners forget, and they are exactly the fields that make a finding professional. Preconditions stop severity inflation (a bug needing admin access is not the same as one needing nothing). Retest closes the loop — a finding is not truly done until you verify the remediation.
Definition. Severity is a two-axis judgment: how bad the outcome (impact) multiplied by how reachable the exploit (likelihood).
Plain explanation.
| Low impact | High impact | |
|---|---|---|
| High likelihood | Low / Medium | Critical |
| Low likelihood | Info / Low | Medium / High |
How it works. You place the finding on both axes, read the cell, then adjust for context. A trivially exploitable dump of PII is Critical. A hard-to-reach info leak is Low.
When / when not. Always rate both axes. Never rate on impact alone ('data breach = always Critical') — a data breach that requires domain-admin to trigger is not the top priority.
Pitfall. Ignoring preconditions. 'Reads any user's record' sounds Critical until you note it needs an authenticated session and the record IDs are unguessable UUIDs — likelihood drops, and so does severity.
Definition. CVSS is an open framework that turns metrics (attack vector, attack complexity, privileges required, user interaction, and impact to confidentiality/integrity/availability) into a 0.0-10.0 base score and a vector string.
Plain explanation. CVSS forces you to answer the same questions for every finding, so two testers score similar bugs similarly. The vector string (e.g., CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) is a receipt: anyone can recompute your score.
How it works. You select each metric, an official calculator produces the number, and you publish both the score and the vector.
When / when not. Use CVSS as your consistent starting point. Do not treat the base score as final — the base score deliberately knows nothing about your client. A CVSS 5.3 'Medium' on the system that processes every payment may be your #1 priority. When your final severity differs from the CVSS band, write the reason. This is the misconception to correct: a CVSS number is an input you adjust for business context, never the last word.
Pitfall. Copy-pasting a scanner's or an advisory's CVSS score without checking whether your environment matches the metrics. If the exploit needs local access but the advisory scored it as network-reachable, your score is wrong.
Definition. A reported issue is something a tool flagged; a verified issue is something you reproduced and confirmed.
Plain explanation. Scanners produce false positives. A finding you personally reproduced deserves a confident severity; a raw scanner hit deserves a caveat or a lower confidence until confirmed.
Pitfall / misconception. Passing a scanner does not prove security, and a scanner alert does not prove a vulnerability. Never paste an unverified scanner severity into your report as fact. Mark unconfirmed items clearly, or verify them before rating.
THREAT MODEL — the report itself as a decision system
ASSET: client's finite remediation capacity (engineer-hours, sprint slots)
ASSET: the client's real systems the findings describe
ENTRY POINTS (into the report's quality):
- raw scanner output (may contain false positives / borrowed CVSS)
- tester's manual observations (may be incomplete)
- copy-pasted severities from advisories (may not match this environment)
+----------------------------------------------------------+
| TRUST BOUNDARY: nothing enters the report as "fact" |
| until the tester VERIFIES it and RATES it with a |
| documented impact x likelihood + CVSS vector. |
+----------------------------------------------------------+
| |
v v
[ verified finding ] [ unverified / rejected ]
severity + rationale marked as such or dropped
|
v
CLIENT PRIORITIZATION -> fixes highest real risk first
FAILURE MODES:
- inflation -> capacity wasted on cosmetics, trust lost
- deflation -> real risk mislabeled Low, sits unfixed
Knowledge check
There is no programming syntax here; the 'syntax' of reporting is the CVSS vector string and the finding skeleton. Learn both shapes.
CVSS v3.1 base vector — annotated:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
| | | | | | | | +-- Availability impact: None
| | | | | | | +------ Integrity impact: None
| | | | | | +---------- Confidentiality: High
| | | | | +-------------- Scope: Unchanged
| | | | +------------------- User Interaction: None
| | | +------------------------ Privileges Req'd: None
| | +----------------------------- Attack Complexity: Low
| +---------------------------------- Attack Vector: Network
+------------------------------------------- CVSS version 3.1
This example vector computes to a base score of 7.5 (High): network-reachable, no privileges, no user interaction, and it leaks confidential data. Always publish the vector and the number so the score is reproducible.
Finding skeleton (Markdown) — the shape to fill for every issue:
### [Title: impact + location, one line]
**Severity:** High (CVSS 7.5 / AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)
**Affected component:** GET /api/v1/account?id=<n> (app.lab.local, build 2.3.1)
**Preconditions:** Authenticated session as any low-privilege user.
**Description / impact:** <what is wrong and what an attacker gains, in business terms>
**Reproduction (lab only):**
1. ...
2. ...
**Evidence:** <redacted request/response or screenshot reference>
**Remediation:** <the concrete fix>
**Retest:** <how you will confirm the fix rejects the attack and still serves legit use>
Keep it lab-safe: hostnames like app.lab.local, placeholder IDs, and redacted evidence.
Each finding is a self-contained unit that the client can triage and fix on its own. The quality of your findings defines the quality of the report.
IDOR (Insecure Direct Object Reference) means a user can access data they should not, simply by changing an identifier such as an ID in a URL.
Rate each finding on two axes:
A data exposure that is trivial to exploit is Critical. A low-impact issue that is hard to reach is Low.
Context matters. The same bug is more severe on a payment system than on a marketing blog.
The Common Vulnerability Scoring System (CVSS) produces a 0-10 score from standardized metrics:
CVSS aids consistency and comparability. But CVSS is an input, not the final word.
Always adjust for business context that the base score cannot know. A "medium" on a crown-jewel system may be your top priority. State the rationale behind your final severity.
Below is a complete worked example: turning one observation into a rated finding, in the INSECURE -> SECURE -> VERIFY shape. The 'code' here is the finding document plus the fix and the retest, because that is the deliverable this lesson produces.
# Logged in as user 1001. Request my own account — fine:
GET /api/v1/account?id=1001 HTTP/1.1
Host: app.lab.local
Cookie: session=<lab-session-for-user-1001>
HTTP/1.1 200 OK
{"id":1001,"email":"me@lab.local","plan":"free"}
# Change one digit — I now read a DIFFERENT user's record:
GET /api/v1/account?id=1002 HTTP/1.1
Host: app.lab.local
Cookie: session=<lab-session-for-user-1001>
HTTP/1.1 200 OK
{"id":1002,"email":"someone-else@lab.local","plan":"paid"}
This is an IDOR (Insecure Direct Object Reference): the server trusts the id in the URL and never checks it belongs to the caller.
### Authenticated user can read any account record by changing the id parameter (IDOR)
**Severity:** High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N = 6.5)
Rationale: base score is High for confidentiality loss of all customer
records. Requires only a low-privilege authenticated account (PR:L),
which any signed-up user has, and a single request (AC:L). Records include
billing plan + email (PII). Business context: this app stores payment
tier data, so we hold at High rather than reducing to Medium.
**Affected component:** GET /api/v1/account?id=<n> on app.lab.local (build 2.3.1)
**Preconditions:** Any authenticated low-privilege session. IDs are sequential integers, so enumeration is trivial.
**Impact:** An attacker with one free account can enumerate id=1..N and read every user's email and plan — a full customer-data disclosure.
**Likelihood:** High. Sequential IDs, single unauthenticated-of-object request, no rate limiting observed.
**Reproduction (lab only):** log in as user 1001, request id=1002, observe another user's record returned (evidence redacted).
# Pseudocode for the endpoint handler. The fix: never trust the client's id
# as an authorization decision. Serve only the caller's own record, or
# explicitly check ownership.
def get_account(request):
session_user = authenticate(request) # who is actually calling
requested_id = int(request.query["id"])
# Object-level authorization check (the missing control):
if requested_id != session_user.id and not session_user.is_admin:
log_authz_denied(session_user.id, requested_id, request) # detection
return http_403_forbidden()
record = db.get_account(requested_id)
return http_200(record)
Retest checklist (run in the lab after the fix is deployed):
[PASS required] Negative test — attack is now rejected:
logged in as 1001, GET /api/v1/account?id=1002 -> expect 403 Forbidden
AND an authz-denied entry in the security log.
[PASS required] Positive test — legitimate use still works:
logged in as 1001, GET /api/v1/account?id=1001 -> expect 200 with 1001's record.
[PASS required] Enumeration test — sweep a small id range as user 1001:
every id != 1001 returns 403; none leaks another record.
If all three pass, mark the finding "Remediated - retest passed (date)".
If the negative test still returns 200, the fix is incomplete; reopen.
Expected outcome. Before the fix, id=1002 returned another user's record (the vulnerability). After the fix, the same request returns 403 Forbidden and writes a security log line, while id=1001 still returns the caller's own record. That contrast — bad input rejected, good input accepted — is what closes the finding.
Walking the worked example from observation to closed finding:
id=1001). The tester requests their own record while logged in as 1001. A 200 with their own data is correct — this establishes normal behavior. Nothing wrong yet.id=1002). The tester changes one digit. If authorization were enforced, the server would compare 1002 against the session owner (1001), see a mismatch, and refuse. Instead it returns 1002's record. The value that changed is only the URL parameter — the session cookie is unchanged — which proves the server authorized on the wrong thing.C:H), no integrity or availability change (I:N/A:N).PR:L, not PR:N) but nothing more — one HTTP request (AC:L), reachable over the network (AV:N), no victim interaction (UI:N). IDs are sequential, so enumeration is trivial. High likelihood.AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N into the calculator yields 6.5 (Medium band edges to High). Note how PR:L (needs a login) pulled the score down from the 7.5 it would be if unauthenticated (PR:N). Preconditions are doing real work in the math.requested_id to the authenticated session_user.id; deny and log otherwise. The root cause (server trusts client-supplied id) is addressed once, not patched per-symptom.| Step | id sent | session | server response | meaning |
|---|---|---|---|---|
| Baseline | 1001 | user 1001 | 200 own record | normal |
| Probe (pre-fix) | 1002 | user 1001 | 200 other record | VULNERABLE |
| Probe (post-fix) | 1002 | user 1001 | 403 + log line | fixed, attack rejected |
| Self (post-fix) | 1001 | user 1001 | 200 own record | legit use preserved |
Mistake 1 — Everything is Critical (inflation).
Mistake 2 — Vague title.
Mistake 3 — Copying a scanner's or advisory's CVSS blindly.
Mistake 4 — Treating a scanner hit as a verified finding.
Mistake 5 — Ten findings that are one root cause (or one finding hiding ten root causes).
Mistake 6 — Omitting the retest field.
'Debugging' a finding means catching problems in the rating and writing before the client does.
Symptom: the client disputes your severity on the call.
Symptom: your Criticals list looks too long.
Symptom: a developer says 'I can't reproduce it.'
Symptom: your CVSS score 'feels wrong'.
PR:N (no privileges) when a login is actually required, or AV:N (network) when the attack needs local access.Symptom: two findings overlap.
Questions to ask when a finding feels off: What exactly does the attacker gain? What do they need first? Did I verify it or did a tool? Can someone recompute my score from the vector? Have I said how the fix will be confirmed?
Security & safety — detection, logging, and evidence hygiene for reporting.
A severity rating is only credible if the underlying activity is observable. Part of writing a good finding is telling the client what their logs should have captured — both to prove the issue and to detect real attackers later.
What a remediated control should log (and what your retest checks for):
account?id=1002).In the IDOR example, the fix's log_authz_denied(...) line is what turns a silent bypass into a detectable event. In your finding, note: 'before the fix, cross-user reads produced no distinguishable log entry — the attack was invisible.' That absence is itself part of the impact.
What to NEVER log (and never paste into a finding as evidence):
When you include a request/response as evidence, redact the session cookie and any real user data. Your proof should show the shape of the vulnerability, not leak the very data you are protecting. Store the report itself as sensitive: it is a map of the client's weaknesses.
Which events signal abuse (worth recommending as detections): a single account requesting many sequential object ids in a short window; a spike in 403s from one identity (probing); access to objects the account never legitimately touches.
How false positives arise: legitimate admin tools, batch jobs, or support staff may sweep many records lawfully; a shared service account may look like enumeration. Recommend the client tune detections by role and baseline normal behavior, so alerting on 'many object reads' does not drown real signals. Note this caveat in findings that recommend monitoring.
Authorized real-world use case. A SaaS company engages your firm for an authorized web-application penetration test, scope limited to their staging environment and a list of in-scope hosts, with a signed statement of work. You find the IDOR above (plus a dozen smaller issues). Your job is to hand back a findings section where severity ranking tells their engineering lead exactly what to fix before the next release. The IDOR, rated High with a documented rationale, jumps the queue; the informational TLS-config note waits. Weeks later you perform the retest and mark items 'Remediated - retest passed', which becomes their compliance evidence.
Authorization checklist (any lab or engagement):
Lab cleanup / reset (practice): revert the vulnerable VM/container to a clean snapshot, delete any test accounts you created, clear captured requests containing lab data, and stop the lab environment. Never carry lab exploit artifacts onto production networks.
Best-practice habits by level:
| Habit | Beginner | Advanced |
|---|---|---|
| Severity | Rate impact x likelihood; publish a CVSS vector | Add temporal/environmental context, defend overrides in remediation calls |
| Validation | Verify every finding manually before rating | Deconflict overlapping findings, merge by root cause across services |
| Least privilege | Test with the lowest role that triggers the bug | Map exactly which roles reach the issue; reflect in PR metric |
| Secure defaults | Recommend deny-by-default authorization | Recommend systemic control (central authz middleware) over per-endpoint patches |
| Logging | Note what the fixed control should log | Design detections and tune out false positives with the client |
| Error handling | Include a retest step per finding | Track remediation SLAs and produce retest evidence for audit |
Across all levels: never claim a system is 'completely secure' — a report says 'no further issues found within this scope and time window', nothing stronger.
All tasks are lab-only, on systems you own or are authorized to test (localhost, a container, an intentionally-vulnerable VM, or a CTF). Each ends by remediating and verifying, not by exploiting.
Beginner 1 — Write two honest titles.
shop.lab.local).Beginner 2 — Fill the finding skeleton.
Intermediate 1 — Score three findings with CVSS.
PR (does it need a login?) and AV (is it really network-reachable?).Intermediate 2 — Deconflict and de-inflate.
Challenge — Full finding with remediation verification.
Main concepts. A finding is a self-contained unit filled from a fixed template: title, severity, affected component, preconditions, reproduction, evidence, impact, likelihood, remediation, retest. The two fields beginners skip — preconditions and retest — are what make a finding professional. Severity = impact x likelihood, adjusted for business context. CVSS is a standardized input (metrics -> score + vector), never the final word; when you override the band, write the reason. Separate what a scanner reported from what you verified.
Key 'syntax'. The CVSS vector string (e.g. AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N) — always publish it alongside the number so anyone can recompute. The finding skeleton in Markdown, filled the same way every time.
Common mistakes. Severity inflation (everything Critical) and deflation; vague acronym-only titles; copying scanner/advisory CVSS blindly; treating a scanner hit as verified; ten findings for one root cause; omitting the retest.
What to remember. Severity decides what the client fixes first, so it must be defensible, not felt. Rate both axes, honor preconditions, publish the vector, document overrides, verify before you rate, redact evidence, and close every finding with a retest that shows bad input rejected and good input still accepted. Never call anything 'completely secure' — only 'no further issues found within this scope and time window'.
Next: Evidence, reproduction, and remediation goes deeper on proving the finding and writing the fix.