Skip to content

Commit 266404a

Browse files
committed
Include contents of external scripts in hook signatures
Commit 11c8ad6 introduced a security vulnerability where defining a hook that simply called an external script would not sign the external script. Fix this by enforcing that these kinds of hooks must have `required_executable`/`command` options that are paths to executables stored in source control (relative to the repository root), and furthermore that the contents of those scripts actually are included in the signature.
1 parent 11c8ad6 commit 266404a

File tree

7 files changed

+83
-50
lines changed

7 files changed

+83
-50
lines changed

lib/overcommit.rb

-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
require 'overcommit/git_repo'
1212
require 'overcommit/hook_signer'
1313
require 'overcommit/hook_loader/base'
14-
require 'overcommit/hook_loader/ad_hoc_hook_loader'
1514
require 'overcommit/hook_loader/built_in_hook_loader'
1615
require 'overcommit/hook_loader/plugin_hook_loader'
1716
require 'overcommit/interrupt_handler'

lib/overcommit/exceptions.rb

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class InvalidCommandArgs < StandardError; end
3030
# Raised when an installation target is not a valid git repository.
3131
class InvalidGitRepo < StandardError; end
3232

33+
# Raised when a hook was defined incorrectly.
34+
class InvalidHookDefinition < StandardError; end
35+
3336
# Raised when one or more hook plugin signatures have changed.
3437
class InvalidHookSignature < StandardError; end
3538

lib/overcommit/hook_loader/ad_hoc_hook_loader.rb

-38
This file was deleted.

lib/overcommit/hook_loader/plugin_hook_loader.rb

+42-3
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ class PluginHookLoader < Base
77
def load_hooks
88
check_for_modified_plugins if @config.verify_plugin_signatures?
99

10-
plugin_paths.map do |plugin_path|
10+
hooks = plugin_paths.map do |plugin_path|
1111
require plugin_path
1212

1313
hook_name = Overcommit::Utils.camel_case(File.basename(plugin_path, '.rb'))
1414
create_hook(hook_name)
1515
end
16+
17+
hooks + ad_hoc_hook_names.map do |hook_name|
18+
create_ad_hoc_hook(hook_name)
19+
end
1620
end
1721

1822
def update_signatures
@@ -31,9 +35,19 @@ def plugin_paths
3135
Dir[File.join(directory, '*.rb')].sort
3236
end
3337

38+
def plugin_hook_names
39+
plugin_paths.map do |path|
40+
Overcommit::Utils.camel_case(File.basename(path, '.rb'))
41+
end
42+
end
43+
44+
def ad_hoc_hook_names
45+
@config.enabled_ad_hoc_hooks(@context)
46+
end
47+
3448
def modified_plugins
35-
plugin_paths.
36-
map { |path| Overcommit::HookSigner.new(path, @config, @context) }.
49+
(plugin_hook_names + ad_hoc_hook_names).
50+
map { |hook_name| Overcommit::HookSigner.new(hook_name, @config, @context) }.
3751
select(&:signature_changed?)
3852
end
3953

@@ -57,5 +71,30 @@ def check_for_modified_plugins
5771

5872
raise Overcommit::Exceptions::InvalidHookSignature
5973
end
74+
75+
def create_ad_hoc_hook(hook_name)
76+
hook_module = Overcommit::Hook.const_get(@context.hook_class_name)
77+
hook_base = hook_module.const_get('Base')
78+
79+
# Implement a simple class that executes the command and returns pass/fail
80+
# based on the exit status
81+
hook_class = Class.new(hook_base) do
82+
def run # rubocop:disable Lint/NestedMethodDefinition
83+
result = @context.execute_hook(command)
84+
85+
if result.success?
86+
:pass
87+
else
88+
[:fail, result.stdout + result.stderr]
89+
end
90+
end
91+
end
92+
93+
hook_module.const_set(hook_name, hook_class).new(@config, @context)
94+
rescue LoadError, NameError => error
95+
raise Overcommit::Exceptions::HookLoadError,
96+
"Unable to load hook '#{hook_name}': #{error}",
97+
error.backtrace
98+
end
6099
end
61100
end

lib/overcommit/hook_runner.rb

-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ def load_hooks
141141
require "overcommit/hook/#{@context.hook_type_name}/base"
142142

143143
@hooks += HookLoader::BuiltInHookLoader.new(@config, @context, @log).load_hooks
144-
@hooks += HookLoader::AdHocHookLoader.new(@config, @context, @log).load_hooks
145144

146145
# Load plugin hooks after so they can subclass existing hooks
147146
@hooks += HookLoader::PluginHookLoader.new(@config, @context, @log).load_hooks

lib/overcommit/hook_signer.rb

+36-6
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,51 @@
11
module Overcommit
22
# Calculates, stores, and retrieves stored signatures of hook plugins.
33
class HookSigner
4-
attr_reader :hook_path, :hook_name
4+
attr_reader :hook_name
55

66
# We don't want to include the skip setting as it is set by Overcommit
77
# itself
88
IGNORED_CONFIG_KEYS = %w[skip]
99

10-
# @param hook_path [String] path to the actual hook definition
10+
# @param hook_name [String] name of the hook
1111
# @param config [Overcommit::Configuration]
1212
# @param context [Overcommit::HookContext]
13-
def initialize(hook_path, config, context)
14-
@hook_path = hook_path
13+
def initialize(hook_name, config, context)
14+
@hook_name = hook_name
1515
@config = config
1616
@context = context
17+
end
18+
19+
# Returns the path of the file that should be incorporated into this hooks
20+
# signature.
21+
#
22+
# @return [String]
23+
def hook_path
24+
@hook_path ||= begin
25+
plugin_path = File.join(@config.plugin_directory,
26+
@context.hook_type_name,
27+
"#{Overcommit::Utils.snake_case(@hook_name)}.rb")
28+
29+
if File.exist?(plugin_path)
30+
plugin_path
31+
else
32+
# Otherwise this is an ad hoc hook using an existing hook script
33+
hook_config = @config.for_hook(@hook_name, @context.hook_class_name)
1734

18-
@hook_name = Overcommit::Utils.camel_case(File.basename(@hook_path, '.rb'))
35+
command = Array(hook_config['command'] ||
36+
hook_config['required_executable'])
37+
38+
unless !@config.verify_plugin_signatures? ||
39+
command.first.start_with?(".#{File::SEPARATOR}")
40+
raise Overcommit::Exceptions::InvalidHookDefinition,
41+
'Hook must specify a `required_executable` or `command` that ' \
42+
'is under source control (i.e. is a path relative to the root ' \
43+
'of the repository) so that it can be signed'
44+
end
45+
46+
File.join(Overcommit::Utils.repo_root, command.first)
47+
end
48+
end
1949
end
2050

2151
# Return whether the signature for this hook has changed since it was last
@@ -54,7 +84,7 @@ def signature
5484
end
5585

5686
def hook_contents
57-
File.open(@hook_path, 'r').read
87+
File.read(hook_path)
5888
end
5989

6090
def stored_signature

template-dir/hooks/overcommit-hook

+2-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ rescue Overcommit::Exceptions::HookContextLoadError => error
7676
puts error
7777
puts 'Are you running an old version of Overcommit?'
7878
exit 69 # EX_UNAVAILABLE
79-
rescue Overcommit::Exceptions::HookLoadError => error
79+
rescue Overcommit::Exceptions::HookLoadError,
80+
Overcommit::Exceptions::InvalidHookDefinition => error
8081
puts error.message
8182
puts error.backtrace
8283
exit 78 # EX_CONFIG

0 commit comments

Comments
 (0)