Ordinary grep cannot see inside a ZIP file, so you either unzip the whole thing to look for one line or you reach for zipgrep instead. It narrows a search to selected members, excludes a noisy directory, and reports whether it found a match, all without touching the archive on disk. The examples use zipgrep from the Ubuntu unzip package, version 6.0-28ubuntu4.1, installed here. Allow about ten minutes, given a shell, a readable ZIP archive and text members you can search as lines.
zipgrep is a shell script wrapped around unzip and egrep. It prints matching lines in the same general form as egrep, prefixed with the archive member name, and only ever reads the archive.
A shell script like this one inherits whatever unzip and egrep happen to be installed, so pin down both versions before you trust the option details. These checks are read-only and need no elevated privileges:
$ command -v zipgrep
/usr/bin/zipgrep
$ dpkg-query -W -f='${Package} ${Version}\n' unzip
unzip 6.0-28ubuntu4.1
The local manual describes this shape:
zipgrep [egrep_options] pattern file[.zip] [file(s) ...] [-x xfile(s) ...]
Keep the pattern and archive path as separate arguments. Options before the archive are passed to egrep; the archive member names come afterwards.
Checkpoint: If command -v finds a different copy, read that system's manual before putting the command into a script.
With no member names, zipgrep searches all members in the archive. Quote a fixed pattern so the shell cannot alter it:
$ zipgrep -n 'ERROR:' /path/to/application.zip
logs/app.log:18:ERROR: database unavailable
logs/worker.log:42:ERROR: retry limit reached
The -n option is an egrep option, so the output includes line numbers. The exact member names, line numbers and matching text will come from your archive. If a member is binary or does not contain ordinary text lines, the result may be unhelpful; zipgrep is a line-searching wrapper, not a binary inspection tool.
If the literal path without a suffix is not found, the script tries the same path with .zip appended. For example, zipgrep 'retry' /path/to/application can find /path/to/application.zip. Do not rely on that fallback when a script needs an unambiguous input path.
Pass member names after the archive to avoid scanning everything. The wildcards here are interpreted by unzip against names inside the archive, not by the shell:
$ zipgrep -n -i 'error' /path/to/application.zip 'logs/*.log'
logs/app.log:18:ERROR: database unavailable
logs/worker.log:42:ERROR: retry limit reached
-i makes the pattern case-insensitive. The manual defines * as zero or more characters and ? as exactly one character. Bracket expressions such as [0-9] match one character from the bracketed set. Quote these expressions: without quotes, the shell may expand them against files in your current directory before zipgrep receives them.
Archive member matching is not the same as an ordinary shell glob. The manual says that wildcards can match directory separators, so 'logs/*.log' can include a log below a nested directory. Test the member selection on a small or known archive before using it in a report.
Use -x followed by one or more member patterns after the members you want to search:
$ zipgrep -n -i 'error' /path/to/application.zip '*.log' -x 'logs/private/*'
logs/app.log:18:ERROR: database unavailable
This searches log members but excludes anything below logs/private/. Because the member wildcard can cross directory separators, the exclusion pattern can remove a whole subtree. Quote it for the same reason as the inclusion pattern.
Security boundary: A search can reveal secrets in terminal output, shell history capture or CI logs. Review the archive's contents and choose a safe destination for output before searching. Do not paste credentials or personal data into a shared terminal transcript. No sudo is normally needed. If the archive is unreadable, fix its ownership or access policy deliberately rather than making a broad directory readable.
Capture the status immediately. A successful match returns zero, while no match normally returns one through the underlying grep operation. An unreadable archive or another command failure can produce a different non-zero status, so do not label every failure as "no matches":
if zipgrep -q 'ERROR:' /path/to/application.zip 'logs/*.log'; then
printf '%s\n' 'At least one matching line was found'
else
status=$?
case "$status" in
1) printf '%s\n' 'No matching line was found' ;;
*) printf 'zipgrep failed with status %s\n' "$status" >&2; exit "$status" ;;
esac
fi
The -q option suppresses matching output, which is useful when the status is all you need. If you need the member names but not matching lines, -l lists each member with a match:
$ zipgrep -l 'ERROR:' /path/to/application.zip 'logs/*.log'
logs/app.log
logs/worker.log
Check your local egrep implementation if a script depends on a less common regular-expression feature. A pattern accepted by one grep implementation may not behave identically on another system.
First check the archive member names without searching their contents:
$ unzip -Z1 /path/to/application.zip
logs/app.log
logs/private/audit.log
README.txt
Compare that list character for character with your member pattern. Common distractions are a leading directory such as project/, a different case, or a pattern that was expanded by the shell because it was not quoted.
Then run a deliberately narrow search with a plain string:
$ zipgrep -n 'database unavailable' /path/to/application.zip 'logs/app.log'
logs/app.log:18:ERROR: database unavailable
If that still fails, check the archive path and its read permission:
$ test -r /path/to/application.zip && echo readable
$ unzip -t /path/to/application.zip
unzip -t tests archive integrity but does not repair anything. Keep the original archive untouched while investigating. If you need a different result, change the pattern or member selection and rerun; there is no undo operation because the normal command makes no state change.
zipgrep and unzip versions.-x.unzip -Z1 when a pattern missed.