Guide 117 worked guide

How to assess symlink risk when processing repositories

Assess repository symlink risks by separating stored links from admitted source, documenting exclusions, and preserving the boundaries of source-only processing.

Treat a repository path as untrusted until the code that opens it has proved what object it will open. A symbolic link in a checkout can refer to a file outside the repository, and a link can appear in an intermediate directory rather than the final filename. For a source processor that has no reason to follow links, the clearest rule is: identify link entries without following them, record them as skipped, and open only regular files through a path-resolution method that enforces the repository boundary at the moment of access. If your product intentionally supports links, the review must specify which targets are allowed and prove confinement at every open.

This is a read-boundary question, not merely a question of whether the link itself is suspicious. CWE-59 describes link following as a route to unintended file access; CWE-61 covers the Unix symlink form. A checkout inside a container does not automatically settle the issue. The relevant facts are which paths and secrets are mounted into that process, which identity it runs as, and how it resolves names.

Walk one invented source-processing job

Illustrative repository and paths. No customer source was used. Elm Docs at revision ED-18 contains ordinary Markdown files under docs/ and one link entry named docs/shared-guide.md. The link points to another path in the same repository. That may be a legitimate convenience for a human developer. A security scanner, however, promises to analyze files in a bounded checkout and records its coverage by file path.

The platform engineer finds this worker sequence:

  1. Clone the repository into a task workspace.
  2. Recursively list docs/ and record every apparent .md path.
  3. Use a general file-read operation to load those paths into an index.
  4. Export the indexed text to a later analysis step.

The listing and the read may resolve links differently. A link could also be changed between those operations if an attacker or concurrent process can write the workspace. The worker therefore cannot infer from the list entry alone that the later read stays in docs/, or even in the checkout. The precise outcome depends on checkout permissions, mounts, the file API and the worker's timing. No exfiltration is established by this example.

Use a path ledger rather than a single “symlinks allowed” flag:

Stage Object being trusted Relevant question Evidence and owner
Repository admission Remote ref and selected revision Is the input pinned to an immutable revision? Job record; platform owner
Checkout Files, links, submodules and worktree layout Are hooks or dependency scripts disabled? What is mounted inside or beside the checkout? Acquisition configuration; platform owner
Enumeration Directory entries Does walking follow directory links, including hidden or nested ones? Walker options and manual file-type review; scanner owner
Open Full path to a file Are all path components constrained at the actual open? File API and OS behavior; scanner owner
Processing Bytes passed to parsers or models Could external bytes be misattributed to the repository? Input manifest and file identity; analysis owner
Reporting Coverage and evidence path Are link entries labeled skipped instead of silently counted as reviewed? Coverage ledger; AppSec owner

The first two rows matter because a safe open operation can still be undermined by a broader runtime boundary. The last row matters because skipping links is a coverage decision. If a link is the only path to code actually shipped, an analyst must obtain the intended source through a separately authorized, bounded method; do not quietly follow the link and claim ordinary repository coverage.

Check how paths are opened

Start by inventorying every consumer of repository paths: content indexing, syntax parsing, secret scanning, archive creation, diff generation, search previews and report evidence retrieval. One safe primary scanner read does not make a later preview safe. Follow paths from metadata to the exact read call. Look for library defaults that recurse through symlinked directories, and for code that joins a checkout root to an untrusted relative filename before calling a normal open.

For each path, record the planned rule. A no-follow policy should reject a link in any component, including a directory on the way to an apparently regular file. The Linux kernel's pathname lookup documentation distinguishes restrictions on all components from checks that only affect the last component. On Linux, openat2 provides resolution restrictions such as RESOLVE_NO_SYMLINKS and RESOLVE_BENEATH; its manual page explains their scope and errors. Those are examples of OS mechanisms, not a portable implementation recipe. Choose the equivalent secure primitive for the actual OS and runtime, then inspect its real behavior.

A separate “is this a symlink?” check before a normal open is not sufficient when another actor can modify the workspace between check and use. This is a time-of-check/time-of-use problem. Check whether the workspace is writable during processing, by whom, and whether path resolution and opening are bound into one operation. For stronger confinement, use a directory handle and descriptor-relative access where supported, with an explicit no-follow and beneath-root policy. Also limit the worker's filesystem permissions: if it does not need host credentials, those credentials should not be mounted into its environment.

A beneath-root path rule does not by itself prevent a read through a bind mount placed inside the checkout. This is a mount-layout question even when no symlink exists. Review the worker's active mount namespace and who can create or change mounts under its source root. On Linux, openat2 documents RESOLVE_NO_XDEV as a way to reject mount-point crossings, including bind mounts. Whether that flag is suitable depends on the intended checkout layout; legitimate mounted directories would also be rejected. If those mounts are required, constrain what is mounted and what the worker identity can read, then verify the actual runtime behavior. Do not infer filesystem confinement from no-follow and beneath-root flags alone.

An engineer reviewing Elm Docs might make this precise decision: the processor will index docs/a.md as a regular file; it will note docs/shared-guide.md as a link and skip its target; and it will never promote that skipped entry to “reviewed.” A legitimate project that depends on linked source gets an incomplete-coverage report and a separate intake decision. That answer is better than hiding the omission or assuming the target is harmless because this particular link currently stays inside the repository.

Reconcile the reader manifest with the coverage manifest

Elm's revised policy skips every symlink, even one currently pointing inside the checkout. Its synthetic intake contains five path entries:

Entry Type/policy result Coverage record
docs/a.md Regular file, bounded read accepted Bytes available for analysis; not yet evidence of substantive review
docs/shared-guide.md Symlink to an in-tree file Skipped: link policy
docs/linked-section Directory symlink Skipped: directory link; children not discovered through it
docs/large.md Regular file exceeds input limit Skipped: size policy
docs/b.md Regular file, bounded read accepted Bytes available for analysis

The arithmetic is five enumerated entries, two content reads and three policy exclusions. The directory-link target may contain an unknown number of files; do not count those unseen children as three, zero or successfully scanned. The file-read manifest and substantive-review record are separate artifacts. A file being read does not mean a reviewer examined its security behavior.

This distinction matters when linked code is part of the deployed application. Safe skipping can produce incomplete scope. Obtain that source through a separate authorized bounded intake if it matters; do not change the no-follow rule silently to improve the headline coverage percentage.

Reopen evidence through the same boundary

The first indexing pass uses a constrained open. Later, the report service receives an evidence request for docs/shared-guide.md and uses an ordinary read to display surrounding lines. That second reader bypasses the policy even though the original scan was safe. The review must inventory evidence previews, search snippets, diff readers and exports alongside the main parser.

An illustrative reader contract is:

input: admitted workspace identity + relative path + bounded byte range
open: enforce root and no-link policy during actual resolution
inspect opened object: require regular file and permitted size
opening policy: must not block on a FIFO or activate a special device
read: bounded bytes from that descriptor
record: workspace/revision, relative path, content identity, outcome
error: source-free reason; no fallback ordinary read

This is not drop-in filesystem code. On Linux, openat2 documents flags affecting all path components and mount crossings. The exact flags, supported kernel, filesystem and error policy must be qualified together. Requiring a regular file only after a blocking ordinary open is too late for special files such as FIFOs; see the Linux open semantics. Reject non-regular intake entries and use a platform-reviewed opening/type-check sequence that cannot hang or cause device effects before the check. If the intended primitive is unavailable, an unrestricted fallback invalidates the claimed boundary. Mark the job unsupported or use another reviewed implementation.

Constrained opens also do not freeze file contents. If a permitted writer can modify a regular file between indexing and later evidence retrieval, the same path can contain different bytes. Keep the acquired workspace immutable to untrusted writers and verify content identity when reusing evidence. A digest mismatch should invalidate that citation or trigger a new bounded read/review; it should not attach old line conclusions to new text.

The completed source-only decision is therefore twofold: the new reader contract can rebut link following once its implementation/runtime are established, and the three excluded entries remain excluded from content coverage. No target was read or synthetic filesystem exercise performed here. The next owner supplies both the actual reader call path and runtime mount/write permissions.

Validate without reading private targets

Use a disposable, authorized environment with synthetic files. Include an ordinary file, a link inside the example tree, a link that would leave the permitted tree, and a link in an intermediate directory. The validation goal is to observe deny or skip decisions and coverage records, not to retrieve protected content. Confirm that the same policy applies to preview and re-read features, and that error messages do not print target contents. Review the worker's selected filesystem permissions and mounts alongside the observed behavior. A scanner process must not run the repository's code, hooks or dependencies to perform this check.

If a new revision fixes path handling, compare the two pinned revisions and repeat the bounded observation against the selected runtime. A green result from one operating system or file API does not establish the same result for every platform. If the processor cannot distinguish a link reliably, fail closed on that entry and report uncertainty instead of counting it as scanned.

Keep exclusions actionable for the source owner

Elm's skipped link should identify the path, the no-follow reason, and the affected review question without reading its target. If the omitted source matters to the assessment, ask the owner for a separately admitted ordinary-file snapshot or another bounded representation. The request should preserve revision identity and ownership; it should not instruct the worker to relax its reader policy temporarily.

This separates two legitimate goals: enforcing a safe intake boundary and obtaining enough source to answer the security question. A truthful partial report can accomplish the first while naming the second as unfinished work. Silently counting the link as reviewed would accomplish neither.

For the next repository intake, list every file reader and fill the path ledger. Use how to review a background job that processes hostile files for the worker's broader resource and cleanup boundaries, and how to verify a fix in a new commit when path handling changes.