Start with a directory and a question
A deployment directory contains configuration, generated files, and a few surprises. Which files should you inspect, and which should you leave alone? Find answers that question by walking a directory tree, evaluating an expression for each entry, and performing the actions that expression reaches. It searches names and metadata; it does not search file contents.
Here we use GNU find, tested with findutils 4.9.0 on Linux, from a POSIX-style shell. Find is a standalone utility, not a shell built-in. The basic model applies broadly; GNU-specific features are identified where they enter the examples. Use an empty disposable working directory and stay in it throughout. The following commands create only a small lab subtree.
Mkdir creates directories; its -p option also creates missing parent directories and accepts directories that already exist. Printf writes its quoted text, interpreting \n as a newline. The shell redirects that text into the path after >, creating the file or replacing its contents. Quotes keep the space in the report filename inside one argument.
mkdir -p lab/conf lab/cache/bad.log lab/notes
printf 'listen=127.0.0.1\ndebug=false\n' > lab/conf/app.conf
printf 'listen=0.0.0.0\ndebug=true\n' > lab/conf/debug.conf
printf 'INFO ready\n' > 'lab/notes/release report.log'
printf 'INFO old\n' > lab/cache/rotated.log
printf 'Remember to check permissions.\n' > lab/notes/readme.txtExpected result: no terminal output. There are five regular files and a directory named bad.log. That directory is deliberate: a name is not a file type.
Select configuration files
Our simplest useful search starts at lab and selects names ending in .conf. The -name test matches the final component of a pathname, not its parent directories. Its argument is a shell-style wildcard pattern: * means any sequence of characters. Single quotes prevent the shell from expanding that wildcard before find receives it. The -print action writes each selected pathname followed by a newline.
The starting directory itself is examined too. Entries are not guaranteed to appear in alphabetical order. The examples below describe the exact matching set; your traversal order may differ. GNU find also matches hidden names with this wildcard syntax. Name matching is different from the regular expressions used in our grep article.
find lab -name '*.conf' -printlab/conf/app.conf
lab/conf/debug.confExpected output, in either order: lab/conf/app.conf and lab/conf/debug.conf. Nothing inside a file was read.
A find expression is a small program
Add -type f to require a regular file. Here f means a regular file; d would mean a directory, and l a symbolic link. Adjacent tests are joined by an implicit AND. Find reaches -print only when both earlier tests succeed.
This is the first useful mental shift: tests return true or false, and actions can both do work and return a truth value. An AND branch stops at its first false result. An OR branch stops at its first true result. That short-circuit behavior determines whether later actions run. GNU documents these rules in Expression operators.
find lab -type f -name '*.log' -printlab/notes/release report.log
lab/cache/rotated.logExpected output, in either order: lab/notes/release report.log and lab/cache/rotated.log. The directory lab/cache/bad.log is excluded.
Why an OR can silently drop half the results
Suppose we want regular configuration files OR regular log files. The -o operator means OR, but implicit AND binds more tightly than OR. A tempting expression below is wrong: its -print belongs only to the right branch. A .conf file makes the left branch true, so find skips the right branch—including printing. A directory ending in .log can pass the right branch because that branch has no type test.
The second command groups the two name tests before applying the print action. Backslashes protect parentheses from the shell so find receives them as grouping operators. Now the regular-file requirement applies to both names. An explicit action matters here: find does not add its usual implicit print when the expression already contains -print.
find lab -type f -name '*.conf' -o -name '*.log' -print
find lab -type f \( -name '*.conf' -o -name '*.log' \) -printFirst command, order may vary:
lab/cache/bad.log
lab/cache/rotated.log
lab/notes/release report.log
Second command, order may vary:
lab/conf/app.conf
lab/conf/debug.conf
lab/cache/rotated.log
lab/notes/release report.logFirst command: the two log files AND the bad.log directory, but neither configuration file. Second command: both configuration files and both regular log files; no bad.log directory.
Skip a subtree rather than filter it afterward
Generated files in cache are outside our inspection scope. The -path test matches the pathname as find sees it, including the starting point. The -prune action stops descent when the current entry is a directory and returns true. For lab/cache, the left branch succeeds; OR skips the right branch. For other entries, the left branch fails and the regular-file selection runs.
Pruning saves the walk itself. Excluding cache names after traversal still pays to enter that subtree and may encounter its permissions or filesystem errors. The exact path matters: a search starting at ./lab produces ./lab/cache rather than lab/cache. GNU Traversal and pruning documents this distinction between traversal control and selection.
find lab -path 'lab/cache' -prune -o -type f -printlab/conf/app.conf
lab/conf/debug.conf
lab/notes/release report.log
lab/notes/readme.txtExpected output, in any order: the two configuration files, release report.log, and readme.txt. Neither cache entry appears.
Pass files as arguments, not a text list
Now inspect the listening addresses inside the configuration files. Find handles file selection; grep handles their contents. The -exec action invokes the named command directly. Its {} placeholder receives matching pathnames, and the + terminator collects them into batches that fit the system command-line limit. The placeholder must be a separate argument immediately before +.
No intervening shell splits the filenames. A space or newline in a filename remains part of one argument. This is often simpler than printing names into a pipeline and reconstructing them afterward. GNU Batched commands explains the batching behavior; do not assume the entire selection fits into a single invocation.
Grep -F treats listen= as literal text; -n prefixes line numbers, and -H always includes the filename, even if a batch contains just one file. The -- argument ends grep option processing. Our grep deep dive covers that distinction between a literal search and a pattern search.
find lab/conf -type f -name '*.conf' -exec grep -F -n -H -- 'listen=' {} +lab/conf/app.conf:1:listen=127.0.0.1
lab/conf/debug.conf:1:listen=0.0.0.0Expected output, in either order: lab/conf/app.conf:1:listen=127.0.0.1 and lab/conf/debug.conf:1:listen=0.0.0.0.
Use another command as a test
An executed command can decide whether a later action runs. The semicolon form of -exec runs once per matching entry and returns true when that command exits with status zero. The backslash in \; stops the shell from treating the semicolon as its own command separator. Grep -q suppresses normal output and returns success when it finds a match.
The expression below prints the pathname only when grep finds the literal debug=true. This combines a metadata filter with a content test without pretending find knows the content format. Notice the difference from batching: the + form always returns true as an expression, although failed child invocations can make GNU find itself exit nonzero. Do not use + when you need a separate true/false answer for every file. See GNU Single-file commands and Batched commands.
find lab/conf -type f -name '*.conf' -exec grep -F -q -- 'debug=true' {} \; -printlab/conf/debug.confExpected output: lab/conf/debug.conf. The line itself is not printed.
Inspect permissions, not just interesting names
An unexpected writable configuration file is often more useful evidence than a spectacular filename. We deliberately make debug.conf writable by everyone in this lab. Chmod changes permission bits; a+w adds write permission for all three classes: owner, group, and others. It changes this fixture only.
The -perm test examines permission bits. A leading minus requires every specified bit to be present while allowing other bits too. In -0002, octal 0002 selects the other-write bit. Plain -perm 0002 would require an exact mode, which is rarely the question we want. GNU -printf replaces ordinary pathname printing with a format: %m is the octal mode, %p the pathname, and \n a newline. This formatting action is a GNU extension.
This checks a mode bit, not effective authorization. Access control lists, mount behavior, and the process identity can change whether someone can actually write the file. Mode bits are a lead for investigation, not a proof of compromise. See GNU Mode-bit tests.
chmod a+w lab/conf/debug.conf
find lab/conf -type f -perm -0002 -printf '%m %p\n'Expected output: one line ending in lab/conf/debug.conf. Its mode includes other-write; the complete mode varies with the initial umask (file-creation permission mask). With the common umask 022, it is 666 lab/conf/debug.conf.
Find the broken link without following every link
Create one link to our configuration directory and one to a missing target. Ln -s creates a symbolic link: it stores the target pathname rather than making a copy. A relative target is interpreted from the directory containing the link, so conf in lab/current means lab/conf.
By default GNU find does not follow symbolic links during traversal. The first search selects both links themselves. The GNU -xtype test looks through a usable link under that default policy; -xtype l therefore selects the broken link here. A link loop can instead produce a diagnostic and a nonzero status; permission-denied targets are not simply classified as broken. See GNU File types.
Following links is a separate choice. GNU -L, placed before the starting path, follows them and can walk beyond the apparent subtree. It can also visit a target through more than one path. Keep the default when auditing an unfamiliar directory unless following links is part of your intended scope. See GNU Symbolic links.
ln -s conf lab/current
ln -s missing lab/dangling
find lab -type l -print
find lab -xtype l -printFirst search:
lab/current
lab/dangling
Second search:
lab/danglingFirst search: lab/current and lab/dangling, in either order. Second search: only lab/dangling.
Use a deployment marker instead of a fuzzy age
For a deployment investigation, “modified after the marker” can be a better question than “less than a day old.” The -newer test compares a candidate’s modification time with the reference file’s modification time. Equal timestamps do not match. It is not creation time, and it does not prove why a file changed. The comparison is documented in GNU Timestamp comparisons.
The setup below deliberately changes fixture timestamps and creates a marker. Touch -t takes a timestamp in YYYYMMDDhhmm form in the local timezone; here we set three widely separated midnight values. The content is unchanged. Then we ask which configuration file is newer than the marker. A real job would preserve its actual deployment marker rather than manufacture one.
touch -t 202001010000 lab/conf/app.conf
touch -t 202101010000 lab/deploy.marker
touch -t 202201010000 lab/conf/debug.conf
find lab/conf -type f -newer lab/deploy.marker -printlab/conf/debug.confExpected output: lab/conf/debug.conf. The timestamp comparison avoids an ambiguous moving “today” boundary, but filesystem timestamp precision and deliberate timestamp changes still limit what it can establish.
A harmless name becomes dangerous when treated as shell code
The direct -exec commands above give filenames to programs as arguments. Adding a shell changes the trust boundary. GNU’s Filename injection hazards documentation shows why putting {} inside the program text passed to sh -c is dangerous: find substitutes the name, then the shell parses its punctuation as commands. A hostile filename is data until that second interpretation.
If a shell is genuinely needed, keep its program text fixed and pass filenames as positional arguments instead. In this read-only example, sh -c runs the quoted program; the next argument, sh, supplies the shell’s $0 name, and matching files become $1, $2, and so on. Quoted "$@" expands each positional argument separately without splitting its content. In printf’s fixed format, %s substitutes one filename argument, and \n ends its line; printf repeats that format for each remaining argument.
This is a documented unsafe construction, not a claim that a particular find release has a parser vulnerability or that an incident happened in the wild. A shell capable of executing commands is also a reason not to grant find unrestricted privileged execution in a command allowlist. Giving a user permission to run arbitrary -exec programs gives substantially more capability than listing files.
find lab/conf -type f -exec sh -c 'printf "file: %s\n" "$@"' sh {} +file: lab/conf/app.conf
file: lab/conf/debug.confExpected output, in either order: file: lab/conf/app.conf and file: lab/conf/debug.conf. Filenames enter through arguments; they are not inserted into the shell program.
Correct argument handling does not freeze the filesystem
A second hazard remains even after quoting is correct: a file can change between selection and use. GNU’s Pathname race conditions documentation describes an attacker replacing a directory with a link between find’s check and a child command’s pathname lookup. The child may then act on a different object. This is a time-of-check/time-of-use race, a property of the workflow rather than a promise of a specific version’s vulnerability.
GNU -execdir reduces this particular exposure by invoking the command in the directory containing the selected entry and passing names prefixed with ./ instead of resolving a long descendant path from the original working directory. It is useful for operations in trees writable by other users. PATH is the list of directories searched to locate an executable by name. For -execdir, that list must contain only trusted absolute directories: an empty component, a dot, or a relative directory makes GNU find refuse the action. This avoids finding an attacker-supplied executable in the changed working directory.
Neither batching nor safe filename separators is a snapshot. For consequential changes, control the tree’s writers or work from an appropriate isolated copy, and design the invoked program’s own operations carefully. There is no “quote it once and all races disappear” switch. Our examples inspect the disposable lab; none demonstrates an attack against a real host.
Preview deletion with the same traversal policy
The lab’s rotated log can now be removed. This section changes the fixture: the final command deletes the regular .log file under lab/cache. It leaves the directory named bad.log alone. Do the preview first and verify the selected path.
The -depth option visits children before their containing directory. GNU -delete removes the current matching entry and implicitly enables that traversal policy. A preview must include -depth too if you want to inspect the same traversal order. Critically, -prune cannot prevent a descent that has already happened: do not replace -print with -delete in the earlier pruning expression. GNU Deletion and Traversal and pruning both document that interaction.
This example selects a narrow known cache subtree, restricts the type to regular files, and matches the intended suffix before the action. A preview is useful, but it is not a lock: files may change between these two commands.
find lab/cache -depth -type f -name '*.log' -print
find lab/cache -depth -type f -name '*.log' -delete
find lab/cache -type f -name '*.log' -printlab/cache/rotated.logExpected output: the preview prints lab/cache/rotated.log; deletion prints nothing; the final check prints nothing. The cache/bad.log directory still exists.
No matches is not a failed search
Find’s exit status describes whether the traversal and requested operations completed successfully, not whether the selection contained a file. In this final command, -name matches nothing but the search succeeds. The shell stores the immediately preceding command’s status in $?, and printf’s %s placeholder prints that value.
A missing starting path or another traversal error instead makes GNU find return nonzero and normally emit a diagnostic. Do not discard those diagnostics and then report a clean audit. This differs from grep, where status 1 means no match; our grep article explains that distinction.
When you next reach for find, separate three decisions: where to walk, what tests an entry must pass, and what action runs afterward. The surprising cases in this lab all follow from those boundaries: OR controls actions, pruning controls descent, a command can become a test, and printing a pathname never grants exclusive ownership of the file it names.
find lab -name 'not-present' -print
printf 'status=%s\n' "$?"status=0Expected output: status=0, with no pathname line before it.