Building an Automated Dependency Update Bot with Security Checks
Outdated dependencies are behind a significant chunk of real-world breaches — Log4Shell, the 2021 Codecov incident, and countless smaller compromises all involved packages that weren't updated or audited. The fix seems obvious, but most teams don't update regularly because doing it by hand is slow, test coverage is never quite right, and nobody owns the task.
This article walks through building a bot that does it automatically: scan your Python project for outdated packages, check each one against a public vulnerability database, and open a pull request with the update when a CVE is found in the installed version.
Why Manual Dependency Management Fails
The standard approach is to run pip list --outdated or safety check once in a while, feel mild concern, then move on. It fails for three reasons:
- No one is accountable. Updating dependencies is infrastructure work with no immediate user value, so it gets deprioritized indefinitely.
- Batch updates are risky. Skip six months of updates and one
pip install -Ucan break half your app. Small, frequent updates are safer but require more process than a team will sustain manually. - Vulnerability knowledge is external. You can't know which packages have CVEs just by looking at your
requirements.txt. You need a live feed.
An automated bot solves all three: it runs on a schedule, creates one PR per package rather than a giant batch, and pulls CVE data fresh each time.
Architecture Overview
The bot has three stages:
- Scan — list all outdated packages in the project
- Check — query the OSV API for known CVEs in the currently installed version
- Act — if a CVE exists, create a Git branch, install the update, and open a GitHub PR
For CVE data I use the OSV API (osv.dev — Google's open-source vulnerability database). It covers PyPI, npm, Go, Rust, Maven, and more. No authentication required, no rate-limit headaches.
Stage 1: Scanning and Checking CVEs
# checker.py import subprocess import json import requests from dataclasses import dataclass, field @dataclass class PackageStatus: name: str current_version: str latest_version: str vulnerabilities: list[dict] = field(default_factory=list) def get_outdated_packages() -> list[dict]: result = subprocess.run( ["pip", "list", "--outdated", "--format=json"], capture_output=True, text=True, check=True ) return json.loads(result.stdout) def check_osv(package_name: str, version: str) -> list[dict]: payload = { "package": {"name": package_name, "ecosystem": "PyPI"}, "version": version } resp = requests.post( "https://api.osv.dev/v1/query", json=payload, timeout=10 ) resp.raise_for_status() return resp.json().get("vulns", []) def audit_project() -> list[PackageStatus]: outdated = get_outdated_packages() results = [] for pkg in outdated: vulns = check_osv(pkg["name"], pkg["version"]) results.append(PackageStatus( name=pkg["name"], current_version=pkg["version"], latest_version=pkg["latest_version"], vulnerabilities=vulns )) return results A few things worth noting here:
-
pip list --outdated --format=jsongives you structured output you can parse without regex. Much more reliable than scraping the text format. - The OSV API returns CVE identifiers plus CVSS severity scores, so you can filter by severity if you only want to act on critical or high findings.
- Wrap results in a dataclass rather than passing raw dicts around — it makes the downstream code cleaner and catches typos at write time.
You can test the scan step in isolation before wiring up the GitHub integration:
for pkg in audit_project(): if pkg.vulnerabilities: ids = [v["id"] for v in pkg.vulnerabilities] print(f"{pkg.name} {pkg.current_version} → {pkg.latest_version} | CVEs: {', '.join(ids)}") Stage 2: Opening Pull Requests Automatically
Once you have a list of packages with CVEs in the installed version, the bot creates a branch per package, runs the update, and opens a PR.
# pr_creator.py import os import subprocess import requests from datetime import datetime GITHUB_TOKEN = os.environ["GITHUB_TOKEN"] REPO = os.environ["GITHUB_REPO"] # format: "owner/repo" def update_package_and_open_pr( pkg_name: str, old_version: str, new_version: str, vuln_ids: list[str] ) -> str: branch = f"deps/{pkg_name}-{new_version}-{datetime.now().strftime('%Y%m%d')}" subprocess.run(["git", "checkout", "-b", branch], check=True) subprocess.run(["pip", "install", f"{pkg_name}=={new_version}"], check=True) subprocess.run("pip freeze > requirements.txt", shell=True, check=True) subprocess.run(["git", "add", "requirements.txt"], check=True) subprocess.run( ["git", "commit", "-m", f"deps: update {pkg_name} to {new_version}"], check=True ) subprocess.run(["git", "push", "origin", branch], check=True) cve_list = "\n".join(f"- {v}" for v in vuln_ids) body = ( f"## Automated dependency update\n\n" f"**Package:** `{pkg_name}`\n" f"**Current version:** `{old_version}` (vulnerable)\n" f"**Updated to:** `{new_version}`\n\n" f"**CVEs patched:**\n{cve_list}\n\n" f"> This PR was opened automatically. Run the test suite before merging." ) headers = { "Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json", } resp = requests.post( f"https://api.github.com/repos/{REPO}/pulls", headers=headers, json={ "title": f"deps: update {pkg_name} {old_version} → {new_version}", "body": body, "head": branch, "base": "main" }, timeout=15 ) resp.raise_for_status() return resp.json()["html_url"] Practical notes:
- Run this inside CI (GitHub Actions, GitLab CI) rather than locally. You want a clean virtualenv each run so
pip freezereflects only project dependencies, not whatever you have installed on your laptop. - Before creating a PR, check whether one already exists for that branch with a
GET /repos/{REPO}/pulls?head={branch}call. Otherwise you will get a 422 on the second run. - For projects using
pyproject.tomlorpoetry.lock, swap the freeze step forpoetry update {pkg_name}and commit the lockfile instead.
Running it on a Schedule
The simplest deployment is a GitHub Actions cron workflow:
# .github/workflows/dep-audit.yml name: Dependency Security Audit on: schedule: - cron: "0 8 * * 1" # every Monday at 08:00 UTC workflow_dispatch: jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -r requirements.txt requests - run: python bot.py env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO: ${{ github.repository }} The workflow_dispatch trigger is useful when a high-profile CVE drops mid-week and you do not want to wait until Monday. One click and the bot runs immediately.
Before automating, it is worth having a solid security baseline for your project. We publish free security hardening checklists for different environments if you want a structured starting point.
The Takeaway
Automated dependency updates are not just a convenience — they are a security control. The window between a CVE publication and an active exploit in the wild has shrunk to days, sometimes hours. Manual quarterly reviews do not cut it anymore.
The design here is intentional about scope: one PR per package, triggered by a confirmed CVE rather than updating everything blindly, and always through a pull request so a human reviews before merging. That balance keeps the bot useful without making it a liability that silently bumps a major version and breaks production.
The OSV API is free, well-maintained, and covers most ecosystems. There is no good reason not to query it in CI.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.


