Vulnerability Management Automation: Scan, Prioritize, Patch, Verify
I built a closed-loop vulnerability management cycle that schedules OpenVAS scans, prioritizes findings by CVSS 3.1 and business context, opens Jira tickets via API, and remediates with Ansible. The pipeline cut the critical-vulnerability exposure window from 7 days to 4 hours across 15+ Linux nodes.
The problem with the old process was not detection, it was latency and handoffs. A weekly manual scan produced a CSV that sat in someone's inbox, prioritization was a judgement call per analyst, and patching waited for the next maintenance window. By the time a critical CVE was patched, it had often been exposed for a week. This lab rebuilds that pipeline end to end so that every stage, scan, analyze, ticket, patch, verify, is automated, timestamped, and auditable.
The loop is deliberately boring: no exotic tooling, just GVM's GMP protocol for scanner control, the Jira REST API for ticketing, Ansible for idempotent remediation, and PostgreSQL/Grafana for metrics. Each stage emits a machine-readable artifact (report UUID, CSV, ticket key, playbook recap) that the next stage consumes, so a failed stage halts the pipeline instead of silently dropping findings.
Context & Standards Alignment
The workflow implements the NIST SP 800-40 patch management lifecycle and CIS Control 7 (Continuous Vulnerability Management), turning ad-hoc scanning into a measurable, auditable process.
Concretely, the mapping looks like this: NIST SP 800-40r4's "Identify and Prioritize" phase is covered by the GVM scan plus CVSS 3.1 scoring enriched with asset criticality tags; "Remediate and Mitigate" is the Ansible stage; "Verify and Measure" is the delta rescan and Grafana trend. For CIS Control 7, the pipeline satisfies 7.1 (establish a vulnerability management process), 7.3 (perform automated operating system patch management), 7.4 (automated application patch management), and 7.5/7.6 (authenticated internal scans on a schedule). Because every stage writes to the results store, an auditor can reconstruct exactly when a CVE was detected, ticketed, patched, and verified closed.
On the threat-model side, the prioritization logic weights two MITRE ATT&CK techniques heavily, because they describe how the lab's simulated adversaries actually get initial access:
- T1190 Exploit Public-Facing Application, prioritized remediation of internet-reachable CVEs.
- T1203 Exploitation for Client Execution, endpoint patch orchestration via Ansible.
A finding on an internet-facing host tagged zone=dmz gets its effective priority bumped one band (HIGH → CRITICAL) because T1190-style exploitation requires no user interaction, whereas a client-side CVE (T1203) behind patched browsers and email filtering can wait for the standard window. That business-context adjustment is the difference between a raw scanner dump and a risk-ranked work queue.
OpenVAS/GVM] --> A[Analyze
CVSS 3.1] A --> P[Prioritize
business risk] P --> T[Ticket
Jira API] T --> R[Remediate
Ansible] R --> V[Verify
rescan] V --> S
Learning Objectives
- Sync Greenbone feeds and verify scanner health via GMP.
- Create scan credentials and credentialed targets.
- Launch authenticated scans from the GVM CLI.
- Export results and prioritize by CVSS 3.1 severity.
- Open Jira tickets from findings via the REST API.
- Remediate with Ansible and verify with post-patch checks.
- Schedule the full cycle with a systemd timer.
Environment & Prerequisites
Prerequisites
- OpenVAS/GVM 22.4 scanner with daily feed sync; Nessus Professional for cross-validation.
- Ansible control node with inventory of 15+ RHEL 9 / Ubuntu 22.04 hosts.
- Jira Cloud project + API token; Grafana dashboard fed from a PostgreSQL results store.
- OpenVAS/GVM 22.4 scanner with daily feed sync; Nessus Professional for cross-validation.
- Ansible control node with an inventory of lab hosts.
- Jira Cloud project plus an API token.
- Grafana with a PostgreSQL results store for dashboards.
- Scan credentials with local admin on targets.
A few details that matter before you start. The GVM scanner runs as a dedicated VM (4 vCPU / 16 GB RAM is a workable minimum, the NVT cache rebuild after a feed sync will happily eat all of it). The scanner, control node, and targets live on an isolated lab VLAN 192.168.20.0/24 so credentialed scanning never touches production. On the Ansible side, every managed host has a dedicated, non-root patchbot account with passwordless sudo scoped to package-manager and reboot commands only, this keeps the remediation blast radius small if the control node is ever compromised.
The Ansible inventory carries the asset-criticality metadata that step 4's prioritization joins against. Keeping zone and data-classification tags in the inventory (rather than a side spreadsheet) means the pipeline and the humans read from the same source of truth:
INI, INVENTORY WITH ASSET-CRITICALITY TAGS
[dmz]
192.168.20.31 zone=dmz role=web data=public
[internal]
192.168.20.18 zone=internal role=app data=restricted
192.168.20.22 zone=internal role=db data=restricted
[critical_hosts:children]
# Written fresh each cycle by export-and-ticket.sh from the findings store,
# so the remediation group always matches the current scan results:
# printf '[critical_hosts]\n%s\n' "${HOSTS[@]}" > inventory.d/critical_hosts.ini
[critical_hosts]
192.168.20.31 # CVE-2024-3094, xz-utils
192.168.20.18 # CVE-2023-4911, glibc
The Jira project uses a dedicated issue type (Vulnerability) with custom fields for CVE ID, CVSS score, affected host, and due date, so the API-created tickets can be queried and SLA-tracked later. Grafana reads from a PostgreSQL table that the pipeline appends to after each stage, one row per finding per cycle, which is what makes the "exposure window" metric computable at all.
The same scanning stage also plugs into CI, so images with critical findings never reach deployment in the first place:
Step-by-Step Execution
1. Sync the Greenbone feeds and verify scanner health [PRIVILEGED]
Everything downstream depends on a current NVT (Network Vulnerability Test) feed. If the feed is stale, the scanner is running last week's signatures and will miss anything disclosed since. GVM splits its data into three feeds, NVTs, SCAP data (CVE/CPE), and CERT advisories, and each has its own sync command. Run them as the service user (_gvm on Debian packages, gvm on source builds), because the feed files are permission-locked to that account; running sync as root once is a classic way to break future automated syncs with ownership errors.
BASH, FEED SYNC AS THE SERVICE USER
Expected output:
sudo -u _gvm greenbone-feed-sync --type nvt
sudo -u _gvm greenbone-feed-sync --type scap
sudo -u _gvm greenbone-feed-sync --type cert
sudo -u _gvm greenbone-feed-sync --type gvmd-data
After the sync, verify gvmd is up and answering GMP over its Unix socket before touching anything else. A quick <get_version/> request confirms both the daemon and the socket path:
gvm-cli --gmp-username admin socket --xml "<get_version/>"EXPECTED OUTPUT
<get_version_response status="200" status_text="OK">
<version>22.4</version>
</get_version_response>
Common mistake: automating this with cron as root. The sync succeeds but re-owns feed directories to root, and the next scan fails with cryptic "no NVTs available" errors. Verify with ls -l /var/lib/openvas/plugins/, everything should be owned by _gvm:_gvm.
What this teaches: a scanner with stale feeds reports false negatives; feed health comes before any scan.
2. Create scan credentials and a credentialed target
Unauthenticated scanning only sees what an attacker across the network sees: open ports, banners, and a handful of remotely-probeable flaws. An authenticated scan logs in over SSH and queries the local package database (rpm/dpkg), which is where the vast majority of patch-level CVEs are found, in my lab, going credentialed roughly multiplied per-host findings by ten. Create a dedicated, low-privilege scan account on every target first (the Ansible inventory already has patchbot, and the scanner gets its own scanuser), then register the credential and target with gvmd.
XML, CREATE AN SSH CREDENTIAL VIA GMP
Expected output:
<create_credential>
<name>lab-ssh-scanuser</name>
<type>up</type>
<login>scanuser</login>
<password>STRONG-RANDOM-PW</password>
</create_credential>
Then define the target, attaching the credential UUID returned by the previous call. Port list 33d0cd82-57c6-11e1-8ed1-406186ea4fc5 is the built-in "All IANA assigned TCP" list, slow but thorough, which is fine for a nightly job:
XML, CREATE A TARGET WITH THE CREDENTIAL ATTACHED
<create_target>
<name>lab-linux-vlan20</name>
<hosts>192.168.20.0/24</hosts>
<port_list id="33d0cd82-57c6-11e1-8ed1-406186ea4fc5"/>
<ssh_credential id="CREDENTIAL-UUID">
<port>22</port>
</ssh_credential>
</create_target>
Verify the credential actually works before scheduling anything, a target with a bad password scans "successfully" and returns almost nothing, which is worse than failing loudly. The <get_targets/> response should show your credential UUID nested under the target, and the first test scan's report should contain hundreds of "package version" style results, not just port banners.
How to verify: grep the first report for 1.3.6.1.4.1.25623.1.0 OIDs (local security checks). If you only see remote/banner checks, the login failed, check /var/log/secure on a target for the scanner's SSH attempts.
What this teaches: credentialed scans see installed software and missing patches that network scans miss.
3. Launch an authenticated scan via the GVM CLI [PRIVILEGED]
With the target registered, create a task that binds target + scan config ("Full and fast", ID daba56c8-73ec-11df-a475-002264764cea), then start it. The whole run is driven over GMP so it can live in a nightly systemd timer rather than a GUI session:
gvm-cli --gmp-username admin socket --xml "<start_task task_id='UUID'/>"EXPECTED OUTPUT, SCAN ACCEPTED, REPORT ID RETURNED
Expected output:
<start_task_response status="202" status_text="OK, request submitted">
<report_id>b8f1c2a4-9e31-4c6d-8a52-1f7d3e90ab12</report_id>
</start_task_response>
The returned report UUID is the artifact the next stage consumes, so the wrapper script captures it immediately and polls task status until it flips to Done:
BASH, POLL UNTIL THE SCAN COMPLETES
while true; do
STATUS=$(gvm-cli --gmp-username admin socket --xml \
"<get_tasks task_id='$TASK_ID'/>" | grep -oPm1 '(?<=<status>)[^<]+')
[ "$STATUS" = "Done" ] && break
[ "$STATUS" = "Stopped" -o "$STATUS" = "Interrupted" ] && exit 1
sleep 120
done
echo "scan complete, report=$REPORT_ID"
Common mistake: treating Interrupted as "done" and exporting a partial report. The loop above exits non-zero on anything that is not Done, which stops the pipeline instead of ticketing half a scan's worth of findings.
What this teaches: the GMP API makes scanning scriptable, which is the prerequisite for automation.
4. Export and prioritize by CVSS 3.1
Once the scan completes, pull the results and filter by severity. CVSS 3.1 base score is the first sort key, 9.0+ is CRITICAL, 7.0-8.9 HIGH, but the pipeline also joins each finding against an asset-criticality table (zone, role, data classification) so an internet-facing HIGH can outrank an internal CRITICAL. The export below grabs everything at 7.0+ (HIGH and above) for the remediation queue; the critical_hosts inventory group from the prerequisites section is regenerated from this same store each cycle:
gvm-cli --gmp-username admin socket --xml "<get_reports report_id='$REPORT_ID' format_id='c402cc3e-b531-11e1-9163-406186ea4fc5'/>" > report.xml
python3 - <<'EOF' | tee critical.csv
import xml.etree.ElementTree as ET
tree = ET.parse("report.xml")
for r in tree.iter("result"):
nvt = r.find("nvt")
try: score = float(nvt.findtext("cvss_base") or 0)
except ValueError: continue
if score >= 7.0:
cve = (nvt.findtext("cve") or next((ref.get("id") for ref in nvt.iter("ref") if ref.get("type") == "cve"), None) or "n/a")
print(f"{cve} {score:<4} {nvt.findtext('name')} host={r.findtext('host')}")
EOFExpected output:
CVE-2024-3094 10.0 xz-utils backdoor host=192.168.20.31
CVE-2023-4911 7.8 glibc Looney Tunables host=192.168.20.18
The full result set (all severities) is exported the same way without the filter and appended to the PostgreSQL results store with the report UUID, scan timestamp, and ticket state. That append-only history is what lets Grafana compute "days open per CVE" later, you cannot measure an exposure window if you overwrite your findings each cycle.
PYTHON, EFFECTIVE PRIORITY = CVSS BAND + ASSET CONTEXT
def effective_priority(cvss, asset):
band = "CRITICAL" if cvss >= 9.0 else "HIGH" if cvss >= 7.0 else \
"MEDIUM" if cvss >= 4.0 else "LOW"
if asset["zone"] == "dmz" and band == "HIGH":
return "CRITICAL" # internet-facing: bump one band
if asset["data"] == "restricted" and band == "MEDIUM":
return "HIGH"
return band
How to verify: cross-check a sample of the CSV against the GVM web UI's report view for the same report UUID. Counts should match exactly; if the CSV is short, the export filter string is wrong (severity filters are on the result, not the NVT).
What this teaches: CVSS plus business criticality turns a vulnerability list into a work queue.
5. Open Jira tickets via the REST API
Every HIGH/CRITICAL finding becomes a Jira ticket automatically, this is what killed the "CSV in an inbox" failure mode. The script reads critical.csv, deduplicates on (cve, host) against tickets already open (a JQL query first, so re-scans do not spawn duplicates), then POSTs one issue per new finding with the CVSS score, affected host, and a due date computed from the severity SLA (CRITICAL: 24 h, HIGH: 7 days).
PYTHON, CREATE A JIRA ISSUE PER FINDING
Expected output:
import requests
from requests.auth import HTTPBasicAuth
auth = HTTPBasicAuth("[email protected]", JIRA_API_TOKEN)
payload = {
"fields": {
"project": {"key": "VULN"},
"issuetype": {"name": "Vulnerability"},
"summary": f"{cve} on {host} (CVSS {score})",
"description": { # Jira Cloud API v3 requires Atlassian Document Format
"type": "doc", "version": 1,
"content": [{"type": "paragraph", "content": [
{"type": "text", "text": f"Scanner report {report_id}"},
{"type": "hardBreak"},
{"type": "text", "text": nvt_name},
{"type": "hardBreak"},
{"type": "text", "text": solution}]}]},
# Custom field IDs and the "Vulnerability" issue type are
# instance-specific: look yours up via GET /rest/api/3/field and the
# project's create metadata before running this.
"customfield_10040": cve, # CVE ID
"customfield_10041": score, # CVSS 3.1
"customfield_10042": host, # affected host
"duedate": sla_due_date(band),
}
}
r = requests.post("https://tyfsadik.atlassian.net/rest/api/3/issue",
json=payload, auth=auth, timeout=30)
r.raise_for_status()
EXPECTED OUTPUT
{"id": "10042", "key": "VULN-128",
"self": "https://tyfsadik.atlassian.net/rest/api/3/issue/10042"}
The returned issue key is written back into the results store next to the finding, which is the linkage that lets the verification stage auto-close tickets later. Note the raise_for_status(), a 401 (bad token) or 400 (missing required custom field) should abort the run loudly, not get logged and forgotten.
Common mistake: skipping the dedup JQL query. Without it, every nightly rescan re-tickets every unpatched finding and the backlog becomes noise within a week. Query for project = VULN AND "CVE ID" ~ "CVE-2024-3094" AND "Host" ~ "192.168.20.31" AND status != Done before creating.
What this teaches: tickets with owners and SLAs are what actually get patches applied.
6. Remediate with Ansible
The remediation playbook is deliberately narrow: it only touches hosts in the critical_hosts group (populated dynamically from the results store), only updates the packages named in the findings, and runs one host at a time (serial: 1) so a bad patch can never take down the whole fleet in one pass. A pre_tasks snapshot/backup task runs first on anything tagged stateful.
YAML, PATCH-CRITICAL.YML (ABRIDGED)
Expected output:
- hosts: critical_hosts
become: true
serial: 1
vars:
# Package names differ by family: xz-utils/libc6 on Debian,
# xz/glibc on RHEL. One list per family, selected by when-clause.
patch_packages_redhat: [xz, glibc]
patch_packages_debian: [xz-utils, libc6]
pre_tasks:
- name: Check for pending reboot (Debian)
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required_file
when: ansible_os_family == "Debian"
- name: Check for pending reboot (RHEL)
ansible.builtin.command: needs-restarting -r
register: rh_reboot
failed_when: false
changed_when: false
when: ansible_os_family == "RedHat"
- name: Set reboot_required_fact
ansible.builtin.set_fact:
reboot_required_fact: >-
{{ (ansible_os_family == "Debian" and reboot_required_file.stat.exists)
or (ansible_os_family == "RedHat" and rh_reboot.rc == 1) }}
tasks:
- name: Update affected packages (RHEL)
ansible.builtin.dnf:
name: "{{ patch_packages_redhat }}"
state: latest
when: ansible_os_family == "RedHat"
- name: Update affected packages (Debian)
ansible.builtin.apt:
name: "{{ patch_packages_debian }}"
state: latest
update_cache: true
when: ansible_os_family == "Debian"
- name: Reboot only if the kernel or glibc changed
ansible.builtin.reboot:
reboot_timeout: 600
when: reboot_required_fact | bool
ansible-playbook -i inventory patch-critical.yml --limit critical_hostsPLAY RECAP
192.168.20.31 : ok=6 changed=3 unreachable=0 failed=0
192.168.20.18 : ok=6 changed=2 unreachable=0 failed=0
Read the recap like a contract: unreachable=0 failed=0 on every host is the pass condition. A non-zero failed count halts the pipeline and the offending host's ticket stays open with the Ansible error attached, partial remediation is reported, never swept under a green dashboard.
How to verify: after the run, spot-check package versions directly, using the right query tool per family: ansible critical_hosts -m shell -a "rpm -q xz glibc || dpkg-query -W xz-utils libc6" and compare against the fixed versions in the CVE advisories. Trust the recap, but verify the bytes.
What this teaches: Ansible makes patching repeatable across the fleet instead of a per-host chore.
7. Post-patch health checks before calling it fixed
A package that installs cleanly can still break the service that depends on it, glibc updates are the classic offender. Before the verification rescan, each patched host runs a short health-check play: systemd services back to active, the app endpoint answering HTTP 200, and disk/memory within normal bounds. If any check fails, the host is rolled out of the patch group and its ticket escalated instead of closed.
YAML, HEALTH CHECK TASKS
Expected output:
- name: Confirm critical services are running
ansible.builtin.systemd:
name: "{{ item }}"
state: started
loop: "{{ critical_services }}"
register: svc
- name: App endpoint answers
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/healthz"
status_code: 200
Common mistake: rebooting the whole critical_hosts group unconditionally "just in case". Reboots are gated behind the reboot_required fact (set by checking needs-restarting on RHEL or /var/run/reboot-required on Ubuntu) so hosts that do not need one stay up.
What this teaches: post-patch verification closes the loop; applied does not mean effective until re-scanned.
8. Schedule the whole cycle with a systemd timer
The final piece is making the loop run without me. A single wrapper script chains the stages, scan, poll, export, ticket, patch, rescan, and each stage exits non-zero on failure so set -euo pipefail stops the run before bad data propagates. The wrapper runs from a systemd timer rather than cron because timers give me persistent logs (journalctl -u vuln-cycle), missed-run catch-up (Persistent=true), and a clean way to see when the next cycle fires.
INI, VULN-CYCLE.TIMER (NIGHTLY AT 02:30)
Expected output:
[Unit]
Description=Nightly vulnerability management cycle
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
INI, VULN-CYCLE.SERVICE (THE UNIT THE TIMER TRIGGERS)
Expected output:
[Unit]
Description=Nightly vulnerability management cycle
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=vulnbot
ExecStart=/usr/local/sbin/vuln-cycle.sh
[Install]
WantedBy=multi-user.target
BASH, VULN-CYCLE.SH (WRAPPER, ABRIDGED)
#!/usr/bin/env bash
set -euo pipefail
./sync-feeds.sh # step 1
./run-scan.sh # steps 2-3, writes report UUID to state file
./export-and-ticket.sh # steps 4-5
./remediate.sh # steps 6-7
./rescan-and-close.sh # validation stage
echo "$(date -Is) cycle complete" >> /var/log/vuln-cycle/cycle.log
EXPECTED OUTPUT, TIMER ARMED AND LAST RUN CLEAN
$ systemctl list-timers vuln-cycle.timer
NEXT LEFT LAST PASSED UNIT
Tue 2026-06-17 02:30:00 EDT 9h left Mon 2026-06-16 02:30:01 EDT 9h ago vuln-cycle.timer
$ systemctl status vuln-cycle.service | grep -E "Active|code="
Active: inactive (dead) since Mon 2026-06-16 03:41:22 EDT; 9h ago
Main PID: 40213 (code=exited, status=0/SUCCESS)
How to verify: a nightly job you never look at will silently rot. The timer's status is itself scraped into the metrics store, and Grafana alerts if code=exited, status= is anything but 0 or if the LAST column ages past 26 hours, the pipeline pages me when the pipeline breaks.
What this teaches: the timer turns the whole cycle into continuous vulnerability management.
How It Works
Vulnerability management is a cycle, not an event: scan, prioritize, remediate, verify, repeat. The scanner (GVM/OpenVAS) discovers hosts, fingerprints services, and matches them against NVT feed data to produce findings with CVSS scores. Credentialed scans log into targets to enumerate installed packages, which catches missing patches that remote probes cannot see.
Prioritization combines CVSS 3.1 severity with business context: a critical CVE on an internet-facing system outranks the same CVE on an isolated lab host. The prioritized queue becomes Jira tickets with owners and SLAs, and Ansible playbooks apply the patches across the inventory idempotently.
Verification is what separates management from scanning. Post-patch health checks plus a re-scan confirm the vulnerability is actually gone; the systemd timer ensures the cycle repeats on schedule. Grafana dashboards over the PostgreSQL results store track the metrics that matter: mean time to patch and critical exposure window.
Validation & Testing
Trigger an automated rescan of the patched hosts and confirm the previously critical CVEs no longer appear. The Grafana dashboard should show the open-critical count drop to zero (alt text: Grafana panel trending open critical CVEs to zero after Ansible remediation).
The verification scan is a separate, smaller task that targets only the hosts that were patched, using the identical scan config as the original, changing the config between scan and rescan would invalidate the comparison. The key artifact is the GVM delta report, which diffs the original report against the rescan and states, per result, whether it is gone, new, or changed:
XML, REQUEST A DELTA REPORT AGAINST THE ORIGINAL SCAN
<get_reports report_id="RESCAN-REPORT-UUID"
delta_report_id="ORIGINAL-REPORT-UUID"
format_id="c402cc3e-b531-11e1-9163-406186ea4fc5"/>
Version note: delta-report attribute support varies by GMP build. If your version rejects delta_report_id, export both reports with the same format and diff the result sets in the PostgreSQL findings store instead, the same gone/new/changed accounting applies.
EXPECTED OUTPUT, DELTA SHOWS THE CVEs AS GONE
CVE-2024-3094 10.0 xz-utils backdoor host=192.168.20.31 state=gone
CVE-2023-4911 7.8 glibc Looney Tunables host=192.168.20.18 state=gone
delta: 2 gone, 0 new, 0 changed
Only when every original CRITICAL finding shows state=gone does the pipeline transition the matching Jira tickets to Done (again via the REST API, using the issue keys stored in step 5) and stamp the closure timestamp into PostgreSQL. The exposure-window metric Grafana graphs is literally closed_at - detected_at per finding, so this stamp is what proves the 7-days-to-4-hours improvement with data rather than anecdote. Finally, once a month I cross-validate with a Nessus Professional scan of the same VLAN: the two scanners never agree perfectly, but any CRITICAL that Nessus sees and GVM missed gets its NVT investigated and the feed/sync process re-checked.
How to verify: if a finding still appears after patching, check whether the fixed package actually supersedes the vulnerable one (rpm -q --changelog xz | head) or whether the service is still running the old, memory-resident binary because it was never restarted.
The Grafana panel itself is one query against the append-only findings table. This is the query behind the "critical exposure window" trend line, anything still open contributes its age up to now, closed findings contribute their actual lifetime:
SQL, MEAN EXPOSURE WINDOW FOR CRITICAL FINDINGS, PER WEEK
SELECT date_trunc('week', detected_at) AS week,
avg(extract(epoch FROM
coalesce(closed_at, now()) - detected_at) / 3600.0)
AS mean_exposure_hours
FROM findings
WHERE severity = 'CRITICAL'
GROUP BY 1
ORDER BY 1;
EXPECTED OUTPUT, WINDOW COLLAPSING ACROSS CYCLES
week | mean_exposure_hours
---------------------+--------------------
2026-04-13 00:00:00 | 168.4 # ~7 days, manual process
2026-05-11 00:00:00 | 22.7
2026-06-08 00:00:00 | 4.1 # pipeline fully closed-loop
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Command not found | package missing | install prerequisite and verify version |
| Permission denied | insufficient privileges | use documented sudo or lab admin context |
| Output differs | version or environment mismatch | verify versions and variables |
| Service or API unreachable | dependency, DNS, or firewall issue | check status, listener, and logs |
Authenticated scan returns very few results
Symptom: the scan completes but each host shows only a handful of banner/port findings instead of hundreds of package checks.
Fix: verify the SSH credential is actually attached to the target (<get_targets/> should show the credential UUID nested inside it) and that the account can log in manually from the scanner: ssh [email protected]. Also confirm the target's scan config is credentialed-aware, "Full and fast" includes local checks, minimal custom configs often strip them. Check /var/log/secure on a target for failed logins; a locked-out account (too many failures via sshd's MaxAuthTries) scans fine but silently.
Feed out of date / "no NVTs available"
Symptom: scans run but return nothing, or gvmd logs complain the SCAP/NVT database is empty.
Fix: run greenbone-feed-sync (all four types) as the service user and watch /var/log/gvm/ while gvmd rebuilds its NVT cache, the rebuild can take 30+ minutes and scans started during it return junk. If syncs previously ran as root, fix ownership first: chown -R _gvm:_gvm /var/lib/openvas /var/lib/gvm. Verify feed currency with the timestamps under /var/lib/gvm/scap-data/.
Ansible patch reboot loops
Symptom: hosts reboot, come back, and immediately get rebooted again by a re-run or the next play.
Fix: gate reboots behind a reboot_required fact check, on RHEL use needs-restarting -r (exit code 1 = reboot needed), on Ubuntu check for /var/run/reboot-required. Never reboot unconditionally. Also make the fact check its own task that runs after all package tasks, so mid-play checks do not see a stale flag from an earlier boot.
gvm-cli fails with "Failed to connect to socket"
Symptom: every GMP command errors out immediately with a socket connection failure.
Fix: gvmd is either down or the socket path is wrong. Check systemctl status gvmd and confirm the socket exists at /run/gvmd/gvmd.sock. If it exists but you get permission denied, your user is not in the _gvm group, either add yourself or pass --socketpath with appropriate sudo. After a reboot, gvmd can take several minutes to finish loading the NVT cache before the socket accepts connections.
Jira API returns 400 or 401 on ticket creation
Symptom: the ticketing script fails with HTTP 400 (bad request) or 401 (unauthorized).
Fix: for 401, the API token is wrong or revoked, Jira Cloud requires basic auth of email:api_token, not a password, and tokens expire when rotated. For 400, read the response body's errors map: it almost always names a required custom field (the CVE/CVSS/Host fields here) that is missing from the payload or not on the issue type's create screen. Confirm field IDs with GET /rest/api/3/field, custom field numbers differ per Jira instance.
Key Results
- Orchestrated patching across 15+ Linux servers with a 99.2% success rate.
- Prioritized 200+ CVEs by CVSS 3.1 and business criticality each cycle.
- Reduced the critical-vulnerability exposure window from 7 days to 4 hours.
- Automated Jira ticket creation for 100% of high/critical findings.
Key Takeaways
- Detection was never the bottleneck, handoffs were. Automating the scan → ticket → patch → verify chain mattered more than any single tool choice.
- Credentialed scans are non-negotiable. Unauthenticated scanning misses the package-level CVEs that make up the bulk of real risk.
- Every stage must emit an artifact the next stage consumes (report UUID, CSV, issue key, recap), so failures halt the pipeline loudly instead of silently dropping findings.
- Patch narrowly and verify with a delta report. Update only implicated packages, one host at a time, and let the scanner, not the playbook recap, have the final word on "fixed".
- Measure the exposure window with timestamps, not vibes. Append-only results in PostgreSQL turned "we patch faster now" into a defensible metric.
Practice Challenges
Challenge 1: Exception workflow
Goal: Add a risk-acceptance path: findings marked accepted skip ticketing but expire after 90 days.
Hint: Add an exceptions table in PostgreSQL and filter it in the prioritization script.
Challenge 2: Patch window safety
Goal: Make the Ansible playbook snapshot (or checkpoint) each host before patching and roll back on health-check failure.
Hint: Use Proxmox snapshots or LVM snapshots in pre_tasks and a rescue block for rollback.