$)\$\((\w+)\)/\${$1}/g; s/(\$){2}/$1/g; s/^[\s\t]*[@-]{1,2}//; } if ( $cat_string ne "" && (m/^\Q$cat_string\E$/ || ($cat_indented && m/^\t*\Q$cat_string\E$/)) ) { $cat_string = ""; next; } my $within_another_shell = 0; if (m,(^|\s+)((/usr)?/bin/)?((b|d)?a|k|z|t?c)sh\s+-c\s*.+,) { $within_another_shell = 1; } # if cat_string is set, we are in a HERE document and need not # check for things if ($cat_string eq "" and !$within_another_shell) { my $found = 0; my $match = ''; my $explanation = ''; my $line = $_; # Remove "" / '' as they clearly aren't quoted strings # and not considering them makes the matching easier $line =~ s/(^|[^\\])(\'\')+/$1/g; $line =~ s/(^|[^\\])(\"\")+/$1/g; if ($quote_string ne "") { my $otherquote = ($quote_string eq "\"" ? "\'" : "\""); # Inside a quoted block if ($line =~ /(?:^|^.*?[^\\])$quote_string(.*)$/) { my $rest = $1; my $templine = $line; # Remove quoted strings delimited with $otherquote $templine =~ s/(^|[^\\])$otherquote[^$quote_string]*?[^\\]$otherquote/$1/g; # Remove quotes that are themselves quoted # "a'b" $templine =~ s/(^|[^\\])$otherquote.*?$quote_string.*?[^\\]$otherquote/$1/g; # "\"" $templine =~ s/(^|[^\\])$quote_string\\$quote_string$quote_string/$1/g; # After all that, were there still any quotes left? my $count = () = $templine =~ /(^|[^\\])$quote_string/g; next if $count == 0; $count = () = $rest =~ /(^|[^\\])$quote_string/g; if ($count % 2 == 0) { # Quoted block ends on this line # Ignore everything before the closing quote $line = $rest || ''; $quote_string = ""; } else { next; } } else { # Still inside the quoted block, skip this line next; } } # Check even if we removed the end of a quoted block # in the previous check, as a single line can end one # block and begin another if ($quote_string eq "") { # Possible start of a quoted block for my $quote ("\"", "\'") { my $templine = $line; my $otherquote = ($quote eq "\"" ? "\'" : "\""); # Remove balanced quotes and their content while (1) { my ($length_single, $length_double) = (0, 0); # Determine which one would match first: if ($templine =~ m/(^.+?(?:^|[^\\\"](?:\\\\)*)\')[^\']*\'/) { $length_single = length($1); } if ($templine =~ m/(^.*?(?:^|[^\\\'](?:\\\\)*)\")(?:\\.|[^\\\"])+\"/ ) { $length_double = length($1); } # Now simplify accordingly (shorter is preferred): if ( $length_single != 0 && ( $length_single < $length_double || $length_double == 0) ) { $templine =~ s/(^|[^\\\"](?:\\\\)*)\'[^\']*\'/$1/; } elsif ($length_double != 0) { $templine =~ s/(^|[^\\\'](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1/; } else { last; } } # Don't flag quotes that are themselves quoted # "a'b" $templine =~ s/$otherquote.*?$quote.*?$otherquote//g; # "\"" $templine =~ s/(^|[^\\])$quote\\$quote$quote/$1/g; # \' or \" $templine =~ s/\\[\'\"]//g; my $count = () = $templine =~ /(^|(?!\\))$quote/g; # If there's an odd number of non-escaped # quotes in the line it's almost certainly the # start of a quoted block. if ($count % 2 == 1) { $quote_string = $quote; $start_lines{'quote_string'} = $.; $line =~ s/^(.*)$quote.*$/$1/; last; } } } # since this test is ugly, I have to do it by itself # detect source (.) trying to pass args to the command it runs # The first expression weeds out '. "foo bar"' if ( not $found and not m/$LEADIN\.\s+(\"[^\"]+\"|\'[^\']+\'|\$\([^)]+\)+(?:\/[^\s;]+)?)\s*(\&|\||\d?>|<|;|\Z)/o and m/$LEADIN(\.\s+[^\s;\`:]+\s+([^\s;]+))/o) { if ($2 =~ /^(\&|\||\d?>|<)/) { # everything is ok ; } else { $found = 1; $match = $1; $explanation = "sourced script with arguments"; output_explanation($display_filename, $orig_line, $explanation); } } # Remove "quoted quotes". They're likely to be inside # another pair of quotes; we're not interested in # them for their own sake and removing them makes finding # the limits of the outer pair far easier. $line =~ s/(^|[^\\\'\"])\"\'\"/$1/g; $line =~ s/(^|[^\\\'\"])\'\"\'/$1/g; foreach my $re (@singlequote_bashisms_keys) { my $expl = $singlequote_bashisms{$re}; if ($line =~ m/($re)/) { $found = 1; $match = $1; $explanation = $expl; output_explanation($display_filename, $orig_line, $explanation); } } my $re = '(?); } } # $cat_line contains the version of the line we'll check # for heredoc delimiters later. Initially, remove any # spaces between << and the delimiter to make the following # updates to $cat_line easier. However, don't remove the # spaces if the delimiter starts with a -, as that changes # how the delimiter is searched. my $cat_line = $line; $cat_line =~ s/(<\<-?)\s+(?!-)/$1/g; # Ignore anything inside single quotes; it could be an # argument to grep or the like. $line =~ s/(^|[^\\\"](?:\\\\)*)\'(?:\\.|[^\\\'])+\'/$1''/g; # As above, with the exception that we don't remove the string # if the quote is immediately preceded by a < or a -, so we # can match "foo <<-?'xyz'" as a heredoc later # The check is a little more greedy than we'd like, but the # heredoc test itself will weed out any false positives $cat_line =~ s/(^|[^<\\\"-](?:\\\\)*)\'(?:\\.|[^\\\'])+\'/$1''/g; $re = '(?); } } foreach my $re (@string_bashisms_keys) { my $expl = $string_bashisms{$re}; if ($line =~ m/($re)/) { $found = 1; $match = $1; $explanation = $expl; output_explanation($display_filename, $orig_line, $explanation); } } # We've checked for all the things we still want to notice in # double-quoted strings, so now remove those strings as well. $line =~ s/(^|[^\\\'](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1""/g; $cat_line =~ s/(^|[^<\\\'-](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1""/g; foreach my $re (@bashisms_keys) { my $expl = $bashisms{$re}; if ($line =~ m/($re)/) { $found = 1; $match = $1; $explanation = $expl; output_explanation($display_filename, $orig_line, $explanation); } } # This check requires the value to be compared, which could # be done in the regex itself but requires "use re 'eval'". # So it's better done in its own if ($line =~ m/$LEADIN((?:exit|return)\s+(\d{3,}))/o && $2 > 255) { $explanation = 'exit|return status code greater than 255'; output_explanation($display_filename, $orig_line, $explanation); } # Only look for the beginning of a heredoc here, after we've # stripped out quoted material, to avoid false positives. if ($cat_line =~ m/(?:^|[^<])\<\<(\-?)\s*(?:(?!<|'|")((?:[^\s;>|]+(?:(?<=\\)[\s;>|])?)+)|[\'\"](.*?)[\'\"])/ ) { $cat_indented = ($1 && $1 eq '-') ? 1 : 0; my $quoted = defined($3); $cat_string = $quoted ? $3 : $2; unless ($quoted) { # Now strip backslashes. Keep the position of the # last match in a variable, as s/// resets it back # to undef, but we don't want that. my $pos = 0; pos($cat_string) = $pos; while ($cat_string =~ s/\G(.*?)\\/$1/) { # position += length of match + the character # that followed the backslash: $pos += length($1) + 1; pos($cat_string) = $pos; } } $start_lines{'cat_string'} = $.; } } } warn "error: $display_filename: Unterminated heredoc found, EOF reached. Wanted: <$cat_string>, opened in line $start_lines{'cat_string'}\n" if ($cat_string ne ''); warn "error: $display_filename: Unterminated quoted string found, EOF reached. Wanted: <$quote_string>, opened in line $start_lines{'quote_string'}\n" if ($quote_string ne ''); warn "error: $display_filename: EOF reached while on line continuation.\n" if ($buffered_line ne ''); close C; if ($mode && !$issues) { warn "could not find any possible bashisms in bash script $filename\n"; $status |= 4; } } exit $status; sub output_explanation { my ($filename, $line, $explanation) = @_; if ($mode) { # When examining a bash script, just flag that there are indeed # bashisms present $issues = 1; } else { if ($opt_lint) { print "$filename:$.:1: warning: possible bashism; $explanation\n"; } else { warn "possible bashism in $filename line $. ($explanation):\n$line\n"; } if ($opt_early_fail) { exit 1; } $status |= 1; } } # Returns non-zero if the given file is not actually a shell script, # just looks like one. sub script_is_evil_and_wrong { my ($filename) = @_; my $ret = -1; # lintian's version of this function aborts if the file # can't be opened, but we simply return as the next # test in the calling code handles reporting the error # itself open(IN, '<', $filename) or return $ret; my $i = 0; my $var = "0"; my $backgrounded = 0; local $_; while () { chomp; next if /^#/o; next if /^$/o; last if (++$i > 55); if ( m~ # the exec should either be "eval"ed or a new statement (^\s*|\beval\s*[\'\"]|(;|&&|\b(then|else))\s*) # eat anything between the exec and $0 exec\s*.+\s* # optionally quoted executable name (via $0) .?\$$var.?\s* # optional "end of options" indicator (--\s*)? # Match expressions of the form '${1+$@}', '${1:+"$@"', # '"${1+$@', "$@", etc where the quotes (before the dollar # sign(s)) are optional and the second (or only if the $1 # clause is omitted) parameter may be $@ or $*. # # Finally the whole subexpression may be omitted for scripts # which do not pass on their parameters (i.e. after re-execing # they take their parameters (and potentially data) from stdin .?(\$\{1:?\+.?)?(\$(\@|\*))?~x ) { $ret = $. - 1; last; } elsif (/^\s*(\w+)=\$0;/) { $var = $1; } elsif ( m~ # Match scripts which use "foo $0 $@ &\nexec true\n" # Program name \S+\s+ # As above .?\$$var.?\s* (--\s*)? .?(\$\{1:?\+.?)?(\$(\@|\*))?.?\s*\&~x ) { $backgrounded = 1; } elsif ( $backgrounded and m~ # the exec should either be "eval"ed or a new statement (^\s*|\beval\s*[\'\"]|(;|&&|\b(then|else))\s*) exec\s+true(\s|\Z)~x ) { $ret = $. - 1; last; } elsif (m~\@DPATCH\@~) { $ret = $. - 1; last; } } close IN; return $ret; } sub init_hashes { %bashisms = ( qr'(?:^|\s+)function [^<>\(\)\[\]\{\};|\s]+(\s|\(|\Z)' => q<'function' is useless>, $LEADIN . qr'select\s+\w+' => q<'select' is not POSIX>, qr'(test|-o|-a)\s*[^\s]+\s+==\s' => q, qr'\[\s+[^\]]+\s+==\s' => q, qr'\s\|\&' => q, qr'[^\\\$]\{([^\s\\\}]*?,)+[^\\\}\s]*\}' => q, qr'\{\d+\.\.\d+(?:\.\.\d+)?\}' => q, qr'(?i)\{[a-z]\.\.[a-z](?:\.\.\d+)?\}' => q, qr'(?:^|\s+)\w+\[\d+\]=' => q, $LEADIN . qr'read\s+(?:-[a-qs-zA-Z\d-]+)' => q, $LEADIN . qr'read\s*(?:-\w+\s*)*(?:\".*?\"|[\'].*?[\'])?\s*(?:;|$)' => q, $LEADIN . qr'echo\s+(-n\s+)?-n?en?\s' => q, $LEADIN . qr'exec\s+-[acl]' => q, $LEADIN . qr'let\s' => q, qr'(? q<'((' should be '$(('>, qr'(?:^|\s+)(\[|test)\s+-a' => q, qr'\&>' => qword 2\>&1>, qr'(<\&|>\&)\s*((-|\d+)[^\s;|)}`&\\\\]|[^-\d\s]+(? qword 2\>&1>, qr'\[\[(?!:)' => q, qr'/dev/(tcp|udp)' => q, $LEADIN . qr'builtin\s' => q, $LEADIN . qr'caller\s' => q, $LEADIN . qr'compgen\s' => q, $LEADIN . qr'complete\s' => q, $LEADIN . qr'declare\s' => q, $LEADIN . qr'dirs(\s|\Z)' => q, $LEADIN . qr'disown\s' => q, $LEADIN . qr'enable\s' => q, $LEADIN . qr'mapfile\s' => q, $LEADIN . qr'readarray\s' => q, $LEADIN . qr'shopt(\s|\Z)' => q, $LEADIN . qr'suspend\s' => q, $LEADIN . qr'time\s' => q