5

This is what I'm using now to get the job done:

#!/bin/sh --

string='Aa1!z'

if ! printf '%s\n' "$string" | LC_ALL=C grep -q '[[:upper:]]' || \
   ! printf '%s\n' "$string" | LC_ALL=C grep -q '[[:lower:]]' || \
   ! printf '%s\n' "$string" | LC_ALL=C grep -q '[[:digit:]]' || \
   ! printf '%s\n' "$string" | LC_ALL=C grep -q '[[:punct:]]'; then
  printf '%s\n' 'String does not meet your requirements'
else
  printf '%s\n' 'String meets your requirements'
fi

This is extermely inefficent and verbose. Is there a better way to do this?

9
  • 11
    xkcd.com/936 Nov 19, 2019 at 21:07
  • 12
    People who put arbitrary restrictions on passwords are the worst. You're literally telling hackers what characters they should expect in a password. Nov 20, 2019 at 13:18
  • 1
    @TomášZato On the other side of the argument, you are also making sure that users can’t enter a very simple password. In other words, you are increasing the average password entropy.
    – dalearn
    Nov 20, 2019 at 14:58
  • 3
    @dalearn It's still solving the wrong problem. When you solve the wrong problem, that's a hack. By restricting what people can enter, you in fact decrease the entropy - the average only goes up because many people would otherwise choose weak passwords. You don't need to enforce this specific pattern to disallow weak passwords, however.
    – J...
    Nov 20, 2019 at 15:04
  • 3
    You might want to take a look at official recommendations from, e.g., NIST. But questions about this are probably better asked on Information Security (though search there first, as they already have a bunch)
    – derobert
    Nov 20, 2019 at 19:13

9 Answers 9

7

With one call to awk and without pipe:

#! /bin/sh -
string='whatever'

has_char_of_each_class() {
  LC_ALL=C awk -- '
    BEGIN {
      for (i = 2; i < ARGC; i++)
        if (ARGV[1] !~ "[[:" ARGV[i] ":]]") exit 1
    }' "$@"
}

if has_char_of_each_class "$string" lower upper digit punct; then
  echo OK
else
  echo not OK
fi

That's POSIX but note that mawk doesn't support POSIX character classes yet. The -- is not needed with POSIX compliant awks but would be in older versions of busybox awk (which would choke on values of $string that start with -).

A variant of that function using a case shell construct:

has_char_of_each_class() {
  input=$1; shift
  for class do
    case $input in
      (*[[:$class:]]*) ;;
      (*) return 1;;
    esac
  done
}

Note however that changing the locale for the shell in the middle of a script doesn't work with all sh implementations (so you'd need the script to be called in the C locale already if you want the input to be considered as being encoded in the C locale charset and the character classes to match only the ones specified by POSIX).

0
7

With flexible awk pattern matching:

if [[ $(echo "$string" | awk '/[a-z]/ && /[A-Z]/ && /[0-9]/ && /[[:punct:]]/') ]]; then  
    echo "String meets your requirements"
else 
    echo "String does not meet your requirements"
fi
5
  • 4
    +1, but the OP's code has a /bin/sh shebang line and you're using a bashism by way of [[
    – iruvar
    Nov 19, 2019 at 22:06
  • @RomanPerekhrest I seem to be having issues making this work properly, even if I change the interpreter to bash; I get the output "String does not meet your requirements" whether the string does or does not meet my requirements Nov 19, 2019 at 22:17
  • 1
    @HaroldFischer, are you using mawk? Refer Stephane's note around mawk in his answer
    – iruvar
    Nov 19, 2019 at 22:34
  • 1
    If you're going to upgrade to Bash, might as well just use the built-in regex matching of [[ ... Nov 20, 2019 at 12:29
  • @RomanPerekhrest Is there any way this can be done with -v var="$string" instead of using echo or printf? Thought you might know how to do it. Dec 6, 2019 at 21:48
3

The following script is longer than your code, but shows how you could test a string against a list of patterns. The code detects whether the string matches all patterns or not and prints out a result.

#!/bin/sh

string=TestString1

failed=false

for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
do
    case $string in
        $pattern) ;;
        *)
            failed=true
            break
    esac
done

if "$failed"; then
    printf '"%s" does not meet the requirements\n' "$string"
else
    printf '"%s" is ok\n' "$string"
fi

The case ... esac compound command is the POSIX way to test a string against a set of globbing patterns. The variable $pattern is used unquoted in the test, so that the match is not done as a string comparison. If the string does not match the given pattern, then it will match *, and the loop is exited after setting failed to true.

Running this would yield

$ sh script.sh
"TestString1" does not meet the requirements

You could tuck the testing away in a function like so (the code tests a number of strings in a loop, calling the function):

#!/bin/sh

test_string () {
    for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
    do
        case $1 in ($pattern) ;; (*) return 1; esac
    done
}

for string in TestString1 Test.String2 TestString-3; do
    if ! test_string "$string"; then
        printf '"%s" does not meet the requirements\n' "$string"
    else
        printf '"%s" is ok\n' "$string"
    fi
done

If you want to set LC_ALL=C locally in the function, write it as

test_string () (
    LC_ALL=C

    for pattern in '*[[:upper:]]*' '*[[:lower:]]*' '*[[:digit:]]*' '*[[:punct:]]*'
    do
        case $1 in ($pattern) ;; (*) return 1; esac
    done
)

Note that the body of the function now is in a sub-shell. Setting LC_ALL=C will therefore not affect the value of this variable in the calling environment.

Get the shell function to take the patterns as arguments too, and you basically get Stéphane Chazelas' answer (the variant).

3

Inspired by RomanPerekhrest, but with some minor refinements to do away with the pipeline and command substitution:

if awk '/[[:lower:]]/ && /[[:upper:]]/ && /[[:digit:]]/ && /[[:punct:]]/ {exit 1}' <<< "$string" ; then
  echo "did not match all requirements"
else
  echo "looks good to me"
fi
3

This is RomanPerekhrest's answer rewritten to work with mawk:

#!/bin/sh --

string='Aa1!z'

if printf '%s\n' "$string" | LC_ALL=C awk '/[a-z]/ && /[A-Z]/ && /[0-9]/ && /[!-\/:-@[-`{-~]/ {exit 1}'; then
  printf '%s\n' 'String does not meet your requirements'
else
  printf '%s\n' 'String meets your requirements'
fi

It also borrows from bxm's answer by using awk's exit code instead of checking whether's awk's output is empty.

0
1

Stealing shamelessly from @HaroldFischer @bxm and @RomanPerekhrest for a pure awk solution

awk -v test="does not meet" '/[a-z]/ && /[A-Z]/ && /[0-9]/ && /[[:punct:]]/ {test="meets"}
    END {print "String "test" your requirements"}' <<<"Aa&0"
0

For completeness, since no other answers mention PCRE. A limitation of BRE/ERE is that you cannot trivially¹ implement a logical and for the equivalent logical "or" in alternation with |.

PCRE patterns let you create "and" conditions using zero-width assertions: look-ahead or look-behind. These "consume" no characters, but restrict matching before or after patterns. There are many ways to use these, putting look-aheads up front makes sense here:

LC_ALL=C pcregrep -q '(?=.*[[:upper:]])(?=.*[[:lower:]])(?=.*[[:digit:]])(?=.*[[:punct:]]).{4,}'

The PCRE applies 4 "preconditions" to the input before applying the match .{4,} (4 or more characters, feel free to make it larger ;-). One point to note is that "(?=[[:upper:]])" will only inspect a single character, so each condition is preceded with ".*" so the entire input is checked. pcregrep also supports a locale via --locale=C.

Since the "P" in PCRE stands for perl:

perl -wln -e \
  '/(?=.*[[:upper:]])(?=.*[[:lower:]])(?=.*[[:digit:]])(?=.*[[:punct:]]).{4,}/ && exit 0; exit 1;'

does the same thing for a single line of input (it's not a general replacement for "pcregrep -q").

A head-spinning superset of this type of problem can be found here: https://stackoverflow.com/questions/469913/regular-expressions-is-there-an-and-operator


¹ You could expand an ERE to emulate "and" by permutations:

[[:lower:]].*[[:upper:]].*[[:digit:]].*[[:punct:]]|
[[:lower:]].*[[:upper:]].*[[:punct:]].*[[:digit:]]|
[[:lower:]].*[[:digit:]].*[[:upper:]].*[[:punct:]]| ... 20 more lines ...
[[:punct:]].*[[:digit:]].*[[:upper:]].*[[:lower:]]

Definitely not going to help with being "inefficent and verbose".

0

Now if bash were an option: You could enable extended globbing and combine the @( and !( sub-patterns to construct the glob @(!(*[[:upper:]]*)|!(*[[:lower:]]*)|!(*[[:punct:]]*)|!(*[[:digit:]]*)) to compare against

$ shopt -s extglob
$ arr=( '!(*'{'[[:upper:]]','[[:lower:]]','[[:punct:]]','[[:digit:]]'}'*)' )
$ pattern=$(IFS='|'; printf '@(%s)' "${arr[*]}")
$ printf "$pattern\n"
@(!(*[[:upper:]]*)|!(*[[:lower:]]*)|!(*[[:punct:]]*)|!(*[[:digit:]]*))
$ [[ 'Aa3,' = $pattern ]] && echo yes
$ [[ 'Aa3' = $pattern ]] && echo yes
yes
$ [[ 'Aa,' = $pattern ]] && echo yes
yes
$ [[ 'A3,' = $pattern ]] && echo yes
yes
$ [[ 'a3,' = $pattern ]] && echo yes
yes
0

Below creates a random password with the requested. You can make the password longer by replacing head -c 12 with a larger value.

while true
do
  A=$(head /dev/urandom | tr -dc A-Za-z0-9.\'\"$,_! | head -c 12 ; echo '')

  [[ ${A} =~ [A-Z] && ${A} =~ [a-z] && ${A} =~ [0-9] && ${A} =~ [.\'\"$,_!] ]] && break

done

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .