Skip to content

Commit 4752fac

Browse files
committed
security: reject symlink and path escape under --root (Sat Oct 3, 2026 ET)
1 parent 3e48f9d commit 4752fac

3 files changed

Lines changed: 45 additions & 19 deletions

File tree

‎LinuxPersistenceAuditor/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ LinuxPersistenceAuditor/
104104
├── linux_persistence_auditor/
105105
│ ├── collectors/ # cron, systemd, timers
106106
│ ├── heuristics.py
107+
│ ├── pathsafe.py # --root symlink / path-escape checks
107108
│ ├── report.py
108109
│ ├── cli.py
109110
│ └── __main__.py
@@ -112,3 +113,10 @@ LinuxPersistenceAuditor/
112113
├── requirements.txt
113114
└── README.md
114115
```
116+
117+
118+
---
119+
120+
## Changelog
121+
122+
- **Sat Oct 3, 2026 ET** — Hardened `--root` scans against symlink and path escapes. Candidate paths are resolved lexically under the given root; `..` and absolute paths that leave the root are rejected, and symlink files or directories are skipped instead of followed (reads use `O_NOFOLLOW`). Real files inside the root are still scanned.

‎LinuxPersistenceAuditor/linux_persistence_auditor/collectors/cron.py‎

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,31 @@
22

33
from __future__ import annotations
44

5-
import os
65
from pathlib import Path
7-
from typing import List, Tuple
6+
from typing import List, Optional, Tuple
87

98
from ..models import PersistenceEntry, SoftSkip
9+
from ..pathsafe import iter_real_files, path_status, read_text_nofollow, resolve_under_root
1010

1111

12-
def _under_root(root: str | None, *parts: str) -> Path:
13-
if root:
14-
return Path(root).joinpath(*[p.lstrip("/") for p in parts])
15-
return Path("/").joinpath(*[p.lstrip("/") for p in parts])
12+
def _under_root(root: str | None, *parts: str) -> Optional[Path]:
13+
"""Join parts under root, rejecting ``..`` escapes. None if the path leaves root."""
14+
return resolve_under_root(root, *parts)
1615

1716

18-
def _read_text(path: Path, soft_skips: List[SoftSkip]) -> str | None:
17+
def _read_text(path: Optional[Path], soft_skips: List[SoftSkip], root: str | None) -> str | None:
18+
if path is None:
19+
return None
1920
try:
20-
if not path.exists():
21+
status = path_status(root, path)
22+
if status in {"symlink", "escape"}:
23+
soft_skips.append(
24+
SoftSkip(source=str(path), reason="skipped symlink or path escape")
25+
)
2126
return None
22-
return path.read_text(encoding="utf-8", errors="replace")
27+
if status != "file":
28+
return None
29+
return read_text_nofollow(path)
2330
except OSError as exc:
2431
soft_skips.append(SoftSkip(source=str(path), reason=f"unreadable: {exc}"))
2532
return None
@@ -58,11 +65,21 @@ def _parse_crontab_lines(
5865
)
5966

6067

61-
def _iter_dir_files(directory: Path, soft_skips: List[SoftSkip]) -> List[Path]:
62-
if not directory.exists():
68+
def _iter_dir_files(
69+
directory: Optional[Path], soft_skips: List[SoftSkip], root: str | None
70+
) -> List[Path]:
71+
if directory is None:
6372
return []
6473
try:
65-
return sorted(p for p in directory.iterdir() if p.is_file())
74+
status = path_status(root, directory)
75+
if status in {"symlink", "escape"}:
76+
soft_skips.append(
77+
SoftSkip(source=str(directory), reason="skipped symlink or path escape")
78+
)
79+
return []
80+
if status != "dir":
81+
return []
82+
return iter_real_files(root, directory)
6683
except OSError as exc:
6784
soft_skips.append(SoftSkip(source=str(directory), reason=f"unreadable: {exc}"))
6885
return []
@@ -75,25 +92,25 @@ def collect_cron(root: str | None = None) -> Tuple[List[PersistenceEntry], List[
7592

7693
# /etc/crontab
7794
etc_crontab = _under_root(root, "etc/crontab")
78-
text = _read_text(etc_crontab, soft_skips)
95+
text = _read_text(etc_crontab, soft_skips, root)
7996
if text is not None:
8097
_parse_crontab_lines(etc_crontab, text, entries, kind="etc_crontab")
8198

8299
# /etc/cron.d/*
83-
for path in _iter_dir_files(_under_root(root, "etc/cron.d"), soft_skips):
84-
text = _read_text(path, soft_skips)
100+
for path in _iter_dir_files(_under_root(root, "etc/cron.d"), soft_skips, root):
101+
text = _read_text(path, soft_skips, root)
85102
if text is not None:
86103
_parse_crontab_lines(path, text, entries, kind="cron.d")
87104

88105
# Periodic dirs: daily/hourly/weekly/monthly — list scripts as entries
89106
for period in ("daily", "hourly", "weekly", "monthly"):
90107
period_dir = _under_root(root, f"etc/cron.{period}")
91-
for path in _iter_dir_files(period_dir, soft_skips):
108+
for path in _iter_dir_files(period_dir, soft_skips, root):
92109
# Skip .placeholder / disabled
93110
name = path.name
94111
if name.startswith(".") or name.endswith("~"):
95112
continue
96-
text = _read_text(path, soft_skips)
113+
text = _read_text(path, soft_skips, root)
97114
detail = text.strip().splitlines()[0] if text and text.strip() else name
98115
# Prefer a short summary: first non-shebang/non-comment line if any
99116
body_preview = ""
@@ -115,8 +132,8 @@ def collect_cron(root: str | None = None) -> Tuple[List[PersistenceEntry], List[
115132
# User crontabs
116133
for spool in ("var/spool/cron/crontabs", "var/spool/cron/crons"):
117134
spool_dir = _under_root(root, spool)
118-
for path in _iter_dir_files(spool_dir, soft_skips):
119-
text = _read_text(path, soft_skips)
135+
for path in _iter_dir_files(spool_dir, soft_skips, root):
136+
text = _read_text(path, soft_skips, root)
120137
if text is not None:
121138
_parse_crontab_lines(path, text, entries, kind="user_crontab")
122139

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ Most folders are copies of the original repositories. `FileIntegrityCheckerSHA25
1818
## Changelog
1919

2020
- **Sat Sep 26, 2026 ET** — SharePoint hardening on `main` ([017c0c1](https://github.com/ztel42/PythonCollection/commit/017c0c1ca80fe428f7801ecf12727f8faca1dae7)): path traversal fix in `PythonSharePointLibraryAutoZip`; OData datetime validation in `PythonSharePointAutoPull`. See each folder’s `README.txt`.
21+
- **Sat Oct 3, 2026 ET** — `LinuxPersistenceAuditor`: `--root` scans reject symlink and path escapes (`..` and absolute paths that leave the fixture are not followed or read). See that folder’s README changelog.

0 commit comments

Comments
 (0)