Skip to content

TOCTOU race between is_safe_to_scan() and the subsequent file read #31

Description

@kuzivaai

Found while adding test coverage for scan_safety.py in #30. Not introduced there — the pattern predates the module extraction, which only moved it out of report.py.

The gap

scan_safety.is_safe_to_scan(filepath, project_root) validates a path and returns (True, ""). The caller then opens that path again, by name:

safe, reason = is_safe_to_scan(filepath, root_resolved)
if not safe:
    continue
content = fpath.read_text(...)   # second resolution — may not be the file that was checked

Between the check and the read, filepath can be replaced with a symlink pointing outside project_root. The guard validated one inode; the read gets another. That defeats the symlink-escape protection the guard exists to provide.

Affects both callers: scripts/report.py and scripts/sbom.py (4 walkers).

Severity: low

Requires local write access to the tree being scanned, plus timing the swap inside a narrow window. Realistic exposure is a shared CI runner scanning an untrusted repository where an attacker also controls a concurrent process. Worth fixing on a defence-in-depth basis, not urgently.

Suggested fix

Resolve the check-then-use split by opening once and validating the descriptor:

  • os.open(path, os.O_RDONLY | os.O_NOFOLLOW) to refuse a symlink at open time
  • os.fstat(fd) for the size cap, so the size checked is the size read
  • containment verified against the opened descriptor, then read from fd

This makes the guard and the read operate on the same inode rather than the same name. O_NOFOLLOW is POSIX; Windows needs a different approach, so the guard should degrade to current behaviour there rather than fail closed on a platform where it cannot be enforced.

Not to be handled by suppression

CodeQL flags lines 41 and 51 of scan_safety.py (py/path-injection), which is a different, false-positive finding — resolve() and stat() read no file content. Those alerts stay open and undismissed, per project policy. This issue is the real weakness at that site, and wants a code fix rather than any change to scanner configuration.

Verification when fixed

A regression test should reproduce the race deterministically rather than by timing — e.g. patch the guard to swap the file between check and read, and assert the out-of-root content never reaches the findings or the BOM. tests/test_scan_safety.py already covers the non-racing symlink-escape cases and is the natural home.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions