Automated Security-Updates for Drupal

Planet Drupal
Drupal

UPDATE: we've been asked if we could put these scripts on Github, so there you go: https://github.com/1xINTERNET/drupal-security-update-scripts

We all have been there:

We do not need the shiniest newest features in our Drupal installation, but we need the project to run reliably and securely. For this you can choose to only run security-updates.

But it might be tedious to always look at your projects, figure out their dependencies and check for updates. At the end of the day this approach does not scale, is prone to errors and you might end up forgetting it.

On the other hand, having the server update itself is not a great idea because what you might be developing against is not running on the server anymore or the update, although running fine on 99% of machines, will break your machine because you have a special edge-case present.

Thus the security-update will take down your production-system and all the benefits of a CI/CD-pipeline walk out of the door.

But if you have a CD approach already set up for your Drupal installation, it is easy to adopt it to help you keep your project up to date and the nature of the task at hand lends itself nicely to Kubernetes-Jobs or docker-containers in general.

The approach illustrated here assumes that you have a spare database and you have a place where you can re-create the docroot-folder-structure of your project.

The security-update-logic itself will not need to be available via internet so no actual webserver is required.

All you need for this to work is the deployable project (either the artifact running on production or a rebuild of master that is used), a database, a re-creation of the folder structure of your webserver and shell access, drush and composer for Drupal 8.

The following approach shows how we go about it and explains why.

We furthermore assume that the environment the script runs in is already setup to accommodate the project.
(also already containing all files used during this setup, e.g. db-dumps)

The following script is for updating a Drupal 8 site just because it is a bit more complicated, for Drupal 7 you can just call drush and be done with it.

D8 Update-Script

#!/bin/bash

updateTime=$(date +%Y%m%d-%H-%M-%S)
emails=("email@to.notify", "notify@me.aswell")

function noUpdateFoundFunction {
echo "Security-Update ran at $updateTime, no Updates found."|mail -s "No Security-Update" ${emails[@]}
exit 0
}
function commitErrorHandling {
exitStatus=$?
if [[ $exitStatus -eq 1 ]] ; then
noUpdateFoundFunction
else
echo "Security-Update failed at $updateTime, git encountered problem."|mail -s "Failed Security-Update" ${emails[@]}
exit $exitStatus
fi
}

function handleErrors {
exitStatus=$?
echo "Security-Update failed at $updateTime"|mail -s "Failed Security-Update" ${emails[@]}
exit $exitStatus
}

function isNotInBlacklist() {
element=$1
blacklist=$2

for blackListedElement in $blacklist; do
if [ $element == $blackListedElement ]; then
return 1; # 1 is false in bash
fi
done

return 0;# 0 is true in bash
}

trap handleErrors ERR

echo "Deploy new Artifact"
rm -fr dist
mkdir dist
tar xf archive.tar -C dist
rm archive.tar
pushd dist/web

../vendor/drush/drush/drush sql-drop -y
cat /tmp/securityDB.sql |../vendor/drush/drush/drush sql-cli
rm /tmp/securityDB.sql

composerModuleNames=()

#disabling trap because drush will throw a status-code 1 if it finds a core-update and wants to warn.
#https://github.com/drush-ops/drush/issues/3749
# see "SHELL BUILTIN COMMANDS" in `man bash` or `bash -c "help set" && bash -c "help trap"`
trap - ERR
set +e

echo "preparing to get update-message"
updateMessage=$(../vendor/drush/drush/drush pm:security -n )
echo "prepare composer-packagenames"

for module in $(../vendor/bin/drush pm:security --format=list --field=Name 2>/dev/null); do
composerModuleNames+=("drupal/"$module)
done
cd ..

set -e
trap handleErrors ERR

git checkout -b security
git config user.email "security-email@company.tld"
git config user.name "security"

echo "Updating packages"

if [[ -n ${composerModuleNames[@]} ]]; then
composer update ${composerModuleNames[@]} --with-dependencies
pushd web/
../vendor/bin/drush updb -y
../vendor/bin/drush entup -y
popd
trap commitErrorHandling ERR
git add .
git commit -a -m "Security-Update ran successfully at $updateTime"
echo "Security-Update successfully executed at $updateTime \n $updateMessage "|mail -s "Successful Security-Update" ${emails[@]}
else
noUpdateFoundFunction
fi
popd

The script itself works on some environmental states (like where is the DB-Dump to be used coming from, bash as shell in use) but in general it tries to not assume a lot about the environment and tries to keep the state contained within the project, e.g., calling the drush-version of the project explicitly, it also commits the changes to the VCS (you can choose to push it to your remote, for easy integration into your CI/CD-pipeline).

We push the commit into our remote in a later step, so our Devs can just look at the update, ensure that nothing breaks, and no vital configurations (.htaccess, .gitignore) are overwritten and then just merge it.

Before we get into the details of the script, the attentive reader might have noticed that we do not specify any database to be used by Drupal. This is because we always have a specific file present that tells Drupal what DB to use.

Drupal will automatically go out and include this file, if the file itself is not present it will fail hard and fast.
This setup ensures that we keep as much information concerning the project in the repository (yes we commit our settings.php) and that the environmental-specific information is kept on the environment.

Seeing that the environment needs to be provisioned to cater to a project anyways there is only an upside to it.

Now, let us look at the script in portions.

Specified Bash-Functions

function noUpdateFoundFunction {
echo "Security-Update ran at $updateTime, no Updates found."|mail -s "No Security-Update" ${emails[@]}
exit 0
}
function commitErrorHandling {
exitStatus=$?
if [[ $exitStatus -eq 1 ]] ; then
noUpdateFoundFunction
else
echo "Security-Update failed at $updateTime, git encountered problem."|mail -s "Failed Security-Update" ${emails[@]}
exit $exitStatus
fi
}

function handleErrors {
exitStatus=$?
echo "Security-Update failed at $updateTime"|mail -s "Failed Security-Update" ${emails[@]}
exit $exitStatus
}

function isNotInBlacklist() {
element=$1
blacklist=$2

for blackListedElement in $blacklist; do
if [ $element == $blackListedElement ]; then
return 1; # 1 is false in bash
fi
done

return 0;# 0 is true in bash
}

The functions specified above are concerned with error-handling or dispersion of specific information.

Most of these functions will be encountered in when using the "trap"-utility of bash.

You will also note that commitErrorHandling() is actually a special case of handleErrors() and we could do without, so you actually have full sway as to how you want to structure this.

We also provide a function to check if a module is blacklisted for update(think updatelock in drush).

Clean Slate

echo "Deploy new Artifact"
rm -fr dist
mkdir dist
tar xf archive.tar -C dist
rm archive.tar
pushd dist/web

../vendor/drush/drush/drush sql-drop -y

The code above provides a clean slate.

We delete the previously deployed artifact(this is not required for containerized environments (just don't use volumes)).

Getting Database

cat /tmp/securityDB.sql |../vendor/drush/drush/drush sql-cli
rm /tmp/securityDB.sql

Here we just import the database.

You can also choose to stream it into the environment via ssh/kubectl/docker and a tar-pipe.

Update- and Package-Informations

composerModuleNames=()

#disabling trap because drush will throw a status-code 1 if it finds a core-update and wants to warn.
#https://github.com/drush-ops/drush/issues/3749
# see "SHELL BUILTIN COMMANDS" in `man bash` or `bash -c "help set" && bash -c "help trap"`
trap - ERR
set +e

echo "preparing to get update-message"
updateMessage=$(../vendor/drush/drush/drush pm:security -n )
echo "prepare composer-packagenames"

for module in $(../vendor/bin/drush pm:security --format=list --field=Name 2>/dev/null); do
composerModuleNames+=("drupal/"$module)
done
cd ..

set -e
trap handleErrors ERR

Here we prepare the udpates to be usable by composer and we also get the human-readable form printed by drush, to later ship it with the email.

Please also note that we disable error-handling (listening to status-code != 0) as this is common behavior in drush to return non-zero status-code upon finding a core-update.

A ticket for this situation has been opened with the project (see bash-comment).

Running the actual update of Drupal 8

git checkout -b security
git config user.email "security-email@company.tld"
git config user.name "security"

echo "Updating packages"

if [[ -n ${composerModuleNames[@]} ]]; then
composer update ${composerModuleNames[@]} --with-dependencies
pushd web/
../vendor/bin/drush updb -y
../vendor/bin/drush entup -y
popd
trap commitErrorHandling ERR
git add .
git commit -m "Security-Update ran successfully at $updateTime"
echo "Security-Update successfully executed at $updateTime \n $updateMessage "|mail -s "Successful Security-Update" ${emails[@]}
else
noUpdateFoundFunction
fi
popd

Here we actually update all the packages found using drush.

Thanks to the naming-convention we can just use the drush package-names and prefix them with `drupal/` .

As you can see we can also choose to put the emails in directly instead of having a function do it.
As long as we do not use `local` with a variables, they are globally accessible, so the success-message can easily be put into a function without having to worry about the variables.

Please also note that `git add .` is required, running `git commit -a -m "...."` will ignore untracked files.

With this we have a new branch on our server that can be pushed to the remote in a subsequent step.

We advise to use a  force-push in this case.
This ensures that there are no conflicts between the old security-branch and the new one.
It also removes the necessity to account for state.

Because we only care about the latest security-update and we want to be able to simulate a live-deploy with the commit made.

Running this code once every 24h guarantees that the security-branch is usable for merging within this timeframe and it can be cherry-picked without conflicts to the masterbranch and merged into the develop-branch (you shouldn't have any conflicts anyways as 3rd-party code is touched only).

In that sense Security-Updates are nothing other than hotfixes  for 3rd-party-code and can be treated as hotfixes using their git-workflow.