T09 · Insecure Skill Coding Practices
- Location
scripts/review_skill.py:100- Finding
Symbolic Link Traversal Allows Reads Outside the Reviewed Skill Directory
- Content
View full analysis
Vulnerability Details
File Location:
scripts/review_skill.py:100-104, 115-116, 158-184
Vulnerability Type: Symbolic link traversal and unbounded file read
Risk Level: MediumVulnerable code:
python def iter_files(root: Path) -> Iterable[Path]: for current, dirs, files in os.walk(root): dirs[:] = [name for name in dirs if name not in EXCLUDED_DIRS] for file_name in files: yield Path(current) / file_namepython def read_text(path: Path, max_bytes: int) -> tuple[str | None, str | None]: data = path.read_bytes() if b"\x00" in data: return None, "binary content" if len(data) > max_bytes: return None, f"text file larger than {max_bytes} bytes"python for path in iter_files(root): rel_path = relative(path, root) lower_rel = rel_path.lower() for pattern in SECRET_FILE_PATTERNS: if pattern.search(lower_rel): findings.append(Finding("ERROR", rel_path, None, "sensitive filename", "Remove secret-like files before publishing.")) size = path.stat().st_size if size > 5_000_000: findings.append(Finding("INFO", rel_path, None, "large file", f"File is {size} bytes; confirm it belongs in the skill.")) if not is_text_candidate(path): text, note = read_text(path, max_text_bytes) if text is None: findings.append(Finding("INFO", rel_path, None, "skipped non-text file", note or "Not a text file.")) continue else: text, note = read_text(path, max_text_bytes) if note: findings.append(Finding("INFO", rel_path, None, "text scan note", note)) if text is None: continueTechnical Analysis
The scanner recursively enumerates names beneath the user-supplied Skill directory, but it does not reject symbolic links or resolve each candidate ...[truncated 2523 chars]
- Remediation
View remediation
Remediation Suggestions
-
Reject symbolic links before inspecting metadata or content:
python if path.is_symlink(): findings.append( Finding("WARN", rel_path, None, "symbolic link", "Skipped symbolic link.") ) continue -
Resolve the root once and verify every resolved candidate remains within it:
python resolved_root = root.resolve() resolved_path = path.resolve(strict=True) if not resolved_path.is_relative_to(resolved_root): findings.append( Finding("ERROR", rel_path, None, "path escapes root", "Resolved file is outside the reviewed skill directory.") ) continue -
Perform the containment check immediately before opening the file to reduce time-of-check/time-of-use exposure.
-
Open files without following symlinks where supported, such as by using
os.open()withO_NOFOLLOW, and then read from the returned descriptor. -
Check file size before allocating content, using metadata obtained from the safely opened descriptor.
-
Read at most
max_bytes + 1bytes through a bounded stream rather than callingread_bytes():python with safe_opened_file as handle: data = handle.read(max_bytes + 1) if len(data) > max_bytes: return None, f"text file larger than {max_bytes} bytes" -
Treat filesystem errors, broken links, special files, and files changed during scanning as explicit findings rather than allowing them to terminate or bypass the review.
-
Add regression tests covering file symlinks to external files, directory symlinks, broken symlinks, oversized targets, and symlink replacement races.
-
