The previous module closed by pointing at a word that appeared in all six of its lessons without ever being developed: verify. The risk register states that port 5432 is exposed, the control catalogue promises a monthly external scan that nobody has run (C-10, "planned") and risk R-07 talks about "forgotten services" without anyone knowing how many there are today. This lesson turns those assertions into measurements: you are going to learn to find what is exposed, decide what gets fixed first and prove that it was fixed, with zero-cost tools that fit within Nimbus's budget.

⚠️ Legal and authorisation warning — read it before running anything

Everything in this lesson is run exclusively against your own systems or with written, explicit and current authorisation.

  • Scanning ports or launching a vulnerability scanner against someone else's machine without authorisation may constitute a criminal offence in Spain (arts. 197 bis and 264 of the Spanish Criminal Code). Curiosity is not a defence.
  • In the cloud you must also comply with the provider's testing policy, and if the system is operated by a third party — the consultancy of A-19, the e-mail provider, the payment gateway — the authorisation comes from whoever operates it, not just from whoever contracts it.

All the material works on Nimbus's own ranges (10.20.0.0/16 in the office, 10.30.0.0/16 in the cloud) and its domain nimbusreservas.example. There are no exploits and no payloads: every finding comes with its fix. (The specific criminal interpretation depends on the case; before any test with an impact on third parties, seek legal advice.)

Contents

  1. Vulnerability management as a continual process
  2. The tool map: what each family finds
  3. Network discovery with nmap
  4. Vulnerability scanner: Greenbone/OpenVAS
  5. Dependencies and containers: pip-audit and trivy
  6. Static code analysis and secret scanning
  7. Prioritisation: CVSS, EPSS, KEV and exposure
  8. False positives, false negatives and validation
  9. Integration into the cycle and process metrics
  10. The vulnerability report

  1. Vulnerability management as a continual process

Almost every SME that "does vulnerability management" is in fact doing a one-off scan: once a year somebody runs a tool, exports a 300-page PDF and files it away. That does not reduce risk: it produces a document. The difference is a closed loop with verification.

flowchart LR
    I["0. INVENTORY\nWhat I have (A-01..A-22).\nWithout this there is no coverage"] --> D["1. DISCOVER\nnmap, scanner, SCA,\nSAST, secrets, cloud"]
    D --> P["2. PRIORITISE\nCVSS + EPSS + KEV\n+ exposure + criticality"]
    P --> R["3. REMEDIATE\nPatch, configuration,\nmitigation or acceptance"]
    R --> V["4. VERIFY\nRescan and CLOSE\nwith evidence"]
    V --> N["5. REPORT\nMetrics and trend\n(KPIs from 04-03)"]
    N --> D
    V -.->|"if not fixed"| E["EXCEPTION with expiry\nto the risk register 04-01"]

Five rules separate the process from the theatre:

  • Step 0 is not optional. A scan of 12 machines when you have 18 finds nothing on the other 6: coverage is the first metric, not the last.
  • Several tools feed it, not one. No single family sees more than 30 % of the problem.
  • Prioritising is not sorting by CVSS. It is the part that adds the most value and the one almost nobody does well (§7).
  • A finding is closed when the rescan no longer finds it, not when somebody says they fixed it: that is the auditable evidence of 04-03. And whatever is not fixed becomes an exception with an expiry date (04-02), not a row that quietly ages.

A precision from 01-01: a version of OpenSSL with a CVE is a vulnerability; that service listening on the Internet is the exposure; the risk combines both with the criticality of the asset. A critical on a powered-off machine is not an emergency; a medium on the public API that reaches A-01 is.


  1. The tool map: what each family finds

Family What it finds / what it does not see Free Commercial Where
Network discovery Hosts, ports, services and versions · sees nothing inside the application nmap, masscan Runzero, Censys §3 and 05-04
Infrastructure scanner System and service CVEs · does not see business logic flaws or your own code Greenbone/OpenVAS, Nuclei Nessus, Qualys §4
Dynamic web scanner (DAST) Injections, XSS, headers · does not see code unreachable through the interface OWASP ZAP, Nikto, testssl.sh Burp Pro, Invicti 05-05 and 05-03
Software composition (SCA) CVEs in dependencies · does not see flaws in your own code pip-audit, grype, osv-scanner Snyk, Mend §5
Static analysis (SAST) Insecure patterns in your own code · does not see what depends on the environment semgrep, bandit, CodeQL Checkmarx, Fortify §6 and 05-05
Secret scanner · container scanner Keys in the git history · image CVEs and Dockerfile bad practices gitleaks · trivy, grype GitGuardian · Aqua §5-§6, 05-05 and 05-07
Cloud and IaC configuration Public buckets, excessive permissions, missing encryption · does not see the application prowler, ScoutSuite, checkov, tfsec Wiz, Orca 05-07
System configuration Deviation from a CIS baseline · does not see application CVEs lynis, OpenSCAP Tenable 05-06

The important column is "what it does not see". The IDOR that Nimbus fixed with WHERE tenant_id is found by none of these families: it is not an old version nor an obvious pattern, it is an authorisation flaw that requires understanding the business. That is why scanning is complemented with code review (05-05) and manual testing (05-03), and why no scanner can certify that a system is secure.


  1. Network discovery with nmap

nmap answers the question Nimbus cannot answer: what is listening out there and in here?

# 1) Which machines are alive on the Valencia user VLAN
sudo nmap -sn 10.20.10.0/24 -oA /var/log/nimbus-scan/office-hosts

# 2) Full VPC surface, run FROM the bastion host
sudo nmap -sS -sV --version-intensity 5 -p- --max-retries 2 -T4 --open \
     -oA /var/log/nimbus-scan/vpc-internal 10.30.10.0/24 10.30.20.0/24 10.30.90.0/24

# 3) The same infrastructure seen from the INTERNET
nmap -Pn -sV --top-ports 1000 --reason -oA /var/log/nimbus-scan/external \
     api.nimbusreservas.example preprod.nimbusreservas.example
Option What it does Why it matters here
-sn / -sS Discover hosts without scanning ports / SYN scan that does not complete the connection The first is compared against the inventory from 01-04; the second is fast and reliable, and requires privileges
-sV Interrogates the service to deduce product and version Turns "5432 open" into "PostgreSQL 14.7", which is what gets cross-referenced with CVEs
-p- / --top-ports 1000 All 65,535 ports / the most common ones The python -m http.server was listening on 8000, outside the top 1000; but an external -p- takes hours and is reserved for the monthly pass
-Pn Do not check whether the host is alive before scanning Mandatory from the Internet: almost everything blocks ICMP and without -Pn you would find nothing
-T4 Aggressive timing template (0-5) A good balance on your own network; T5 loses accuracy and T1-T2 are for not triggering detections
--reason / -oA Why each port is classified that way / saves in all three formats It distinguishes "filtered" from "closed"; and without the previous output there is no diff, which is 90 % of the value

Real output of the external scan:

Nmap scan report for preprod.nimbusreservas.example (203.0.113.47)
PORT     STATE    SERVICE    REASON   VERSION
22/tcp   open     ssh        syn-ack  OpenSSH 8.9p1 Ubuntu 3ubuntu0.1
443/tcp  open     ssl/http   syn-ack  nginx 1.18.0 (Ubuntu)
5432/tcp open     postgresql syn-ack  PostgreSQL DB 14.7 - 14.9
6379/tcp open     redis      syn-ack  Redis key-value store 6.0.16
8000/tcp open     http       syn-ack  SimpleHTTPServer 0.6 (Python 3.10.6)

Nmap scan report for api.nimbusreservas.example (203.0.113.20)
443/tcp  open     ssl/http   syn-ack  nginx 1.24.0
22/tcp   filtered ssh        no-response

The findings from 01-04, now measured and dated: 5432 reachable from the Internet confirms R-03 (only a password protects it); 6379 with Redis and no authentication is R-07; 8000 is the python -m http.server forgotten 94 days ago; and the contrast between 22/tcp open in preproduction and filtered in production proves that production does have a rule and preproduction does not. A-22 is today the weakest link at Nimbus.

Both points of view are needed and they measure different things. The external one (monthly, automated, control C-10) measures the surface an attacker on the Internet sees, that is, the exposure. The internal one (quarterly, from the bastion host and from the user VLAN) measures what is reachable by whoever is already inside or by a compromised laptop, and therefore reveals segmentation flaws and lateral movement: it is the one that would have discovered, before the incident, that the data zone was reachable from the administration network with no intermediate control (days 1-4 of 02-06).


  1. Vulnerability scanner: Greenbone/OpenVAS

nmap says what is there; the scanner says what is wrong with what is there. Greenbone Community Edition (formerly OpenVAS) is free and good enough for Nimbus. It only does two things, and it is best not to credit it with magic:

  1. Compare versions against a vulnerability database. It is fast and it is the source of almost every false positive: distributions patch without bumping the visible number (backporting).
  2. Active checks: it sends a request and observes the response — whether TLS 1.0 is accepted, whether a panel answers with default credentials. Slower and far more reliable.
# Define target and task, and launch it; all of it automatable from cron
gvm-cli socket --xml '<create_target><name>Production VPC</name>
  <hosts>10.30.10.0/24,10.30.20.0/24</hosts></create_target>'
gvm-cli socket --xml '<create_task><name>Monthly authenticated</name>
  <config id="daba56c8-73ec-11df-a475-002264764cea"/><target id="TARGET_ID"/></create_task>'
gvm-cli socket --xml '<start_task task_id="TASK_ID"/>'

The config id is the scan template (Full and fast balances coverage and time). A scanner that has to be launched by hand gets launched twice a year: either it is scheduled or it does not exist.

Authenticated versus unauthenticated

Unauthenticated Authenticated
How it works Probes from outside and infers Logs in over SSH and reads the system from the inside
What it sees Exposed services and their banners Every package, patch, configuration and user
Findings per Linux server 8-15 60-150
False positives Many (because of backporting) Few: it compares the real package version
Risk None The scanner's credential is a critical asset

It finds far more because it stops guessing: without credentials it cannot see a vulnerable library that is not listening on any port, and most system vulnerabilities are exactly that. For Nimbus: an svc-scanner account without sudo, a dedicated Ed25519 key (03-06), a restricted source address and six-monthly rotation.

== Greenbone report · Monthly authenticated scan · 2026-04-06 ==
6 hosts analysed · 1h42m · 3 High · 11 Medium · 24 Low · 63 Log

[10.0] 10.30.90.7   Redis without authentication reachable on the network
       ACTIVE check: INFO was executed without credentials            QoD 99%
       Solution: requirepass + bind 127.0.0.1 + security group
[9.8]  10.30.20.5   PostgreSQL reachable from 0.0.0.0/0
       ACTIVE check: connection established from an external source   QoD 98%
[7.5]  10.30.10.11  OpenSSL 3.0.2-0ubuntu1.9 - CVE-2026-1000
       Package VERSION check (authenticated scan)                     QoD 97%
[5.3]  10.30.10.11  nginx 1.18.0 - multiple CVEs
       VERSION check from the remote banner                           QoD 30%
       ^-- low confidence: probable distribution backport

Three keys to reading it: QoD (Quality of Detection) is the most useful field and the most ignored — 99 % with an active check is a fact, 30 % from a banner is a hypothesis, so you start with high QoD and not with high CVSS; the 63 "Log" entries are not noise, they are inventory for 01-04; and a report with no findings only means that the scanner found nothing it knows how to look for: the IDOR in A-04 will never show up here.


  1. Dependencies and containers

80 % of the code the API runs was not written by Iván: it is dependencies. It is the fastest-growing surface and the cheapest to measure.

pip-audit --requirement requirements.txt --format json --output /tmp/pip-audit.json --strict

--requirement audits the project's pinned dependencies (without it you audit the machine) and --strict also fails when something could not be analysed: treating an "I don't know" as an "it's fine" is the root of false negatives.

Found 3 known vulnerabilities in 2 packages
Name      Version  ID                   Fix Versions
jinja2    3.1.2    GHSA-h5c8-rqwp-cp95  3.1.3
requests  2.28.1   GHSA-j8r2-6x86-q33q  2.31.0
requests  2.28.1   GHSA-9wx4-h78v-vm56  2.32.0

The requests one is the relevant one: the API calls the payment gateway and the e-mail provider with that library. The jinja2 one only affects internal-panel templates. Same nominal severity, very different priority.

trivy image --severity HIGH,CRITICAL --ignore-unfixed \
            --scanners vuln,secret,misconfig --format table \
            registry.nimbus.example/api:2026.04.1

--severity HIGH,CRITICAL filters out the noise (a typical base image throws up 300 low findings, and including them guarantees that none of them get looked at); --ignore-unfixed hides whatever has no patch yet, and it is the most debatable option — Nimbus policy: use it in CI so the build is not blocked by something unresolvable, and do not use it in the monthly report; and --scanners vuln,secret,misconfig adds secrets in the layers and Dockerfile bad practices: three tools for the price of one.

registry.nimbus.example/api:2026.04.1 (debian 12.4)   Total: 14 (HIGH 12, CRITICAL 2)
┌───────────┬───────────────┬──────────┬───────────────┬──────────────┐
│ libssl3   │ CVE-2026-1000 │ CRITICAL │ 3.0.11-1      │ 3.0.13-1     │
│ zlib1g    │ CVE-2026-1001 │ HIGH     │ 1:1.2.13.dfsg │ 1:1.2.13-1+u1│
│ perl-base │ CVE-2026-1002 │ HIGH     │ 5.36.0-7      │ (not fixed)  │
└───────────┴───────────────┴──────────┴───────────────┴──────────────┘
Secret  api/.env.sample  AWS Access Key ID  (line 4)   <-- review

A vulnerability present ≠ a vulnerability exploitable. perl-base is in the image because the Debian base drags it in, but the API never runs Perl: it is present and it is not reachable. Two concepts formalise this:

  • Reachability: is there an execution path from Nimbus's code to the vulnerable function? Analysing it discards up to 70 % of the findings.
  • VEX (Vulnerability Exploitability eXchange): a signed document in which you declare, per CVE and product, whether it affects you (affected, not_affected, fixed, under_investigation) and why.

Nimbus issues its VEX alongside the SBOM from 04-04: when a clinic asks about a famous CVE, the answer will be a document and not an emergency phone call. The response to a non-exploitable finding is not to ignore it, it is to document it. And the most effective way to reduce their number is to move to a minimal or distroless base image (05-07), which wipes out 80 % of these lines in one go.


  1. Static code analysis and secret scanning

bandit -r app/ -ll -f json -o /tmp/bandit.json
semgrep --config=p/python --config=p/security-audit --sarif -o /tmp/semgrep.sarif app/

-ll limits it to medium severity or above; --sarif produces the standard format GitHub displays in its security tab. A real finding in Nimbus's code:

>> Issue: [B501:request_with_no_cert_validation] Requests call with verify=False
   Severity: High  Confidence: High  CWE-295  app/integrations/gateway.py:44
# BEFORE - the `verify=False` that turned up in module 2. It disables certificate
# validation: anyone on the path can get in the middle and read or alter the
# payment request. TLS without validation protects against nothing (03-05).
r = requests.post(GATEWAY_URL, json=payload, verify=False, timeout=10)

# AFTER - validation on and trust scoped explicitly.
r = requests.post(
    GATEWAY_URL,
    json=payload,
    verify="/etc/ssl/certs/ca-certificates.crt",  # explicit chain of trust
    timeout=(3.05, 10),                            # connect and read separately
)
r.raise_for_status()

SAST gets this right because verify=False is an unambiguous textual pattern, and it fails with the IDOR because knowing that a WHERE tenant_id is missing requires understanding the data model. Rule: it finds pattern errors, not reasoning errors. In 05-05 you will write your own semgrep rule that does detect Nimbus's specific pattern.

gitleaks detect --source . --log-opts="--all" --report-format json --report-path /tmp/gitleaks.json

--log-opts="--all" is the critical option: without it, only the current tree is inspected; with it, every commit and branch is walked. A secret deleted the next day is still in the history and is still valid.

Finding:  DATABASE_URL=postgresql://nimbus_api:Tr4mo...@10.30.20.5:5432/nimbus
RuleID:   postgres-connection-string
File:     deploy/docker-compose.override.yml
Commit:   a91f3c7 (2024-11-02) Author: ivan@nimbusreservas.example

This is risk R-06 and the escalation on day 5 of 02-06. Fix in this order: (1) rotate the credential today — it has been compromised since 2024 and deleting the file does not invalidate it; this is the urgent step and the one almost everyone postpones; (2) move it to the secrets manager (03-06); (3) rewrite the history with git filter-repo, which is cosmetic compared with step 1; and (4) gitleaks as a pre-commit hook and as a blocking CI step (control C-12).


  1. Prioritisation: CVSS, EPSS, KEV and exposure

This is where the process earns its keep. A Nimbus scan produces ~180 findings and Lucía has 440 h/year for everything. Sorting by descending CVSS is the industry's most widespread mistake.

CVSS group What it measures Who calculates it
Base Intrinsic, invariable severity The vendor or the NVD (9.8 for unauthenticated remote execution)
Temporal (Threat in v4) Whether there is a public exploit and whether there is a patch It is updated over time: 9.8 drops to 8.5 with no known exploit
Environmental Your environment: asset criticality and controls in place Only you: a 9.8 on an isolated machine drops to 4.0

The environmental group is the one that turns a generic list into your list, and the only one nobody can calculate for you. Two external signals are added to it:

  • EPSS (FIRST): the probability that a CVE will be exploited in the next 30 days, from 0 to 1, updated daily. More than 90 % of CVEs have an EPSS < 0.05; a minority concentrates almost all real-world exploitation.
  • KEV (CISA): a catalogue of vulnerabilities with confirmed real-world exploitation. It is not a prediction, it is an observed fact; the list is short and consulting it is free.

Nimbus's operating rule: anything in KEV and reachable is treated as critical, whatever its CVSS. A 7.5 in KEV is more urgent than a 9.8 with an EPSS of 0.001.

Priority Criterion Deadline
P0 Emergency In KEV and exposed to the Internet, or active compromise 24 h, outside the maintenance window if necessary
P1 Critical CVSS ≥ 9.0 or EPSS ≥ 0.10 on a reachable critical asset 7 days (matches C-16)
P2 High CVSS 7.0-8.9 on a critical asset, or KEV not exposed 30 days
P3 Medium CVSS 4.0-6.9, or high on a non-critical asset 90 days
P4 Low CVSS < 4.0 or not reachable (documented in VEX) Next cycle or exception with an expiry date

Deadlines run from discovery, not from the publication of the CVE. And if a P1 misses its deadline it is not relabelled as P2: a signed exception is opened (04-02). Downgrading the priority so the dashboard stays green is the most common way of corrupting the process.

"""Rank findings by combining severity, real exploitation and exposure."""

FINDINGS = [   # id, cvss, epss, kev, exposed, asset, criticality(1-5)
    ("CVE-2026-1000",  9.8, 0.72,  True,  True,  "A-04 API",        5),
    ("CVE-2026-1002",  8.1, 0.004, False, False, "A-04 (perl)",     5),
    ("REDIS-NO-AUTH", 10.0, 0.90,  True,  True,  "A-22 preprod",    2),
    ("CVE-2026-1010",  7.5, 0.31,  True,  False, "A-01 DB",         5),
    ("CVE-2026-1044",  9.1, 0.002, False, False, "A-14 laptops",    3),
]

def priority(cvss, epss, kev, exposed, criticality):
    base = cvss / 10.0                                   # 1) normalised severity
    exploitation = 1.0 if kev else min(1.0, epss * 3)    # 2) KEV is FACT; EPSS, nuance
    exposure = 3.0 if exposed else 1.0                   # 3) reachable triples urgency
    importance = criticality / 5.0                       # 4) inventory from 01-04
    return round(base * (0.3 + 0.7 * exploitation) * exposure * importance, 3)

rows = [(priority(c, e, k, x, cr), i, a) for i, c, e, k, x, a, cr in FINDINGS]
for p, ident, asset in sorted(rows, reverse=True):
    deadline = "24 h" if p >= 2.0 else "7 days" if p >= 1.0 else "30 days" if p >= 0.4 else "90 days"
    print(f"{p:>6}  {ident:<16} {asset:<18} -> {deadline}")
 2.940  CVE-2026-1000    A-04 API           -> 24 h
 2.250  CVE-2026-1010    A-01 DB            -> 24 h
 1.200  REDIS-NO-AUTH    A-22 preprod       -> 7 days
 0.257  CVE-2026-1002    A-04 (perl)        -> 90 days
 0.230  CVE-2026-1044    A-14 laptops       -> 90 days

What the result reveals: REDIS-NO-AUTH has the highest CVSS (10.0) and falls to third place because it lives on an asset with criticality 2; CVE-2026-1044 scores 9.1 — more than 1010, at 7.5 — and comes last because it is not in KEV, it is not reachable and it does not affect a critical asset. Sorting by CVSS would have put Lucía to work for three days on the two least urgent vulnerabilities. An honest warning: A-22 having criticality 2 is the documented classification, and the inventory admits it is "to be reviewed". If preproduction holds a copy of real data, its criticality is 5 and the Redis goes straight to first place: reviewing that cell pays off better than any scan.


  1. False positives, false negatives and validation

Definition Cost Example at Nimbus
False positive Reports something that does not exist or does not apply Time and loss of credibility for the process nginx 1.18.0 flagged despite Ubuntu's backport
False negative Does not report something that does exist Real risk left unmanaged The IDOR, which no scanner detected

Validating a finding, in order of cost: (1) checking the real package version (dpkg -l | grep nginx) resolves 60 % of infrastructure false positives; (2) reading the distribution advisory (Ubuntu's USN, Debian's DSA), which says whether that version is already fixed; (3) checking reachability; (4) reproducing it non-destructively only if the doubt persists — verifying that a port responds or that a header is missing, never exploiting in production: that conversation belongs to 05-03; and (5) documenting the decision, valid or not, because a false positive closed without being recorded comes back every month and costs the same every time.

A scanner never replaces judgement. It does not know that A-01 holds records that reveal health information, nor that A-19 has permanent access, nor that preproduction may hold real data, nor can it find an authorisation flaw. It measures versions and configurations: the risk is set by the context, and the context is set by a person. That goes into the report's statement of limitations (§10).


  1. Integration into the cycle and process metrics

Moment What runs Duration Blocking?
Every pull request gitleaks, semgrep, pip-audit < 3 min Yes: secrets and new criticals
Every deployment trivy on the final image < 2 min Yes, subject to threshold
Weekly ZAP baseline against preproduction (05-05) 20 min No: informational
Monthly (C-10) External nmap + authenticated Greenbone + prowler (05-07) 2-4 h No: findings with an owner
After infrastructure changes Differential external nmap 10 min No: alerts on a new port

The last one is the cheapest and the most underrated: comparing today's scan with yesterday's and flagging only what is new is what would have caught the http.server the same day, not 94 days later.

# .github/workflows/security.yml
name: Security analysis
on:
  pull_request:
  push: { branches: [main] }
  schedule: [{ cron: "0 5 * * 1" }]   # Mondays: catches new CVEs WITHOUT code changes
permissions: { contents: read, security-events: write }

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }      # FULL history: without this gitleaks cannot see the past

      - uses: gitleaks/gitleaks-action@v2   # BLOCKING: a secret is never negotiable

      - name: Python dependencies
        run: |
          pip install pip-audit
          pip-audit -r requirements.txt --strict --format json -o pip-audit.json || true
          # `|| true` lets OUR policy apply the threshold, not the binary
      - run: python ci/threshold.py pip-audit.json --fail-if CRITICAL,HIGH-KEV

      - run: docker build -t nimbus/api:${{ github.sha }} .
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: nimbus/api:${{ github.sha }}   # THIS commit's image, not :latest
          severity: CRITICAL,HIGH
          ignore-unfixed: true        # do not block on what has no patch yet
          exit-code: "1"              # 1 = broken build if anything is left above the threshold
          format: sarif
          output: trivy.sarif
      - if: always()                  # publish ALSO when the previous step fails
        uses: github/codeql-action/upload-sarif@v3
        with: { sarif_file: trivy.sarif }

The design principle: block on what is actionable and report on what is not. A pipeline that breaks the build over findings nobody can fix ends up disabled within two weeks, and then it protects nothing. And the failure criterion is decided by Nimbus (ci/threshold.py), not by a tool that changes version: outsourcing it means handing over the governance of risk.

Metric Type Target What it reveals when it fails
Scan coverage KPI 100 % Look at it first: the rest of the metrics lie if it is not 100 %. It is the one that produces the most surprises: it usually reveals that the uncovered 30 % was preproduction
MTTR by priority KRI P1 ≤ 7 d · P2 ≤ 30 d Insufficient capacity or a broken patching process
Criticals open past deadline KRI 0 This is the committee's number: if it grows, you must either invest or formally accept
Reopening rate and % closed with a rescan KPI < 5 % · 100 % Things are closed without verification: "closed" means "somebody said so"

  1. The vulnerability report

Audience What they need Length
Marta (management) Business risk, trend and what decision is being asked of her 1 page: traffic light, 3 figures, 1 request
Lucía (systems) What to touch, on which machine, with which command and by when Table sorted by priority
Iván (development) File, line, fix and regression test Issues in the repository, not a PDF
Customer or auditor (06-04) That a process, a cadence and evidence exist 1-2 pages with no exploitable detail

Structure of the monthly report: executive summary, scope and limitations, methodology and tool versions, prioritised findings, trend, closed and verified items, and a technical annex. The limitations are what honestly protects the reader: they say what was not scanned and what flaws these tools do not find.

### VUL-2026-014 · Production PostgreSQL reachable from the Internet

- **Priority:** P0 (KEV no · CVSS 9.8 · **exposed** · asset A-01, criticality 5)
- **Asset:** `db-prod-1` (10.30.20.5) · **Related risk:** R-03 (04-01)
- **Discovered:** 2026-04-06, monthly external scan (C-10) · **Deadline:** 2026-04-07

**Description.** The security group allows 5432/tcp from `0.0.0.0/0`. Any machine on
the Internet can attempt to authenticate; the only protection is the password of the
`nimbus_api` role, with no attempt limit.

**Evidence.**
    $ nmap -Pn -p 5432 -sV 203.0.113.47
    5432/tcp open postgresql PostgreSQL DB 14.7 - 14.9   (syn-ack)

**Business impact.** Access to the data of 40 clinics, including records that
indirectly reveal health information. Equivalent to day 5 of 02-06. A notifiable
breach under the GDPR (06-03).

**Fix.** Restrict the inbound rule to `10.30.10.0/24`. No maintenance window needed:
the API connects over the internal route. **Effort: 20 minutes.**
**Verification.** External rescan of 5432; expected state `filtered`, plus an API
smoke test. **Owner:** Lucia · **Status:** Open

The five elements that turn a finding into something actionable: reproducible evidence, business impact and not just technical impact, a concrete fix with an estimated effort, an explicit verification criterion and a named owner. The most frequent mistake is delivering the tool dump: 300 unprioritised pages that guarantee nothing gets fixed, because nobody knows where to start.


Common Mistakes and Tips

  • Scanning once a year and calling it vulnerability management. The inventory changes every week; better monthly, automated and reviewed.
  • Sorting by CVSS and starting at the top. Without exposure, EPSS/KEV and asset criticality you will spend your time on what matters least. It is the most expensive mistake in the lesson.
  • Scanning without credentials "so as not to disturb anything". You lose between 70 and 85 % of the system findings; the authenticated scan is the single most cost-effective improvement.
  • Forgetting preproduction. A-22 has more open ports today than production. The attacker does not tell environments apart: they tell doors apart.
  • Blocking CI with impossible thresholds, or closing findings without verification. The first ends with the pipeline switched off; the second turns "closed" into "somebody said so". Watch the reopening rate.
  • Tip: start with the diff. Before setting up Greenbone, automate a weekly external nmap that compares against the previous one and always keep the raw output with a date (-oA, JSON, SARIF): that is your historical series, and without a trend there is no conversation with management. Half an hour of work that would have avoided two of the four findings in 01-04.
  • Tip: if you only remember one rule, make it this one. A CVE in KEV and exposed is dealt with today.

Exercises

Exercise 1 — Interpret a scan and decide the order of work

Lucía runs the first monthly external scan and gets:

Nmap scan report for pruebas-2019.nimbusreservas.example (203.0.113.61)
80/tcp   open  http     Apache httpd 2.4.29 ((Ubuntu))
443/tcp  open  ssl/http Apache httpd 2.4.29
3306/tcp open  mysql    MySQL 5.7.33
  1. What has to be found out about pruebas-2019 even before looking at the versions?
  2. Order the actions for the first week, justifying the order with the criteria from §7.
  3. What is missing from this scan for the process to be complete?

Exercise 2 — Prioritise five findings

Rank them and assign a deadline according to the policy in §7, justifying each decision:

# Finding CVSS EPSS KEV Exposed Asset
A RCE in the internal panel's image library 9.8 0.02 No No A-04 (critical)
B Redis without authentication in preproduction 10.0 0.90 Yes Yes A-22 (to be reviewed)
C TLS 1.0 on a marketing subdomain 5.3 0.01 No Yes Static website
D PostgreSQL credential in the git history since 2024 A-10, A-01
E Local privilege escalation in the laptops' kernel 7.8 0.15 Yes No A-14

Exercise 3 — Fix a pipeline

Identify at least five problems in Iván's proposal and explain how each one is fixed.

name: security
on: { workflow_dispatch: {} }
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: gitleaks detect --source .
      - run: trivy image nimbus/api:latest --exit-code 1
      - run: pip-audit

Solutions

Exercise 1

(1) The first question is not technical, it is about inventory: what is pruebas-2019, who set it up, what data does it hold and is it still needed? That name suggests an environment from seven years ago that nobody maintains and that is neither in the inventory from 01-04 nor in the scope of any patching. If nobody knows, the fix is not to patch it: it is to switch it off once it is confirmed that nothing depends on it, keeping a backup. Switching off is the cheapest, fastest and most definitive fix there is, and the one least often considered.

(2) Order for the first week:

  1. MySQL 3306 exposed (P0, 24 h). It is the R-03 pattern but worse: a database reachable from the Internet on a host unmaintained since 2019, with probably weak credentials. It is closed at the firewall today, before investigating anything.
  2. Determine whether it holds real personal data (P0, in parallel). If it does and it has been exposed for years, it stops being a technical finding: it is a possible breach with GDPR obligations (06-03) and activation of the plan from 04-05.
  3. Switch off the host (P1, 7 days) once it is confirmed that nothing depends on it. Apache 2.4.29 has accumulated years of CVEs: patching it means investing in something that must disappear.
  4. Add it to the inventory and review the DNS, looking for more forgotten subdomains: the fact that it showed up in a scan and not in the inventory means coverage was not 100 %.

(3) What is missing: the scan only looks at the usual ports of names somebody remembered. Missing are subdomain enumeration from the DNS zone, a monthly -p- (the http.server on 8000 does not show up in --top-ports 1000), basic UDP scanning, internal scanning from the user VLAN and from the VPC to measure segmentation, and the layers nmap does not cover: authenticated scanner, SCA, SAST and cloud configuration. nmap is 15 % of the process.

Exercise 2

Rank # Priority Deadline Justification
1st D P0 24 h It has no CVSS because it is not a CVE, and it is the most serious of the lot: a valid credential for the critical database, exposed since 2024. Nothing has to be exploited, it is enough to use it. It is R-06 and day 5 of 02-06. Rotate today; cleaning the history can wait
2nd B P0 24 h KEV + EPSS 0.90 + exposed: all three factors maxed out. A-22's low nominal criticality does not rescue it, because it is "to be reviewed" and probably shares a network or data with production. Closing the port takes minutes
3rd E P2 30 days It is in KEV and the exploitation is real, but it is local escalation: it requires the attacker to be on the laptop already. It is the second link in the chain, not the first. It is grouped with the managed patching of the estate (05-06)
4th A P2 30 days 9.8 on a critical asset, but not exposed and EPSS 0.02. This is where sorting by CVSS fails: it looks like the first and it is the fourth. Reachability is worth confirming; if the panel does not process user images, it drops to P3 and is documented in VEX
5th C P3 90 days Exposed but low impact: a static website, with no data and no sessions. One line of nginx (03-05) at the next maintenance. Treating it as urgent "because it shows up red" is exactly the mistake the policy avoids

A cross-cutting observation: the first two require no patch, but a configuration change and a credential rotation, both free and a matter of minutes. The most urgent thing is almost never the most expensive.

Exercise 3

  1. workflow_dispatch as the only trigger: it only runs by hand, which is to say almost never. Missing are pull_request, push to main and a schedule that catches new CVEs without code changes.
  2. checkout without fetch-depth: 0: gitleaks only sees the last commit, so history secrets — Nimbus's real case — go unnoticed indefinitely.
  3. trivy image nimbus/api:latest: it scans a tag that is not the image this change builds. One image is validated and a different one is deployed; you must build and scan by commit SHA.
  4. --exit-code 1 without --severity: it blocks on any finding, including informational ones and those with no patch. It will be switched off within two weeks.
  5. pip-audit without -r requirements.txt: it audits the runner's environment, not the project; it can pass green with vulnerable dependencies.
  6. No result is published and there are no explicit permissions: without SARIF or artefacts there is no history, no trend and no evidence for 04-03, and the token operates with more permissions than it needs, against the least privilege of 01-03.

The corrected version is the one in section 9, with fetch-depth: 0, the three triggers, scoped permissions, an in-house threshold in ci/threshold.py, an image tagged by SHA and SARIF publication with if: always().


Conclusion

You have turned module 4's "verify" into a measurable process. You know that vulnerability management is a cycle — inventory, discover, prioritise, remediate, verify and report — and not an annual PDF, and that step 0 rules: without an inventory there is no coverage, and without coverage the other metrics lie. You know the map of the nine tool families and, above all, what none of them sees: Nimbus's IDOR does not show up in any scanner, because these tools detect pattern errors and not reasoning errors.

You can handle nmap option by option and you know why the external and the internal scan measure different things; with it you have rediscovered, with a date and evidence, the four findings from 01-04. You know what a vulnerability scanner really does, why the authenticated scan finds five to ten times more, and how to read a report starting with the QoD. With pip-audit and trivy you can distinguish a vulnerability present from a vulnerability exploitable, with reachability and the VEX document as the right way of saying "this does not affect us" without losing traceability. With semgrep, bandit and gitleaks you have closed the verify=False and found the PostgreSQL credential in the history, whose fix starts by rotating it and not by deleting the file. And you take away the part that separates a useful process from a useless spreadsheet: prioritising with base/temporal/environmental CVSS, EPSS as a probability and KEV as an observed fact, the P0-P4 deadline policy and a calculation that reorders the list until the 9.1 CVE ends up last; validating false positives in five steps; integrating it all into a pipeline that blocks on what is actionable and reports on the rest; and writing an actionable finding with evidence, business impact, an estimated fix, verification and an owner.

But notice the limit of everything above: this process finds badly closed doors; it does not see anyone walking through them. If tomorrow an attacker uses the consultancy's credential at 22:14, no scanner in this lesson will notice: the port is legitimately open and the credential is valid. That is the gap that left twenty days of silence in the incident of 02-06 and that the catalogue in 04-03 reflects with eight detective controls of which only two are implemented. In Monitoring and Detection Techniques (05-02) we finally build those controls: what to log, where to centralise it, how to write detections that fire on what matters and stay silent on what does not, and how to cut the attacker's dwell time from twenty days to one morning.

Fundamentals of Information Security Course

Module 1: Introduction to Information Security

Module 2: Cybersecurity

Module 3: Cryptography

Module 4: Risk Management and Protection Measures

Module 5: Security Tools and Techniques

Module 6: Best Practices and Regulations

Module 7: Final Project

© Copyright 2026. All rights reserved