Vulnerability Management Lifecycle

Production-grade guide to vulnerability management lifecycle covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

Vulnerability Management Lifecycle is the continuous process of identifying, assessing, prioritizing, remediating, and verifying security vulnerabilities across an organisation’s assets. It matters most when systems are in active use—when code is deployed, infrastructure is running, and new services are exposed to the internet. It is not a project but a rhythm, a repeatable sequence that keeps systems secure between releases and across environments.

Discover and Inventory Assets

The first step is knowing what exists. A vulnerability is only as valuable as the asset it describes. Without a complete inventory, scans miss systems, and findings go unactioned.

To build and maintain this inventory, engineers use a combination of network discovery and asset tagging. A typical workflow begins with a full network scan using nmap:

nmap -sS -sU -O -T4 -p 1-65535 --open --osscan --script=vuln -oA full-scan 192.168.1.0/24

This command performs a TCP SYN scan, UDP scan, OS detection, and runs vulnerability scripts against all open ports. The output includes a .gnmap file for incremental scans and a .nmap file for detailed results. The --script=vuln flag enables a suite of Nmap scripts from the vuln category, such as http-security-headers, ssh-auth-methods, and cve-2021-44228 (Log4Shell).

The results are ingested into a vulnerability database. For example, a PostgreSQL-backed asset inventory might use a table assets with columns: id, ip, hostname, os, last_scanned, tags, status. A script updates this table from the Nmap output:

INSERT INTO assets (ip, hostname, os, last_scanned, tags, status)
VALUES ('192.168.1.10', 'web01.example.com', 'Ubuntu 20.04', now(), ARRAY['web', 'prod'], 'active')
ON CONFLICT (ip) DO UPDATE SET
  hostname = EXCLUDED.hostname,
  os = EXCLUDED.os,
  last_scanned = EXCLUDED.last_scanned,
  tags = array_cat(assets.tags, EXCLUDED.tags),
  status = 'active'
WHERE assets.status = 'inactive';

Assets are then tagged dynamically using a set of rules: systems with nginx in their tags array are automatically assigned the web-server role. Systems with port 22 and SSH-2.0-OpenSSH in their services JSON are marked as ssh-enabled.

A common failure occurs when the inventory does not reflect actual runtime state. A server might be marked active in the database but be unreachable for 72 hours. The scan detects it, but the last_scanned field is stale. To detect such drift, a daily cron job runs:

#!/bin/sh

psql -c "
SELECT ip, hostname, last_scanned
FROM assets
WHERE status = 'active'
  AND last_scanned < now() - interval '2 days'
  AND ip NOT IN (
    SELECT ip FROM (
      SELECT DISTINCT ip FROM (
        SELECT '192.168.1.0/24'::cidr AS cidr
      ) AS network
      JOIN (
        SELECT ip FROM nmap_results
        WHERE scan_type = 'full-scan'
      ) AS discovered ON network.cidr @> discovered.ip
    )
"

This query identifies assets that were active but have not been scanned in over two days—indicating possible downtime or network issues.

Scan and Detect Vulnerabilities

With a current asset list, the next step is scanning. Scans must be both thorough and repeatable. Engineers use a combination of tools: nmap for network-level detection, openvas for host and service scans, and trivy for container and file system analysis.

For containerized environments, trivy scans images directly from a registry:

trivy image --exit-code 1 --severity HIGH,CRITICAL --format json --output trivy-results.json docker.io/myapp/web:latest

The --exit-code 1 flag ensures the command fails if any high or critical vulnerability is found, making it ideal for CI/CD pipelines. The output is a JSON file with entries for each vulnerability, including id, title, severity, description, references, and fixed_in.

A typical trivy-results.json snippet:

[
  {
    "Target": "alpine:3.18",
    "Type": "OS",
    "Vulnerabilities": [
      {
        "ID": "CVE-2022-34918",
        "Title": "Buffer overflow in libpam",
        "Severity": "CRITICAL",
        "Description": "A buffer overflow in libpam allows remote attackers to execute arbitrary code.",
        "References": [
          "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-34918"
        ],
        "FixedIn": "1.4.0-r1",
        "InstalledVersion": "1.4.0-r0"
      }
    ]
  }
]

For host-based scanning, openvas is invoked via its command-line interface:

openvas-start
openvas-manage --create-task --name="Weekly Host Scan" --target="192.168.1.0/24"
openvas-control --start-task=123

The openvas-manage tool creates a task with a specific target and schedule. The openvas-control command runs the task and polls its status until completion. A task result is stored as a XML file in the openvas storage directory, with a report element containing results and summary.

Scans often fail silently. The most common issue is a mismatch between scan frequency and the scope of assets. A scan configured for 192.168.1.0/24 might miss a newly added server in 192.168.2.0/24. To detect this, a script compares the scan target with the current asset list:

#!/bin/sh
# Validate scan scope against current assets
if ! diff <(echo "192.168.1.0/24" | jq -r --slurpfile assets assets.json '.[] | .ip' | sort) <(grep -oE '192\.168\.[0-9]+\.[0-9]+' scan-results.xml | sort); then
  echo "Scan scope mismatch: assets not in target range" >&2
  exit 1
fi

Another silent failure is scan drift: a scan task that runs weekly but does not update its target. The target field in openvas is a static list of IPs; when a new server is added, it must be manually included. A missing --update-target flag in the openvas-manage command leads to stale scan targets.

Prioritize and Assess Vulnerabilities

Not all vulnerabilities are equally important. Prioritization requires scoring, context, and risk assessment. The standard approach uses CVSS (Common Vulnerability Scoring System), but engineers must apply it consistently.

For each vulnerability, the system computes a base score and applies environmental adjustments. A CVSS3 score is derived from the following formula:

Base Score = 10.0 ×
  (Impact × 0.61) +
  (Exploitability × 0.39)

The Impact factor depends on confidentiality, integrity, and availability losses. Exploitability depends on attack vector, attack complexity, privileges required, user interaction, and scope.

A typical CVSS3 JSON input:

{
  "attackVector": "NETWORK",
  "attackComplexity": "LOW",
  "privilegesRequired": "LOW",
  "userInteraction": "NONE",
  "scope": "UNCHANGED",
  "confidentiality": "HIGH",
  "integrity": "HIGH",
  "availability": "HIGH",
  "baseScore": 9.1,
  "baseSeverity": "CRITICAL"
}

This JSON is used to score vulnerabilities in a Python service:

def calculate_cvss3(cvss_data):
    impact = 6.42 * (
        1 - (1 - cvss_data['confidentiality']) * 
        (1 - cvss_data['integrity']) * 
        (1 - cvss_data['availability'])
    )
    exploitability = 8.22 * (
        1 - cvss_data['attackComplexity'] * 
        cvss_data['privilegesRequired'] * 
        cvss_data['userInteraction']
    )
    base_score = 10.0 * (impact * 0.61 + exploitability * 0.39)
    return round(base_score, 1)

The cvss_data dictionary comes from a database query:

SELECT 
  v.id,
  v.title,
  v.severity,
  v.cve_id,
  (SELECT cvss3 FROM cvss_scores WHERE cve_id = v.cve_id) AS cvss3
FROM vulnerabilities v
WHERE v.severity = 'HIGH'
  AND v.assessment = 'pending'
ORDER BY cvss3->>'baseScore' DESC;

A frequent failure is neglecting the scope metric. When a vulnerability affects a component outside the initial system (e.g., a web app affecting a database), the scope is CHANGED. The impact is lower than expected, and the base score is underestimated. Engineers often confuse scope=UNCHANGED with scope=CHANGED—the latter requires additional effort to assess and document.

Remediate and Track Fixes

Once prioritized, vulnerabilities are assigned to teams. Engineers use a ticketing system, often integrated with a Git workflow. A typical remediation process begins with a GitHub issue created from a vulnerability:

{
  "title": "[CVE-2022-34918] Buffer overflow in libpam",
  "body": "A buffer overflow in libpam allows remote attackers to execute arbitrary code.\n\nAffected systems: all web servers, containers, and VMs.\n\nSeverity: CRITICAL\n\nCVSS3: 9.1\n\nReferences:\n- https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-34918\n- https://github.com/openssl/openssl/issues/12345",
  "labels": ["vulnerability", "critical", "remediation"],
  "assignees": ["security-team", "devops-team"]
}

The assignees field is populated from a team_mapping.json file:

{
  "vulnerability": {
    "security-team": {
      "response_time": "48h",
      "responsibility": "triage, documentation, validation"
    },
    "devops-team": {
      "response_time": "24h",
      "responsibility": "patching, deployment, monitoring"
    }
  }
}

When a fix is merged, a pull request is linked to the issue, and a status update is posted via curl:

curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Patch applied. Version 1.4.0-r1 of libpam is now in production. The fix is verified on web01, web02, and app01.",
    "labels": ["fixed", "verified"]
  }' \
  https://api.github.com/repos/myorg/security/issues/421/comments

A common failure is a mismatch between the fixed_in version and the actual deployed version. A vulnerability states fixed_in: 1.4.0-r1, but the deployment script installs 1.4.0-r0. The solution is a verify-fix step:

#!/bin/sh
# Verify that a vulnerability is fixed in deployed version
expected_version="1.4.0-r1"
actual_version=$(ssh web01.example.com 'cat /etc/os-release | grep VERSION_ID | cut -d\" -f2')

if [ "$actual_version" != "$expected_version" ]; then
  echo "Fix verification failed: expected $expected_version, got $actual_version"
  exit 1
fi

This script is run as part of a CI pipeline, and the result is logged in the issue. If the verification fails, a new comment is posted, and the ticket is reopened.

Verify and Close

The final phase ensures that vulnerabilities are not just fixed but confirmed. Verification includes testing, monitoring, and validation.

Engineers run a test suite against a staging environment. A pytest suite checks for the presence of a patch:

import pytest
import subprocess

@pytest.mark.parametrize("host", ["web01", "web02", "app01"])
def test_libpam_version(host):
    result = subprocess.run(
        ["ssh", f"{host}.example.com", "cat /etc/os-release | grep VERSION_ID"],
        capture_output=True,
        text=True,
    )
    assert "1.4.0-r1" in result.stdout
    assert result.returncode == 0

The test suite is run nightly via cron:

0 2 * * * /home/ops/scripts/run-vulnerability-verification.sh

A failure in this script triggers a notification via slack:

import requests
import json

def notify_slack(message, channel="#security"):
    payload = {
        "text": message,
        "channel": channel,
        "attachments": [
            {
                "color": "danger",
                "title": "Vulnerability Verification Failed",
                "text": "One or more systems failed to apply the fix.",
                "fields": [
                    {"title": "Run", "value": "2024-04-10 02:00:00", "short": True},
                    {"title": "Failures", "value": "web01, app01", "short": True}
                ]
            }
        ]
    }
    requests.post("https://hooks.slack.com/services/ABC123/DEF456/GHI789", data=json.dumps(payload))

The attachments field in the Slack payload is not optional—it is the standard way of conveying structured data in Slack-based alerts.

A subtle failure occurs when the verification is done in isolation but not in production. The staging environment has the correct version, but production servers are still on 1.4.0-r0. The solution is to run verification against production data: a script pulls the current docker-compose.yml and runs the test suite against live containers.

Finally, when verification passes, the vulnerability is closed. The status in the database is updated:

UPDATE vulnerabilities
SET status = 'closed',
    verified_at = now(),
    verified_by = 'verification-pipeline'
WHERE id IN (
  SELECT id FROM vulnerabilities
  WHERE status = 'remediated'
    AND verification_passed = true
);

A verification_passed flag is set by a pipeline job that checks the output of the pytest suite. If any test fails, the job is marked failed, and the ticket remains open.

Feedback and Continuous Improvement

The lifecycle is not linear but circular. Each iteration feeds data into the next. The trivy scan generates a trivy-results.json file that is parsed and stored. The nmap results are archived and compared across scans. The CVSS3 scores are versioned and reused.

A feedback loop connects the openvas task results to the trivy output. When a new trivy scan finds a vulnerability not present in openvas, a new task is created. When openvas detects a service not in trivy, a new trivy scan is scheduled.

This continuous flow ensures that the vulnerability management system evolves with the environment. It is not a one-time project but a living process.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.