From e0f4a467d3cfc8e09ad498f26c1fefdcb6030e25 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 21 Jul 2026 14:31:09 +0200 Subject: [PATCH 1/6] Add SC2337: warn about grep -q in pipelines under pipefail When `set -o pipefail` is active and `grep -q` (or `--quiet`) appears as a non-first command in a pipeline, the upstream command may receive SIGPIPE because grep -q exits immediately upon finding a match. This causes the pipeline to report a non-zero exit status under pipefail. The check covers grep, egrep, and fgrep variants. It does not warn when grep -q reads from a file directly or is the first command in the pipeline. Co-Authored-By: Claude Opus 4.6 --- src/ShellCheck/Checks/Commands.hs | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index 68a2c3015..6d270bed5 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -104,6 +104,9 @@ commandChecks = [ ,checkXargsDashi ,checkUnquotedEchoSpaces ,checkEvalArray + ,checkGrepQPipefail + ,checkEgrepQPipefail + ,checkFgrepQPipefail ] ++ map checkArgComparison ("alias" : declaringCommands) ++ map checkMaskedReturns declaringCommands @@ -400,6 +403,40 @@ checkGrepRe = CommandCheck (Basename "grep") check where contra = mkRegex "[^a-zA-Z1-9]\\*|[][^$+\\\\]" +prop_checkGrepQPipefail1 = verify checkGrepQPipefail "set -o pipefail; cat file | grep -q pattern" +prop_checkGrepQPipefail2 = verify checkGrepQPipefail "set -o pipefail; cat file | grep --quiet pattern" +prop_checkGrepQPipefail3 = verify checkGrepQPipefail "set -o pipefail; cat file | grep -iq pattern" +prop_checkGrepQPipefail4 = verify checkGrepQPipefail "set -o pipefail; cmd1 | cmd2 | grep -q pattern" +prop_checkGrepQPipefail5 = verify checkGrepQPipefail "set -euo pipefail; cmd | grep -q foo" +prop_checkGrepQPipefailN1 = verifyNot checkGrepQPipefail "cat file | grep -q pattern" +prop_checkGrepQPipefailN2 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern file" +prop_checkGrepQPipefailN3 = verifyNot checkGrepQPipefail "set -o pipefail; cat file | grep pattern" +prop_checkGrepQPipefailN4 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern | cat" +prop_checkGrepQPipefailN5 = verifyNot checkGrepQPipefail "grep -q pattern file" +prop_checkGrepQPipefailN6 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | bash -c 'grep -q pattern file'" +checkGrepQPipefail = CommandCheck (Basename "grep") checkQuietGrepInPipefailImpl + +prop_checkEgrepQPipefail1 = verify checkEgrepQPipefail "set -o pipefail; cat file | egrep -q pattern" +checkEgrepQPipefail = CommandCheck (Basename "egrep") checkQuietGrepInPipefailImpl + +prop_checkFgrepQPipefail1 = verify checkFgrepQPipefail "set -o pipefail; cat file | fgrep -q pattern" +checkFgrepQPipefail = CommandCheck (Basename "fgrep") checkQuietGrepInPipefailImpl + +checkQuietGrepInPipefailImpl cmd = do + pipefail <- asks hasPipefail + astPath <- getPathM cmd + sequence_ $ do + guard pipefail + guard $ hasFlag cmd "q" || hasFlag cmd "quiet" + _simpleCmd:grepRedirectingCmd:parentNodes <- Just $ NE.toList astPath + T_Pipeline _ _ (_first:redirectingCmds) <- listToMaybe parentNodes + guard $ any (\node -> getId node == getId grepRedirectingCmd) redirectingCmds + return $ warn (getId cmd) 2337 warnMsg + where + warnMsg = + "In pipefail mode, grep -q may cause the pipeline to fail. Use a non-pipe input like '< <(cmd)', '<<<' or 'grep pattern > /dev/null' instead." + + prop_checkTrapQuotes1 = verify checkTrapQuotes "trap \"echo $num\" INT" prop_checkTrapQuotes1a = verify checkTrapQuotes "trap \"echo `ls`\" INT" prop_checkTrapQuotes2 = verifyNot checkTrapQuotes "trap 'echo $num' INT" From e9bd7ce0276907303b75df08a857c86cbf784015 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Wed, 22 Jul 2026 18:10:27 +0200 Subject: [PATCH 2/6] Use getOpts for more robust argument parsing --- src/ShellCheck/Checks/Commands.hs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index 6d270bed5..4b55d8542 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -414,6 +414,11 @@ prop_checkGrepQPipefailN3 = verifyNot checkGrepQPipefail "set -o pipefail; cat f prop_checkGrepQPipefailN4 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern | cat" prop_checkGrepQPipefailN5 = verifyNot checkGrepQPipefail "grep -q pattern file" prop_checkGrepQPipefailN6 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | bash -c 'grep -q pattern file'" +prop_checkGrepQPipefailN7 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -e -q" +prop_checkGrepQPipefailN8 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -eq pattern" +prop_checkGrepQPipefailN9 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep --regexp -q" +prop_checkGrepQPipefailN10 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -- -q" + checkGrepQPipefail = CommandCheck (Basename "grep") checkQuietGrepInPipefailImpl prop_checkEgrepQPipefail1 = verify checkEgrepQPipefail "set -o pipefail; cat file | egrep -q pattern" @@ -427,12 +432,19 @@ checkQuietGrepInPipefailImpl cmd = do astPath <- getPathM cmd sequence_ $ do guard pipefail - guard $ hasFlag cmd "q" || hasFlag cmd "quiet" + opts <- parseGrepOpts $ arguments cmd + guard $ any (\(flag, _) -> flag == "q" || flag == "quiet") opts _simpleCmd:grepRedirectingCmd:parentNodes <- Just $ NE.toList astPath T_Pipeline _ _ (_first:redirectingCmds) <- listToMaybe parentNodes guard $ any (\node -> getId node == getId grepRedirectingCmd) redirectingCmds return $ warn (getId cmd) 2337 warnMsg where + parseGrepOpts = getOpts (True, True) + "cilLnoqsvwxhHrRbaEFGPe:f:m:A:B:C:d:D:" + (map (\name -> (name, True)) longOptionsConsumingParameter) + longOptionsConsumingParameter = + ["regexp", "file", "max-count", "after-context", "before-context", + "context", "directories", "devices"] warnMsg = "In pipefail mode, grep -q may cause the pipeline to fail. Use a non-pipe input like '< <(cmd)', '<<<' or 'grep pattern > /dev/null' instead." From fadb5898deb20a1c6f1cbe44c9ab8eb508964712 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Fri, 24 Jul 2026 09:04:47 +0200 Subject: [PATCH 3/6] Also detect 'grep -m n' and 'grep -L' --- src/ShellCheck/Checks/Commands.hs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index 4b55d8542..8715d86b1 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -408,6 +408,9 @@ prop_checkGrepQPipefail2 = verify checkGrepQPipefail "set -o pipefail; cat file prop_checkGrepQPipefail3 = verify checkGrepQPipefail "set -o pipefail; cat file | grep -iq pattern" prop_checkGrepQPipefail4 = verify checkGrepQPipefail "set -o pipefail; cmd1 | cmd2 | grep -q pattern" prop_checkGrepQPipefail5 = verify checkGrepQPipefail "set -euo pipefail; cmd | grep -q foo" +prop_checkGrepQPipefail6 = verify checkGrepQPipefail "set -o pipefail; cmd | grep -m 2 foo | cmd2" +prop_checkGrepQPipefail7 = verify checkGrepQPipefail "set -o pipefail; cmd | grep -L foo | cmd2" + prop_checkGrepQPipefailN1 = verifyNot checkGrepQPipefail "cat file | grep -q pattern" prop_checkGrepQPipefailN2 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern file" prop_checkGrepQPipefailN3 = verifyNot checkGrepQPipefail "set -o pipefail; cat file | grep pattern" @@ -427,18 +430,25 @@ checkEgrepQPipefail = CommandCheck (Basename "egrep") checkQuietGrepInPipefailIm prop_checkFgrepQPipefail1 = verify checkFgrepQPipefail "set -o pipefail; cat file | fgrep -q pattern" checkFgrepQPipefail = CommandCheck (Basename "fgrep") checkQuietGrepInPipefailImpl +-- Catches occurrences of "grep -q" and variants inside of pipes under pipefail. checkQuietGrepInPipefailImpl cmd = do pipefail <- asks hasPipefail astPath <- getPathM cmd sequence_ $ do guard pipefail - opts <- parseGrepOpts $ arguments cmd - guard $ any (\(flag, _) -> flag == "q" || flag == "quiet") opts + opts <- map fst <$> parseGrepOpts (arguments cmd) + guard $ any isEarlyExitFlag opts _simpleCmd:grepRedirectingCmd:parentNodes <- Just $ NE.toList astPath T_Pipeline _ _ (_first:redirectingCmds) <- listToMaybe parentNodes guard $ any (\node -> getId node == getId grepRedirectingCmd) redirectingCmds return $ warn (getId cmd) 2337 warnMsg where + -- Contains "L", even though BSD grep does not exit early with this flag, + -- but GNU grep does. This is consistent with the linter practice of warning + -- about potential problems, while also allowing users to disable specific + -- linter checks locally. + earlyExitFlags = ["q", "quiet", "m", "max-count", "L"] + isEarlyExitFlag name = name `elem` earlyExitFlags parseGrepOpts = getOpts (True, True) "cilLnoqsvwxhHrRbaEFGPe:f:m:A:B:C:d:D:" (map (\name -> (name, True)) longOptionsConsumingParameter) From 89835a061a34772ddb562cc751b81d6da2b33276 Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Mon, 27 Jul 2026 10:13:22 +0200 Subject: [PATCH 4/6] Updated warning message --- src/ShellCheck/Checks/Commands.hs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index 8715d86b1..eee40d480 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -455,8 +455,11 @@ checkQuietGrepInPipefailImpl cmd = do longOptionsConsumingParameter = ["regexp", "file", "max-count", "after-context", "before-context", "context", "directories", "devices"] - warnMsg = - "In pipefail mode, grep -q may cause the pipeline to fail. Use a non-pipe input like '< <(cmd)', '<<<' or 'grep pattern > /dev/null' instead." + warnMsg = unwords $ + [ + "In pipefail mode, flags like -q, -m, or -L can cause grep to exit early, aborting the pipeline with SIGPIPE.", + "Use a non-pipe input like '< <(cmd)' or '<<<' instead." + ] prop_checkTrapQuotes1 = verify checkTrapQuotes "trap \"echo $num\" INT" From e6bd9e9b5eb2bfd57be71059eca2f1e37147969c Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Jul 2026 12:43:03 +0200 Subject: [PATCH 5/6] Big rename. --- src/ShellCheck/Checks/Commands.hs | 54 +++++++++++++++---------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index eee40d480..f48e132f9 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -104,9 +104,9 @@ commandChecks = [ ,checkXargsDashi ,checkUnquotedEchoSpaces ,checkEvalArray - ,checkGrepQPipefail - ,checkEgrepQPipefail - ,checkFgrepQPipefail + ,checkGrepSendsPipefail + ,checkEgrepSendsPipefail + ,checkFgrepSendsPipefail ] ++ map checkArgComparison ("alias" : declaringCommands) ++ map checkMaskedReturns declaringCommands @@ -403,35 +403,35 @@ checkGrepRe = CommandCheck (Basename "grep") check where contra = mkRegex "[^a-zA-Z1-9]\\*|[][^$+\\\\]" -prop_checkGrepQPipefail1 = verify checkGrepQPipefail "set -o pipefail; cat file | grep -q pattern" -prop_checkGrepQPipefail2 = verify checkGrepQPipefail "set -o pipefail; cat file | grep --quiet pattern" -prop_checkGrepQPipefail3 = verify checkGrepQPipefail "set -o pipefail; cat file | grep -iq pattern" -prop_checkGrepQPipefail4 = verify checkGrepQPipefail "set -o pipefail; cmd1 | cmd2 | grep -q pattern" -prop_checkGrepQPipefail5 = verify checkGrepQPipefail "set -euo pipefail; cmd | grep -q foo" -prop_checkGrepQPipefail6 = verify checkGrepQPipefail "set -o pipefail; cmd | grep -m 2 foo | cmd2" -prop_checkGrepQPipefail7 = verify checkGrepQPipefail "set -o pipefail; cmd | grep -L foo | cmd2" +prop_checkGrepSendsPipefail1 = verify checkGrepSendsPipefail "set -o pipefail; cat file | grep -q pattern" +prop_checkGrepSendsPipefail2 = verify checkGrepSendsPipefail "set -o pipefail; cat file | grep --quiet pattern" +prop_checkGrepSendsPipefail3 = verify checkGrepSendsPipefail "set -o pipefail; cat file | grep -iq pattern" +prop_checkGrepSendsPipefail4 = verify checkGrepSendsPipefail "set -o pipefail; cmd1 | cmd2 | grep -q pattern" +prop_checkGrepSendsPipefail5 = verify checkGrepSendsPipefail "set -euo pipefail; cmd | grep -q foo" +prop_checkGrepSendsPipefail6 = verify checkGrepSendsPipefail "set -o pipefail; cmd | grep -m 2 foo | cmd2" +prop_checkGrepSendsPipefail7 = verify checkGrepSendsPipefail "set -o pipefail; cmd | grep -L foo | cmd2" -prop_checkGrepQPipefailN1 = verifyNot checkGrepQPipefail "cat file | grep -q pattern" -prop_checkGrepQPipefailN2 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern file" -prop_checkGrepQPipefailN3 = verifyNot checkGrepQPipefail "set -o pipefail; cat file | grep pattern" -prop_checkGrepQPipefailN4 = verifyNot checkGrepQPipefail "set -o pipefail; grep -q pattern | cat" -prop_checkGrepQPipefailN5 = verifyNot checkGrepQPipefail "grep -q pattern file" -prop_checkGrepQPipefailN6 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | bash -c 'grep -q pattern file'" -prop_checkGrepQPipefailN7 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -e -q" -prop_checkGrepQPipefailN8 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -eq pattern" -prop_checkGrepQPipefailN9 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep --regexp -q" -prop_checkGrepQPipefailN10 = verifyNot checkGrepQPipefail "set -o pipefail; cmd1 | grep -- -q" +prop_checkGrepSendsPipefailN1 = verifyNot checkGrepSendsPipefail "cat file | grep -q pattern" +prop_checkGrepSendsPipefailN2 = verifyNot checkGrepSendsPipefail "set -o pipefail; grep -q pattern file" +prop_checkGrepSendsPipefailN3 = verifyNot checkGrepSendsPipefail "set -o pipefail; cat file | grep pattern" +prop_checkGrepSendsPipefailN4 = verifyNot checkGrepSendsPipefail "set -o pipefail; grep -q pattern | cat" +prop_checkGrepSendsPipefailN5 = verifyNot checkGrepSendsPipefail "grep -q pattern file" +prop_checkGrepSendsPipefailN6 = verifyNot checkGrepSendsPipefail "set -o pipefail; cmd1 | bash -c 'grep -q pattern file'" +prop_checkGrepSendsPipefailN7 = verifyNot checkGrepSendsPipefail "set -o pipefail; cmd1 | grep -e -q" +prop_checkGrepSendsPipefailN8 = verifyNot checkGrepSendsPipefail "set -o pipefail; cmd1 | grep -eq pattern" +prop_checkGrepSendsPipefailN9 = verifyNot checkGrepSendsPipefail "set -o pipefail; cmd1 | grep --regexp -q" +prop_checkGrepSendsPipefailN10 = verifyNot checkGrepSendsPipefail "set -o pipefail; cmd1 | grep -- -q" -checkGrepQPipefail = CommandCheck (Basename "grep") checkQuietGrepInPipefailImpl +checkGrepSendsPipefail = CommandCheck (Basename "grep") checkGrepSendsPipefailImpl -prop_checkEgrepQPipefail1 = verify checkEgrepQPipefail "set -o pipefail; cat file | egrep -q pattern" -checkEgrepQPipefail = CommandCheck (Basename "egrep") checkQuietGrepInPipefailImpl +prop_checkEgrepSendsPipefail1 = verify checkEgrepSendsPipefail "set -o pipefail; cat file | egrep -q pattern" +checkEgrepSendsPipefail = CommandCheck (Basename "egrep") checkGrepSendsPipefailImpl -prop_checkFgrepQPipefail1 = verify checkFgrepQPipefail "set -o pipefail; cat file | fgrep -q pattern" -checkFgrepQPipefail = CommandCheck (Basename "fgrep") checkQuietGrepInPipefailImpl +prop_checkFgrepSendsPipefail1 = verify checkFgrepSendsPipefail "set -o pipefail; cat file | fgrep -q pattern" +checkFgrepSendsPipefail = CommandCheck (Basename "fgrep") checkGrepSendsPipefailImpl --- Catches occurrences of "grep -q" and variants inside of pipes under pipefail. -checkQuietGrepInPipefailImpl cmd = do +-- Catches occurrences of "grep -q" and variants, such as "-m" or "-L", inside of pipes under pipefail. +checkGrepSendsPipefailImpl cmd = do pipefail <- asks hasPipefail astPath <- getPathM cmd sequence_ $ do From 5ec600499f70fef5a862f3d3e6cb5ec0acbede1d Mon Sep 17 00:00:00 2001 From: Moritz Clasmeier Date: Tue, 28 Jul 2026 12:44:44 +0200 Subject: [PATCH 6/6] Don't forget --silent from GNU grep --- src/ShellCheck/Checks/Commands.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ShellCheck/Checks/Commands.hs b/src/ShellCheck/Checks/Commands.hs index f48e132f9..eb3a97029 100644 --- a/src/ShellCheck/Checks/Commands.hs +++ b/src/ShellCheck/Checks/Commands.hs @@ -447,7 +447,7 @@ checkGrepSendsPipefailImpl cmd = do -- but GNU grep does. This is consistent with the linter practice of warning -- about potential problems, while also allowing users to disable specific -- linter checks locally. - earlyExitFlags = ["q", "quiet", "m", "max-count", "L"] + earlyExitFlags = ["q", "quiet", "--silent", "m", "max-count", "L"] isEarlyExitFlag name = name `elem` earlyExitFlags parseGrepOpts = getOpts (True, True) "cilLnoqsvwxhHrRbaEFGPe:f:m:A:B:C:d:D:"