Checking expired passwords with Serverspec

Expired passwords can cause a few problems. For example if a user sets up a cron job, but their password subsequently expires the job won't run. Instead you will get messages similar to the following in /var/log/cron:

Mar 22 20:56:01 localhost crond[1087]: (bob) PAM ERROR (Authentication token is no longer valid; new one required)
Mar 22 20:56:01 localhost crond[1087]: (bob) FAILED to authorize user with PAM (Authentication token is no longer valid; new one required)

Sticking with the theme of the last two posts, this post is going to look at using Serverspec to test for expired passwords.

Expired passwords and chage

Under Linux, local authentication details for user accounts are stored in /etc/shadow. Password expiry is defined by two fields, "date of last password change" and "maximum password age". The shadow man page gives the following description for "date of last password change":

The date of the last password change, expressed as the number of days since Jan 1, 1970.

The value 0 has a special meaning, which is that the user should change her password the next time she will log in the system.

An empty field means that password aging features are disabled.

And for "maximum password age":

The maximum password age is the number of days after which the user will have to change her password.

After this number of days is elapsed, the password may still be valid. The user should be asked to change her password the next time she will log in.

An empty field means that there are no maximum password age, no password warning period, and no password inactivity period (see below).

If the maximum password age is lower than the minimum password age, the user cannot change her password.

The chage command can be used to quickly show this information for a user:

$ chage --list bob
Last password change                                    : Mar 16, 2017
Password expires                                        : Mar 26, 2017
Password inactive                                       : never
Account expires                                         : never
Minimum number of days between password change          : 0
Maximum number of days between password change          : 10
Number of days of warning before password expires       : 7

Note: the Password expires line can also show never or password must be changed depending on the state of the account. The source for chage shows the exact logic for this.

/*
 * The "last change" date is either "never" or the date the password
 * was last modified. The date is the number of days since 1/1/1970.
 */
(void) fputs (_("Last password change\t\t\t\t\t: "), stdout);
if (lstchgdate < 0) {
  (void) puts (_("never"));
} else if (lstchgdate == 0) {
  (void) puts (_("password must be changed"));
} else {
  changed = lstchgdate * SCALE;
  print_date ((time_t) changed);
}

Testing with bash

A simple bash script to test an account for password expiry might look something like:

#!/bin/bash

yesterday="$(date +%s -d yesterday)"
expire="$(chage -l bob | grep "^Password expire" | cut -d: -f2)"

case "$expire" in
  *never*)
    exit 0 ;;
  *password\ must\ be\ changed*)
    exit 1 ;;
  *)
    expire_date="$(date -d "$expire" +%s)"
    test "$expire_date" -gt "$yesterday"
    exit "$?" ;;
esac

Using Serverspec

Serverspec does have a user resource type. So ideally it would be nice to be able to do something like this:

describe user('bob') do
  its(:password) { should_not be_expired }
end

Unfortunately there currently isn't a method to check for password expiry in Serverspec. A fairly inelegant way around this is to use the bash script above:

describe command('bash <<EOF
  yesterday="\$(date +%s -d yesterday)"
  expire="\$(chage -l bob | grep "^Password expire" | cut -d: -f2)"

  case "\$expire" in
    *never*)
      exit 0 ;;
    *password\ must\ be\ changed*)
      exit 1 ;;
    *)
      expire_date="\$(date -d "\$expire" +%s)"
      test "\$expire_date" -gt "\$yesterday"
      exit "\$?" ;;
  esac
EOF') do
  its(:exit_status) { should eq 0 }
end

Although this works, output from RSpec is far from ideal:

Command "bash <<EOF
  yesterday="\$(date +%s -d yesterday)"
  expire="\$(chage -l bob | grep "^Password expire" | cut -d: -f2)"

  case "\$expire" in
    *never*)
      exit 0 ;;
    *password\ must\ be\ changed*)
      exit 1 ;;
    *)
      expire_date="\$(date -d "\$expire" +%s)"
      test "\$expire_date" -gt "\$yesterday"
      exit "\$?" ;;
  esac
EOF"
  exit_status
    should eq 0

Finished in 0.28234 seconds (files took 0.28879 seconds to load)
1 example, 0 failures

Escaping special characters can also make working with tests tricky. For example $ characters need to be escaped so they are interpreted in the child bash shell.

A better approach is to move the check logic out of the command being run remotely by Serverspec. For example:

require 'date'

describe 'Command "chage -l bob"' do
  stdout = command("chage -l bob").stdout

  it 'should not contain "password must be changed"' do
    expect(stdout).to_not match /password must be changed/
  end

  it 'should not have an expirey date in the past' do
    begin
      expiry_date = Date.parse(stdout.match(/^Password expires.*$/)[0].split(':')[1], ' %b %d, %Y')
      expect(expiry_date).to be > (Date.today - 1)
    rescue ArgumentError
      # Skip date comparison if stdout does not contain a parsable date
    end
  end
end

As well as being easier to work with, RSpec does a much better job of outputting useful failure information:

Command "chage -l bob"
  should not contain "password must be changed"
  should not have an expirey date in the past (FAILED - 1)

Failures:

  1) Command "chage -l bob" should not have an expirey date in the past
     On host `remote.example.com'
     Failure/Error: expect(expiry_date).to be > (Date.today - 1)
       expected: > #<Date: 2017-03-21 ((2457834j,0s,0n),+0s,2299161j)>
            got:   #<Date: 2017-03-17 ((2457834j,0s,0n),+0s,2299161j)>