
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}.
- 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 versionapt 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:
- Query for installed packages - to get the list of packages we want to check for vulnerabilities. Not installed, not our problem!
- 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.
- Check if the vulnerability is fixable - If the package can be upgraded, will upgrading it, fix the vulnerability?
- 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:
