Write a Python vulnerability scanner
Home Python Build a Vulnerability Scanner in Python

Build a Vulnerability Scanner in Python

12 May, 2025

Here's what you need to know before getting started:

Frequently Asked Questions

A vulnerability is a flaw in a piece of software that can be exploited in order to either damage a system, steal data or do some other type of damage.

Vulnerabilities are catalogued in public databases and have an ID in order to reference them easier. One of these databases is the CVE (Common Vulnerabilities and Exposures) and IDs usually looks like CVE-{YEAR}-{NUMERIC_ID}.

Vulnerability scanners are very useful for keeping systems secure and up-to-date. System Engineers and Cybersecurity Professionals use them in order to detect vulnerabilities in their systems, to prioritize them and to automate the monitoring of these systems.

In this tutorial we are going to use the OSV.DEV database. There are others, but this API is convenient easy to understand. For a specific package and version, we are going to query this API for potential CVEs.

CVSS stands for Common Vulnerability Scoring System and is a standard for rating vulnerability severity on a 0–10 scale. This scanner parses CVSS vectors (like CVSS:3.1/...) and maps them to readable levels:
  • LOW → (0.1 – 3.9)
  • MEDIUM → (4.0 – 6.9)
  • HIGH → (7.0 – 8.9)
  • CRITICAL → (9.0 – 10.0)

Project Setup

For this project we will need access to a Debian based Linux machine. If you are already on such system great! If not, find one!

The scanner we will be building is designed for Debian distributions (Debian, Ubuntu, Linux Mint). We are going to use commands like: dpkg-query amd apt. For other distros, we would need to use different commands. For the sake of simplicity we are going to focus on Debian.

Installed and Upgradable Packages

Here are the 2 bash commands we will be using:

  • dpkg-query --show → Get installed packages and their version
  • apt list --upgradable → Get packages that can be upgraded

Try them out for yourself, see what results you get.

Querying the OSV.dev API

Why do we need to query an API? You simply can't tell what vulnerabilities a package contains just by looking at the version number. There are specialized databases that keep track of from what version the package became vulnerable, when it was fixed, the severity of the vulnerability etc...

This is the endpoint we need to query: https://api.osv.dev/v1/query. The posted payload follows this format:

1
2
3
4
5
6
7
{
    "package": {
        "name": {{ package_name }},
        "ecosystem": "Debian"
    },
    "version": {{ version }}
}

Here is a sample response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
{
    'vulns': [
        {
            'affected': [
                {
                    'database_specific': {
                        'source': 'https://storage.googleapis.com/cve-osv-conversion/osv-output/CVE-2012-6655.json'
                    },
                    'ecosystem_specific': {
                        'urgency': 'low'
                    },
                    'package': {
                        'ecosystem': 'Debian:11',
                        'name': 'accountsservice',
                        'purl': 'pkg:deb/debian/accountsservice?arch=source'
                    },
                    'ranges': [
                        {
                            'events': [{'introduced': '0'}],
                            'type': 'ECOSYSTEM'
                        }
                    ],
                    'versions': [
                        '0.6.55-3',
                        '22.07.5-1',
                        # ...
                        '23.13.9-6.1',
                        '23.13.9-7'
                    ]
                },
                {
                    'database_specific': {
                        'source': 'https://storage.googleapis.com/cve-osv-conversion/osv-output/CVE-2012-6655.json'
                    },
                    'ecosystem_specific': {
                        'urgency': 'low'
                    },
                    'package': {
                        'ecosystem': 'Debian:12',
                        'name': 'accountsservice',
                        'purl': 'pkg:deb/debian/accountsservice?arch=source'
                    },
                    'ranges': [
                        {
                            'events': [
                                {'introduced': '0'},
                                {'fixed': '22.08.8-4'}
                            ],
                            'type': 'ECOSYSTEM'
                        }
                    ]
                },
                {
                    'database_specific': {
                        'source': 'https://storage.googleapis.com/cve-osv-conversion/osv-output/CVE-2012-6655.json'
                    },
                    'ecosystem_specific': {
                        'urgency': 'low'
                    },
                    'package': {
                        'ecosystem': 'Debian:13',
                        'name': 'accountsservice',
                        'purl': 'pkg:deb/debian/accountsservice?arch=source'
                    },
                    'ranges': [
                        {
                            'events': [
                                {'introduced': '0'},
                                {'fixed': '22.08.8-4'}
                            ],
                            'type': 'ECOSYSTEM'
                        }
                    ]
                }
            ],
            'details': 'An issue exists AccountService 0.6.37 in the ...',
            'id': 'CVE-2012-6655',
            'modified': '2025-01-14T06:04:49.431182Z',
            'published': '2019-11-27T18:15:11Z',
            'references': [
                {
                    'type': 'WEB',
                    'url': 'http://www.securityfocus.com/bid/69245'
                },
                # ...
            ],
            'related': ['UBUNTU-CVE-2012-6655', 'USN-6687-1'],
            'schema_version': '1.6.0',
            'severity': [
                {
                    'score': 'CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N',
                    'type': 'CVSS_V3'
                }
            ]
        }
    ]
}

Checking if a Vulnerability is Fixable

Ok, we found a vulnerability. How do we know if that vulnerability is fixable?

A vulnerability will have a list of events. If we can find a "fix" event on a version that is more recent than what we are currently running, then the vulnerability is fixable!

Extract Severity Using CVSS

If you look at the sample payload from osv.dev, you'll notice that it contains a severity score, with the value: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N. Let's see what that means:

Metric Value Meaning
AV (Attack Vector) L (Local) Requires local access to the system
AC (Attack Complexity) L (Low) No special conditions required
PR (Privileges Required) L (Low) Attacker needs low privileges
UI (User Interaction) N (None) No user interaction required
S (Scope) U (Unchanged) Impact is limited to the vulnerable component
C (Confidentiality) L (Low) Partial disclosure of information
I (Integrity) N (None) No modification of data
A (Availability) N (None) No impact on availability

This notation is called CVSS, and it stands for Common Vulnerability Scoring System.

You can parse these strings using the cvss Python library like this:

1
2
from cvss import CVSS3
cvss = CVSS3("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N")
Score Range Severity Level Description
0.0 None No impact
0.1 – 3.9 Low Minor security impact
4.0 – 6.9 Medium Could be exploited with some effort
7.0 – 8.9 High Serious security risk, likely to be exploited
9.0 – 10.0 Critical Severe impact, often remotely exploitable

Write the VulnerabilityScanner class

Let's define our VulnerabilityScanner class, define our constants and implement the __init__ method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import click
import requests
import subprocess
from cvss import CVSS3
from packaging import version



class VulnerabilityScanner:
    OSV_ECOSYSTEM = "Debian"
    OSV_API_URL = "https://api.osv.dev/v1/query"

    INSTALLED_PACKAGES_CMD = ['dpkg-query', '-W', '-f=${Package} ${Version}\n']
    UPGRADABLE_PACKAGES_CMD = ['apt', 'list', '--upgradable']

    SEVERITY_CRITICAL_THRESHOLD = 9.0
    SEVERITY_HIGH_THRESHOLD = 7.0
    SEVERITY_MEDIUM_THRESHOLD = 4.0
    SEVERITY_LOW_THRESHOLD = 0.1

    SEVERITY_THRESHOLDS = [
        (9.0, "CRITICAL"),
        (7.0, "HIGH"),
        (4.0, "MEDIUM"),
        (0.1, "LOW"),
    ]

    def __init__(self, only_upgradable=True):
        self.only_upgradable = only_upgradable
        self.installed_packages = self.get_installed_packages()
        self.upgradable_packages = self.get_upgradable_packages() if only_upgradable else set()

Run the INSTALLED_PACKAGES_CMD and parse the response:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    def get_installed_packages(self):
        result = subprocess.run(
            self.INSTALLED_PACKAGES_CMD,
            capture_output=True, text=True
        )
        packages = {}
        for line in result.stdout.splitlines():
            try:
                name, version_ = line.strip().split()
                packages[name] = version_
            except ValueError:
                continue
        return packages

Run the UPGRADABLE_PACKAGES_CMD and parse the response:

1
2
3
4
5
6
7
8
    def get_upgradable_packages(self):
        result = subprocess.run(self.UPGRADABLE_PACKAGES_CMD, capture_output=True, text=True)
        upgradable = set()
        for line in result.stdout.splitlines():
            if "/" in line and "upgradable" in line:
                name = line.split("/")[0]
                upgradable.add(name)
        return upgradable

Like discussed, we need to query the OSV API in order to get the vulnerabilities of a package:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    def query_osv(self, package_name, version_):
        payload = {
            "package": {
                "name": package_name,
                "ecosystem": self.OSV_ECOSYSTEM
            },
            "version": version_
        }
        try:
            response = requests.post(self.OSV_API_URL, json=payload)
            if response.status_code == 200:
                return response.json().get("vulns", [])
        except requests.RequestException:
            pass
        return []

Let's now pass through the events associated with that vulnerability and see if there is a more recent version than ours that fixes the vulnerability:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    def is_fixable(self, vuln, current_version):
        for affected in vuln.get("affected", []):
            for range_ in affected.get("ranges", []):
                for event in range_.get("events", []):
                    if "fixed" in event:
                        try:
                            if version.parse(current_version) < version.parse(event["fixed"]):
                                return event["fixed"]
                        except:
                            pass
        return None

Let's assign the severity according to the severity thresholds. We just need to loop over the thresholds and find the first one that is lower than the score:

1
2
3
4
5
    def extract_severity(self, score):
        for threshold, severity in self.SEVERITY_THRESHOLDS:
            if score >= threshold:
                return severity
        return "NONE"

Let's create a convenient method that extracts the CVSS score from the vulnerability payload, parses the CVSS string, and computes the severity. It should return a simple (severity, score) tuple:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    def extract_cvss(self, vuln):
        for entry in vuln.get("severity", []):
            cvss_score = entry.get("score", "")
            try:
                cvss = CVSS3(cvss_score)
                score_val = cvss.scores()[0]
                severity = self.extract_severity(score_val)
                return severity, score_val
            except:
                pass
        return "UNKNOWN", None

Time has come to put everything together. A good command line program has meaningful output, so let's make sure we are being sufficiently verbose. Here's what we're doing here:

  1. Query for installed packages - to get the list of packages we want to check for vulnerabilities. Not installed, not our problem!
  2. Query for upgradable packages - get the list of packages we can upgrade. If we can't upgrade it, we can either accept the vulnerability or uninstall the package.
  3. Check if the vulnerability is fixable - If the package can be upgraded, will upgrading it, fix the vulnerability?
  4. Print summary - For each vulnerability, show the user a meaningful summary
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    def run(self):
        if self.only_upgradable:
            print("🔍 Scanning only upgradable packages for vulnerabilities...\n")
        else:
            print("🔍 Scanning all packages for vulnerabilities...\n")

        total_vulns = 0

        for name, ver in self.installed_packages.items():
            if self.only_upgradable and name not in self.upgradable_packages:
                continue

            vulns = self.query_osv(name, ver)
            if vulns:
                print(f"⚠️  {name} {ver} has {len(vulns)} vulnerabilities:")
                for vuln in vulns:
                    fix = self.is_fixable(vuln, ver)
                    severity, score_val = self.extract_cvss(vuln)
                    fix_text = f"(✅ Fix: {fix})" if fix else "(❌ No fix)"
                    summary = vuln.get("summary", "No summary")
                    print(f"  - {vuln['id']}: {summary} {fix_text} "
                          f"[Severity: {severity} ({score_val})]")
                print()
                total_vulns += len(vulns)

        print(f"✅ Scan complete. Total vulnerabilities found: {total_vulns}")

Write the CLI Tool

Let's now use the click library to facilitate running the scanner as a command line script. Click is a neat library for setting up command line programs, passing parameters and validating them. There are other ways of doing this, but click is my goto option.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@click.command()
@click.option(
    "--only-upgradable",
    is_flag=True,
    default=False,
    help="Scan only packages that are upgradable (default: False)"
)
def main(only_upgradable):
    scanner = VulnerabilityScanner(only_upgradable=only_upgradable)
    scanner.run()

if __name__ == "__main__":
    main()

You can now simply run it like this $ python scanner.py --only-upgradable. Here's a sample output:

Vulnerability Scanner - Sample output
Vulnerability Scanner - Sample output