mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix: clarify that simulation shows commit-signing layer, not full artifact chain
This commit is contained in:
parent
c24d69b674
commit
adaf2933af
2 changed files with 138 additions and 138 deletions
|
|
@ -20,10 +20,23 @@ KERI-based decentralized identifiers (DIDs). With Auths:
|
|||
- Stealing PyPI/npm credentials is insufficient without the signing key
|
||||
- Verification happens locally — no network calls to a central authority
|
||||
|
||||
## How Auths Addresses the Attack
|
||||
|
||||
The real attack bypassed Git entirely — the attacker published directly to PyPI with
|
||||
no corresponding commit. Commit-level signing alone would not have caught a
|
||||
registry-only publish. However, Auths establishes a verifiable chain: every legitimate
|
||||
release must trace back to a signed commit by an authorized maintainer. A package
|
||||
published without a matching signed commit has no valid attestation chain and would be
|
||||
flagged by consumers and CI pipelines that verify signatures.
|
||||
|
||||
This workflow adds the commit-signing layer. A full deployment would also use
|
||||
`auths artifact sign` in the release workflow to bind published packages to signed
|
||||
commits, closing the gap completely.
|
||||
|
||||
## Running the Simulation
|
||||
|
||||
The simulation script recreates the attack scenario and demonstrates how Auths
|
||||
verification catches the unauthorized commit:
|
||||
The simulation script demonstrates the commit-signing layer — it shows that commits
|
||||
from unauthorized parties are detected:
|
||||
|
||||
```bash
|
||||
brew tap auths-dev/auths-cli && brew install auths
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""
|
||||
Auths Attack Simulation: LiteLLM March 24, 2026 Supply Chain Incident
|
||||
|
||||
Demonstrates how Auths cryptographic commit verification would have detected
|
||||
the unauthorized PyPI publish that compromised LiteLLM v1.82.7 and v1.82.8.
|
||||
Demonstrates how Auths cryptographic verification would have detected the
|
||||
unauthorized PyPI publish that compromised LiteLLM v1.82.7 and v1.82.8.
|
||||
|
||||
What happened:
|
||||
1. Attacker compromised the Trivy GitHub Action (March 19)
|
||||
|
|
@ -12,106 +12,26 @@ What happened:
|
|||
5. The malicious packages contained a credential stealer in a .pth file
|
||||
6. Source code on GitHub was never modified — the attack existed only in PyPI
|
||||
|
||||
Why Auths prevents this:
|
||||
With Auths, every release artifact carries an Ed25519 signature from the
|
||||
maintainer's cryptographic identity (KERI-based DID). Stealing the PyPI
|
||||
token is insufficient — the attacker cannot produce a valid signature
|
||||
without the maintainer's private key stored in their device keychain.
|
||||
How Auths closes this gap:
|
||||
The real attack bypassed Git entirely — the attacker published directly to
|
||||
PyPI with no corresponding commit. Auths establishes a policy that every
|
||||
legitimate release must trace back to a signed action by an authorized
|
||||
maintainer. A package published without a valid signature from a known
|
||||
maintainer identity has no valid attestation and would be rejected.
|
||||
|
||||
This simulation uses the Auths Python SDK to demonstrate the core
|
||||
cryptographic primitive: sign an action with a maintainer's key, then
|
||||
show that verification succeeds for the legitimate release and fails
|
||||
for a tampered or unauthorized one.
|
||||
|
||||
Usage:
|
||||
brew tap auths-dev/auths-cli && brew install auths
|
||||
pip install auths
|
||||
python auths_attack_simulation.py
|
||||
|
||||
Requires: auths (Python SDK)
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def check_auths_cli() -> bool:
|
||||
"""Check if the auths CLI is available."""
|
||||
return shutil.which("auths") is not None
|
||||
|
||||
|
||||
def run(cmd: list[str], cwd: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def setup_test_repo(tmpdir: str) -> str:
|
||||
"""Create a temporary git repo with signed and unsigned commits."""
|
||||
repo = os.path.join(tmpdir, "litellm-simulation")
|
||||
os.makedirs(repo)
|
||||
|
||||
# Initialize repo
|
||||
run(["git", "init"], cwd=repo)
|
||||
run(["git", "config", "user.email", "maintainer@example.com"], cwd=repo)
|
||||
run(["git", "config", "user.name", "LiteLLM Maintainer"], cwd=repo)
|
||||
|
||||
# Generate a test Ed25519 keypair for the "legitimate maintainer"
|
||||
key_path = os.path.join(tmpdir, "test_key")
|
||||
run(
|
||||
["ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "", "-q"],
|
||||
cwd=tmpdir,
|
||||
)
|
||||
|
||||
# Configure git to sign with this key
|
||||
run(["git", "config", "gpg.format", "ssh"], cwd=repo)
|
||||
run(["git", "config", "user.signingkey", key_path], cwd=repo)
|
||||
|
||||
# Create allowed_signers file
|
||||
pub_key_content = open(f"{key_path}.pub").read().strip()
|
||||
signers_path = os.path.join(repo, ".auths")
|
||||
os.makedirs(signers_path)
|
||||
with open(os.path.join(signers_path, "allowed_signers"), "w") as f:
|
||||
f.write(f"maintainer@example.com {pub_key_content}\n")
|
||||
|
||||
run(["git", "config", "gpg.ssh.allowedSignersFile", os.path.join(signers_path, "allowed_signers")], cwd=repo)
|
||||
|
||||
# Commit 1: Legitimate signed release (v1.82.6)
|
||||
with open(os.path.join(repo, "litellm_version.py"), "w") as f:
|
||||
f.write('version = "1.82.6"\n')
|
||||
run(["git", "add", "."], cwd=repo)
|
||||
run(["git", "commit", "-S", "-m", "release: v1.82.6 (legitimate, signed)"], cwd=repo)
|
||||
|
||||
# Commit 2: Attacker's malicious commit (unsigned — simulates PyPI-only publish)
|
||||
run(["git", "config", "commit.gpgSign", "false"], cwd=repo)
|
||||
with open(os.path.join(repo, "litellm_version.py"), "w") as f:
|
||||
f.write('version = "1.82.7"\n')
|
||||
with open(os.path.join(repo, "litellm_init.pth"), "w") as f:
|
||||
f.write("# Simulated malicious payload — credential stealer\n")
|
||||
f.write("import os; os.environ.get('AWS_SECRET_ACCESS_KEY') # exfiltrate\n")
|
||||
run(["git", "add", "."], cwd=repo)
|
||||
run(["git", "commit", "-m", "release: v1.82.7 (MALICIOUS — unsigned)"], cwd=repo)
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
def run_verification(repo: str) -> None:
|
||||
"""Run auths verification and display results."""
|
||||
result = run(
|
||||
["auths", "verify", "HEAD~1..HEAD", "--allowed-signers", ".auths/allowed_signers"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(" BLOCKED: Unsigned commit detected")
|
||||
if result.stdout:
|
||||
print(f" Output: {result.stdout.strip()}")
|
||||
if result.stderr:
|
||||
print(f" Detail: {result.stderr.strip()}")
|
||||
else:
|
||||
print(" PASSED: All commits verified")
|
||||
if result.stdout:
|
||||
print(f" Output: {result.stdout.strip()}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
@ -120,54 +40,121 @@ def main() -> None:
|
|||
print("=" * 70)
|
||||
print()
|
||||
|
||||
if not check_auths_cli():
|
||||
print("The 'auths' CLI is not installed.")
|
||||
try:
|
||||
from auths import sign_action, verify_action_envelope
|
||||
except ImportError:
|
||||
print("The 'auths' Python SDK is not installed.")
|
||||
print()
|
||||
print("Install it with:")
|
||||
print(" brew tap auths-dev/auths-cli && brew install auths")
|
||||
print(" (or: cargo install auths_cli)")
|
||||
print(" pip install auths")
|
||||
print()
|
||||
print("Or visit: https://github.com/auths-dev/auths")
|
||||
sys.exit(0)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
print("[1] Setting up simulation repository...")
|
||||
repo = setup_test_repo(tmpdir)
|
||||
print(" Created repo with 2 commits:")
|
||||
print(" - v1.82.6: Legitimate release, signed by maintainer")
|
||||
print(" - v1.82.7: Attacker's malicious version, unsigned")
|
||||
print()
|
||||
# Derive the public key from the private seed for verification.
|
||||
# In production, the maintainer's public key comes from their Auths
|
||||
# identity (did:keri:...) and is listed in .auths/allowed_signers.
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
# Verify the legitimate commit
|
||||
print("[2] Verifying legitimate commit (v1.82.6)...")
|
||||
result = run(
|
||||
["auths", "verify", "HEAD~2..HEAD~1", "--allowed-signers", ".auths/allowed_signers"],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(" PASSED: Commit is signed by an authorized maintainer")
|
||||
else:
|
||||
print(" Result: ", result.stdout.strip() if result.stdout else result.stderr.strip())
|
||||
print()
|
||||
MAINTAINER_SEED_HEX = "a" * 64 # Simulated maintainer private key seed
|
||||
seed_bytes = bytes.fromhex(MAINTAINER_SEED_HEX)
|
||||
private_key = Ed25519PrivateKey.from_private_bytes(seed_bytes)
|
||||
MAINTAINER_PK_HEX = private_key.public_key().public_bytes_raw().hex()
|
||||
except ImportError:
|
||||
print("This simulation requires the 'cryptography' package.")
|
||||
print("Install it with: pip install cryptography")
|
||||
sys.exit(0)
|
||||
|
||||
# Verify the malicious commit
|
||||
print("[3] Verifying attacker's commit (v1.82.7)...")
|
||||
run_verification(repo)
|
||||
print()
|
||||
ATTACKER_SEED_HEX = "b" * 64 # Attacker has a different key
|
||||
MAINTAINER_DID = "did:keri:EBfxc_LiteLLM_Maintainer"
|
||||
|
||||
# Summary
|
||||
print("-" * 70)
|
||||
print("RESULT: The attacker's unsigned commit would have been flagged.")
|
||||
print()
|
||||
print("In the real attack, the attacker used a stolen PyPI token to publish")
|
||||
print("malicious packages directly to the registry. The source code on GitHub")
|
||||
print("was never modified. With Auths, even if registry credentials are stolen,")
|
||||
print("the attacker cannot produce a valid Ed25519 signature — the private key")
|
||||
print("is bound to the maintainer's device keychain and never leaves it.")
|
||||
print()
|
||||
print("Learn more: https://github.com/auths-dev/auths")
|
||||
print("=" * 70)
|
||||
# ── Step 1: Legitimate maintainer signs a release ──────────────────
|
||||
print("[1] Legitimate maintainer signs release v1.82.6...")
|
||||
print()
|
||||
|
||||
release_payload = json.dumps({
|
||||
"package": "litellm",
|
||||
"version": "1.82.6",
|
||||
"digest": "sha256:abc123def456...",
|
||||
"registry": "pypi",
|
||||
})
|
||||
|
||||
legitimate_envelope = sign_action(
|
||||
MAINTAINER_SEED_HEX,
|
||||
"release",
|
||||
release_payload,
|
||||
MAINTAINER_DID,
|
||||
)
|
||||
|
||||
result = verify_action_envelope(legitimate_envelope, MAINTAINER_PK_HEX)
|
||||
print(f" Signed by: {MAINTAINER_DID}")
|
||||
print(f" Verification: {'PASSED' if result.valid else 'FAILED'}")
|
||||
print()
|
||||
|
||||
# ── Step 2: Attacker publishes with stolen PyPI token ──────────────
|
||||
print("[2] Attacker publishes v1.82.7 using stolen PyPI token...")
|
||||
print(" (Attacker has registry credentials but NOT the signing key)")
|
||||
print()
|
||||
|
||||
malicious_payload = json.dumps({
|
||||
"package": "litellm",
|
||||
"version": "1.82.7",
|
||||
"digest": "sha256:malicious_payload_hash...",
|
||||
"registry": "pypi",
|
||||
})
|
||||
|
||||
# Attacker signs with their own key — NOT the maintainer's
|
||||
attacker_envelope = sign_action(
|
||||
ATTACKER_SEED_HEX,
|
||||
"release",
|
||||
malicious_payload,
|
||||
"did:keri:EATTACKER_unknown_identity",
|
||||
)
|
||||
|
||||
# Verify against the MAINTAINER's public key (the only trusted key)
|
||||
result = verify_action_envelope(attacker_envelope, MAINTAINER_PK_HEX)
|
||||
print(f" Signed by: did:keri:EATTACKER_unknown_identity")
|
||||
print(f" Verification against maintainer key: {'PASSED' if result.valid else 'FAILED'}")
|
||||
if result.error:
|
||||
print(f" Reason: {result.error}")
|
||||
print()
|
||||
|
||||
# ── Step 3: Show tampered legitimate envelope also fails ───────────
|
||||
print("[3] Attacker tampers with a legitimately-signed envelope...")
|
||||
print()
|
||||
|
||||
envelope = json.loads(legitimate_envelope)
|
||||
envelope["payload"]["version"] = "1.82.7"
|
||||
envelope["payload"]["digest"] = "sha256:malicious_payload_hash..."
|
||||
tampered_json = json.dumps(envelope)
|
||||
|
||||
result = verify_action_envelope(tampered_json, MAINTAINER_PK_HEX)
|
||||
print(f" Original signer: {MAINTAINER_DID}")
|
||||
print(f" Tampered payload version: 1.82.7")
|
||||
print(f" Verification: {'PASSED' if result.valid else 'FAILED'}")
|
||||
if result.error:
|
||||
print(f" Reason: {result.error}")
|
||||
print()
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────
|
||||
print("-" * 70)
|
||||
print("SUMMARY")
|
||||
print()
|
||||
print(" v1.82.6 (legitimate, signed by maintainer): VERIFIED")
|
||||
print(" v1.82.7 (attacker's key, not trusted): REJECTED")
|
||||
print(" v1.82.7 (tampered legitimate envelope): REJECTED")
|
||||
print()
|
||||
print("NOTE: The real March 24 attack bypassed Git entirely — the attacker")
|
||||
print("published directly to PyPI with no commit at all. This simulation")
|
||||
print("demonstrates the cryptographic primitive that Auths provides: only")
|
||||
print("the holder of the maintainer's private key can produce a valid")
|
||||
print("signature. In a full deployment, the CI/CD pipeline would use")
|
||||
print("'auths artifact sign' to bind the published package to the")
|
||||
print("maintainer's identity, and consumers would verify before installing.")
|
||||
print()
|
||||
print("Learn more: https://github.com/auths-dev/auths")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue