Skip to content

Instantly share code, notes, and snippets.

@robmiller
Last active June 26, 2017 23:33
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save robmiller/8f66de69a6e989243fd4 to your computer and use it in GitHub Desktop.
Save robmiller/8f66de69a6e989243fd4 to your computer and use it in GitHub Desktop.
wp-audit — a script that searches for WordPress installs on disk, and tells you: whether WordPress is out-of-date; whether any of the plugins you're using are out-of-date; and whether any of the core WordPress files have been tampered with. Requires Ruby 2.0+
#!/usr/bin/env ruby
# Usage: wp-audit [options]
# -a, --all-plugins Show all plugins, not just outdated ones
# -c, --[no-]color Highlight outdated versions in colour
# -f, --format [format] Specify output format. Accepted values: html, term (Terminal output and colours), md (Markdown, no colours). term is default.
#
# Requires: Ruby >= 2.0; a Unix-like operating system (one that ships
# with `find`, `less`, and `diff`)
#
# Installation:
# 1. Download script
# 2. $ ruby path/to/wp-audit
at_exit do
options = {
format: "term",
color: true
}
OptionParser.new do |opts|
opts.banner = "Usage: wp-audit [options]"
opts.on("-a", "--all-plugins", "Show all plugins, not just outdated ones") do |v|
options[:all_plugins] = v
end
opts.on("-c", "--[no-]color", "Highlight outdated versions in colour") do |c|
options[:color] = c
end
opts.on("-f", "--format [format]", "Specify output format. Accepted values: html, term (Terminal output and colours), md (Markdown, no colours). term is default.") do |f|
if %w(html md term).include? f
options[:format] = f
end
end
end.parse!
page_output do
WPAudit.new(Dir.pwd, options).run
end
end
require "pathname"
require "open-uri"
require "json"
require "yaml/store"
require "optparse"
require "erb"
require "digest"
# Outputs an audit of all WordPress installs under a given directory,
# including:
#
# * What version of WordPress is installed
# * All the plugins that are present (even if they're not activated)
# * The version of each plugin
# * Whether the plugin is out of date — provided the plugin can be found
# on the WordPress.org plugins repository.
class WPAudit
def initialize(dir = Dir.pwd, options = {})
@dir = Pathname(dir)
@options = options
end
def run
scanner = Scanner.new(dir)
output "start-scan", scanner: scanner
scanner.scan
output "end-scan", scanner: scanner
scanner.sites.each do |site|
output "site-summary", site: site
output "wp-version", site: site
site.plugins.group_by(&:outdated?).each do |outdated, plugins|
next if !outdated && !options[:all_plugins]
output "plugin-summary", outdated: outdated, plugins: plugins
end
output "hashes", invalid_hashes: site.hashes.invalid
output "divider"
end
end
private
attr_reader :dir, :options
def output(template_name, locals = {})
format = options[:format]
renderer = template(template_name, format)
b = binding
locals.each do |local, value|
b.local_variable_set(local, value)
end
puts renderer.result(b)
end
def template(name, format)
templates.fetch(name).fetch(format)
end
def templates
@templates ||=
begin
templates = {}
template, format = nil, nil
DATA.each_line do |line|
case line
when /^@@ (.+)/
template = $1
when /^@@@ (.+)/
format = $1
else
templates[template] ||= {}
templates[template][format] ||= ""
templates[template][format] << line
end
end
templates.each do |template, formats|
formats.each do |format, erb|
templates[template][format] = ERB.new(erb, nil, ">")
end
end
templates
end
end
end
# Scans recursively for WordPress installs below the given directory.
class Scanner
attr_reader :sites
def initialize(dir)
@dir = dir
end
def scan
@sites ||= begin
dirs
.reject { |f|
(
%w(wp wp-config.php wp-config-live.php) &
f.children.map { |p| p.basename.to_s }
).empty?
}
.map { |d| Site.new(d.basename, d) }
.select { |s| s.is_wp? }
end
end
private
def dirs
`find #{@dir} -type d`
.each_line
.map { |l| Pathname(l.strip) }
end
end
# A WordPress install on disk.
class Site
attr_reader :name, :path
def initialize(name, path)
@name = name
@path = Pathname(path)
end
def is_wp?
!!wp_version_file
end
def wp_version
return false unless is_wp?
File.readlines(wp_version_file)
.grep(/^\$wp_version/)
.first
.match(/'([^']+)'/)[1]
end
def latest_wp_version
WPVersionLookup.latest_version
end
def wp_outdated?
Gem::Version.new(wp_version) < Gem::Version.new(latest_wp_version) rescue false
end
def plugins
return [] unless has_plugins?
plugins_dir.children
.reject { |p| p == plugins_dir + "index.php" }
.map { |p| Plugin.new(p.basename, p) }
end
def hashes
SiteHashes.new(wp_dir, wp_version)
end
private
def content_dir
[
path + "wp-content",
path + "wp" + "wp-content",
path + "wordpress" + "wp-content",
].find(&:exist?)
end
def plugins_dir
content_dir + "plugins"
end
def has_plugins?
plugins_dir.exist?
end
def wp_dir
[
path + "wp-load.php",
path + "wp" + "wp-load.php",
path + "wordpress" + "wp-load.php",
].find(&:exist?).dirname
end
def wp_version_file
[
path + "wp-includes" + "version.php",
path + "wp" + "wp-includes" + "version.php",
path + "wordpress" + "wp-includes" + "version.php"
].find(&:exist?)
end
end
# A particular WordPress plugin on a particular site.
class Plugin
attr_reader :name, :path
def initialize(name, path)
@name = name
@path = Pathname(path)
end
def version
find_plugin_file
header && header.match(/Version:\s*(.+)$/) { |m| m[1].strip } or "Unknown"
end
def latest_version
PluginLookup.new(name).version || "Unknown"
end
def outdated?
false unless latest_version
Gem::Version.new(version) < Gem::Version.new(latest_version) rescue false
end
private
attr_reader :plugin_file, :header
def find_plugin_file
@plugin_file ||= begin
if path.file?
File.open(path, "r") { |f| @header = f.read(1024) }
return path
end
path.children.find do |file|
next if file.directory?
next unless file.extname == ".php"
File.open(file, "r") do |f|
@header = f.read(1024)
@header && @header.include?("Plugin Name:")
end
end
end
end
end
# Performs a lookup of the latest WordPress version using the
# WordPress.org API.
class WPVersionLookup
def self.latest_version
@latest_version ||= begin
data = open(api_url) do |u|
json = u.read
JSON.parse(json) rescue {}
end
data["offers"].find { |o| o["response"] == "upgrade" }.fetch("version", "")
end
end
private
def self.api_url
"https://api.wordpress.org/core/version-check/1.7/"
end
end
# Looks up data for a given plugin on the WordPress.org plugin API,
# caching it to disk so that future lookups are faster.
class PluginLookup
attr_reader :slug
def initialize(slug)
@slug = slug.to_s
end
def version
cached = PluginCache[slug]
return cached["version"] if cached
version = data["version"]
PluginCache[slug] = { "version" => data["version"] }
version
end
private
def api_url
"https://api.wordpress.org/plugins/info/1.0/#{slug}.json"
end
def json
@json ||= begin
open(api_url) do |u|
u.read
end
end
end
def data
return {} if json == "null"
JSON.parse(json)
rescue
{}
end
end
# Looks up data for the latest version of GravityForms
class GravityFormsLookup
end
# Caches plugin data on disk and performs lookups from that cache.
class PluginCache
def self.[](slug)
initialize
@store.transaction { @store[slug] }
end
def self.[]=(slug, data)
initialize
@store.transaction { @store[slug] = data }
end
def self.initialize
@store ||= begin
YAML::Store.new(Pathname(Dir.home) + "wp-audit-cache.yml")
end
end
end
def page_output(&block)
return block.call unless $stdout.tty?
old_stdout = $stdout
$stdout = open("|less -RFX", "w")
r = block.call
$stdout.close
$stdout = old_stdout
r
end
# For a given WordPress install, compares hashes of the files on the
# filesystem with known-good hashes, to check for compromised files.
class SiteHashes
def initialize(path, version)
@path = path
@version = version
end
def invalid
hash_files unless hashed?
@invalid
end
private
def valid_hashes
ValidHashes.hashes(version)
end
def hashed?
@hashed
end
def hash_files
@actual_hashes = {}
@invalid = []
valid_hashes.each do |file, hash|
full_path = path + file
next unless File.exist?(full_path)
actual_hash = Digest::MD5.file(full_path).to_s
@actual_hashes[file] = actual_hash
if hash != actual_hash
diff = Diff.diff(full_path, ValidContent.content(version, file))
unless diff.empty?
@invalid << { file: file, expected: hash, actual: actual_hash, diff: diff }
end
end
end
@hashed = true
@actual_hashes
end
attr_reader :path, :version
end
# For a given WordPress version, returns known-good hashes for the
# files for that install.
class ValidHashes
def self.hashes(version)
@hashes ||= {}
unless @hashes[version]
open(url(version)) do |u|
data = JSON.parse(u.read)
@hashes[version] = data["checksums"] || {}
end
end
@hashes[version]
rescue
{}
end
def self.url(version)
"https://api.wordpress.org/core/checksums/1.0/?version=#{version}&locale=en_US"
end
end
# Given a WordPress version and a file path, returns the genuine
# content for that file in that version.
class ValidContent
def self.content(version, file)
url = "https://raw.githubusercontent.com/WordPress/WordPress/#{version}/#{file}"
open(url) { |u| u.read }
rescue OpenURI::HTTPError
""
end
end
class Diff
def self.diff(file, string)
open("|diff -uwB #{file} -", "r+") do |diff|
diff.write(string)
diff.close_write
diff.read
end
end
end
__END__
@@ start-scan
@@@ term
Scanning for WordPress installs in <%= dir %>...
@@@ html
<p>Scanning for WordPress installs in <%= dir %>...</p>
@@@ md
### Scanning for WordPress installs in <%= dir %>...
@@ end-scan
@@@ term
<%= scanner.sites.length %> sites found.
@@@ html
<p><%= scanner.sites.length %> sites found.</p>
<ul>
@@@ md
<%= scanner.sites.length %> sites found.
@@ site-summary
@@@ term
<%= site.name %> - <%= site.path %>
@@@ html
<h2><%= site.name %> - <%= site.path %></h2>
@@@ md
<%= site.name %> - <%= site.path %>
@@ wp-version
@@@ term
Running WordPress v<%= site.wp_version %> <%= "\e[31m" if site.wp_outdated? %>(Latest version: <%= site.latest_wp_version %><%= "\e[0m" %>
@@@ html
<p>Running WordPress v<%= site.wp_version %> <span<%= " style='color: red'" if site.wp_outdated? %>>(Latest version: <%= site.latest_wp_version %></span></p>
@@@ md
Running WordPress v<%= site.wp_version %> <%= "*" if site.wp_outdated? %>(Latest version: <%= site.latest_wp_version %><%= "*" if site.wp_outdated? %>
@@ plugin-summary
@@@ term
<%= outdated ? "Outdated plugins" : "Up-to-date plugins" %> (<%= plugins.length %>)
<% plugins.each do |plugin| %>
* <%= plugin.name %> v<%= plugin.version %> <%= "\e[31m" if plugin.outdated? %>(Latest version: <%= plugin.latest_version %>)<%= "\e[0m" %>
<% end %>
@@@ html
<p><%= outdated ? "Outdated plugins" : "Up-to-date plugins" %> (<%= plugins.length %>)</p>
<ul>
<% plugins.each do |plugin| %>
<li><%= plugin.name %> v<%= plugin.version %> <span<%= " style='color:red'" if plugin.outdated? %>>(Latest version: <%= plugin.latest_version %>)</span></li>
<% end %>
@@@ md
<%= outdated ? "Outdated plugins" : "Up-to-date plugins" %> (<%= plugins.length %>)
<% plugins.each do |plugin| %>
* <%= plugin.name %> v<%= plugin.version %> <%= "*" if plugin.outdated? %>(Latest version: <%= plugin.latest_version %>)<%= "*" if plugin.outdated? %>
<% end %>
@@ divider
@@@ term
---
@@@ html
<hr>
@@@ md
___
@@ hashes
@@@ term
<%= "Possibly hacked files:" if invalid_hashes.length > 0 %>
<% invalid_hashes.each do |hash| %>
File <%= hash[:file] %> had hash <%= hash[:actual] %>, expected <%= hash[:expected] %>
<% if hash[:diff].empty? %>
Files differ only by whitespace.
<% else %>
Diff:
<%= hash[:diff] %>
<% end %>
<% end %>
@@@ html
<%= "<p>Possibly hacked files:</p>" if invalid_hashes.length > 0 %>
<ul>
<% invalid_hashes.each do |hash| %>
<li>
<p>File <%= hash[:file] %> had hash <%= hash[:actual] %>, expected <%= hash[:expected] %></p>
<% if hash[:diff].empty? %>
<p>Files differ only by whitespace.</p>
<% else %>
<p>Diff:</p>
<pre>
<%= hash[:diff] %>
</pre>
<% end %>
</li>
<% end %>
</ul>
@@@ md
<%= "Possibly hacked files:" if invalid_hashes.length > 0 %>
<% invalid_hashes.each do |hash| %>
* File <%= hash[:file] %> had hash <%= hash[:actual] %>, expected <%= hash[:expected] %>
<% if hash[:diff].empty? %>
Files differ only by whitespace.
<% else %>
Diff:
<%= hash[:diff] %>
<% end %>
<% end %>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment