Found during a security review of the repo. This is the only security finding in pcapper — noting up front that the rest of the tool held up well under review: no shell=True (all subprocess calls are list-form), no eval/exec/pickle/yaml.load, no XML parsing (so no XXE), no ReDoS-prone patterns, and .gitignore correctly excludes PCAPs, carved output, case directories and pcapper.toml.
I also specifically tried to break the two spots that matter most for a tool writing attacker-controlled data to disk, and both hold up: _safe_output_path (pcapper/files.py:1153) resolves and checks relative_to, correctly rejecting both ../ traversal and absolute filenames — which Path.__truediv__ would otherwise silently honour, since base / "/etc/passwd" discards the base. _safe_decompress (pcapper/files.py:1180) bounds output against decompression bombs, and carving generates its own filenames rather than trusting names off the wire.
The issue: recovered cleartext secrets written world-readable
Redaction is deliberately disabled — reporting._redact_secret and _redact_in_text (pcapper/reporting.py:241,319) are documented no-ops, which is the right call for a credential-recovery tool. The consequence is that reports, exports, the run log, carved artifacts and decrypted streams routinely contain cleartext credentials, LDAP binds, SNMP community strings, session tokens and malware samples.
None of those write paths restrict permissions, so under a default umask they land at 0644 (and case directories at 0755). On a shared analysis host — a jump box, a lab VM, a multi-analyst forensics workstation — every other local account can read recovered passwords out of another analyst's case directory.
Note this is not just an exposure question: on an IR engagement those files are evidence, and world-readable evidence is harder to defend on chain-of-custody grounds.
Affected write paths
| Location |
Writes |
pcapper/utils.py — safe_write_text |
reports, JSON export (via exporting.export_json) |
pcapper/exporting.py:187,200 |
CSV export + _hosts CSV |
pcapper/exporting.py:212 |
SQLite export |
pcapper/cli.py:755 |
run log (--log), which records event fields |
pcapper/cli.py:2246,5071 |
case directories |
pcapper/carving.py:299 + :273 |
carved artifacts and their output dir |
pcapper/files.py:4335 + :4327 |
extracted file artifacts and their output dir |
pcapper/decryption.py:161,215 + :147,203 |
decrypted TLS/SSH streams and their output dir |
Suggested fix
A small best-effort helper in utils.py, called after each write:
def restrict_permissions(path: Path) -> None:
"""Restrict a written artifact to owner-only access (0600, or 0700 for a dir).
pcapper deliberately does not redact recovered secrets (see
reporting._redact_secret), so its outputs routinely contain cleartext
credentials, session tokens and malware. Under a default umask those land
world-readable, exposing them to every other local account on a shared
analysis host.
Best effort by design: POSIX mode bits are largely a no-op on Windows, and
failing to tighten permissions must never abort an analysis run.
"""
try:
path.chmod(0o700 if path.is_dir() else 0o600)
except Exception:
pass
Two implementation notes from prototyping this:
- For the SQLite export, tighten immediately after
sqlite3.connect() and before rows are inserted, so the database is never briefly readable while being populated.
- Directories should be tightened right after
mkdir(parents=True, exist_ok=True), not only at the end of the run.
I had this implemented and compiling cleanly across all modules, but backed it out so the change could be considered on its own rather than landing alongside unrelated fixes. Happy to open a PR if you want it.
A reasonable alternative, if a blanket 0600 is too aggressive for shared-team workflows, is a --umask/--permissive-output flag that defaults to restrictive.
🤖 Generated with Claude Code
https://claude.ai/code/session_015w8bmKfJjNSUgEKUZgJ8tS
Found during a security review of the repo. This is the only security finding in
pcapper— noting up front that the rest of the tool held up well under review: noshell=True(allsubprocesscalls are list-form), noeval/exec/pickle/yaml.load, no XML parsing (so no XXE), no ReDoS-prone patterns, and.gitignorecorrectly excludes PCAPs, carved output, case directories andpcapper.toml.I also specifically tried to break the two spots that matter most for a tool writing attacker-controlled data to disk, and both hold up:
_safe_output_path(pcapper/files.py:1153) resolves and checksrelative_to, correctly rejecting both../traversal and absolute filenames — whichPath.__truediv__would otherwise silently honour, sincebase / "/etc/passwd"discards the base._safe_decompress(pcapper/files.py:1180) bounds output against decompression bombs, and carving generates its own filenames rather than trusting names off the wire.The issue: recovered cleartext secrets written world-readable
Redaction is deliberately disabled —
reporting._redact_secretand_redact_in_text(pcapper/reporting.py:241,319) are documented no-ops, which is the right call for a credential-recovery tool. The consequence is that reports, exports, the run log, carved artifacts and decrypted streams routinely contain cleartext credentials, LDAP binds, SNMP community strings, session tokens and malware samples.None of those write paths restrict permissions, so under a default umask they land at
0644(and case directories at0755). On a shared analysis host — a jump box, a lab VM, a multi-analyst forensics workstation — every other local account can read recovered passwords out of another analyst's case directory.Note this is not just an exposure question: on an IR engagement those files are evidence, and world-readable evidence is harder to defend on chain-of-custody grounds.
Affected write paths
pcapper/utils.py—safe_write_textexporting.export_json)pcapper/exporting.py:187,200_hostsCSVpcapper/exporting.py:212pcapper/cli.py:755--log), which records event fieldspcapper/cli.py:2246,5071pcapper/carving.py:299+:273pcapper/files.py:4335+:4327pcapper/decryption.py:161,215+:147,203Suggested fix
A small best-effort helper in
utils.py, called after each write:Two implementation notes from prototyping this:
sqlite3.connect()and before rows are inserted, so the database is never briefly readable while being populated.mkdir(parents=True, exist_ok=True), not only at the end of the run.I had this implemented and compiling cleanly across all modules, but backed it out so the change could be considered on its own rather than landing alongside unrelated fixes. Happy to open a PR if you want it.
A reasonable alternative, if a blanket
0600is too aggressive for shared-team workflows, is a--umask/--permissive-outputflag that defaults to restrictive.🤖 Generated with Claude Code
https://claude.ai/code/session_015w8bmKfJjNSUgEKUZgJ8tS