kunoichi 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/kunoichi ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'kunoichi'
4
+
5
+ # Hash for commandline flags that override configuration
6
+ @override_config = Hash.new
7
+
8
+ # Default configuration placement
9
+ @conf_files = [ 'config.yml', '/etc/kunoichi/config.yml' ]
10
+
11
+ begin
12
+ opts = GetoptLong.new(
13
+ [ '--config-file', '-c', GetoptLong::REQUIRED_ARGUMENT ],
14
+ [ '--help', '-h', GetoptLong::NO_ARGUMENT ],
15
+ [ '--dry_run', '-n', GetoptLong::NO_ARGUMENT ],
16
+ [ '--import', '-i', GetoptLong::REQUIRED_ARGUMENT ],
17
+ [ '--generate-config', '-g', GetoptLong::OPTIONAL_ARGUMENT ],
18
+ [ '--debug', '-d', GetoptLong::NO_ARGUMENT ],
19
+ [ '--pid-file', '-p', GetoptLong::REQUIRED_ARGUMENT ]
20
+ )
21
+ rescue => e
22
+ puts e.message
23
+ exit
24
+ end
25
+
26
+ begin
27
+ opts.each do |opt, arg|
28
+ case opt
29
+ when '--help'
30
+ help
31
+ when '--config-file'
32
+ @conf_files = [ arg ]
33
+ when '--dry-run'
34
+ @override_config['no_kill'] = true
35
+ @override_config['no_kill_ppid'] = true
36
+ when '--debug'
37
+ @override_config['daemon'] = false
38
+ @override_config['syslog'] = false
39
+ @override_config['logfile'] = false
40
+ @override_config['debug'] = true
41
+ when '--pid-file'
42
+ @override_config['pidfile'] = arg
43
+ when '--import'
44
+ puts import_conf arg
45
+ exit
46
+ when '--generate-config'
47
+ if arg.empty?
48
+ generate_conf 'config.yml'
49
+ else
50
+ generate_conf arg
51
+ end
52
+ exit
53
+ end
54
+ end
55
+ rescue
56
+ exit
57
+ end
58
+
59
+ # Attempt to include each configuration file
60
+ @conf_files.each do |file|
61
+ next unless File.readable? file
62
+ include_conf file
63
+ break
64
+ end
65
+
66
+ # If we're done attempting ad have no @config it means none could be loaded
67
+ unless @config.is_a? Hash
68
+ puts 'Could not find any configuration files. Tried to load: ' + @conf_files.to_s
69
+ exit
70
+ end
71
+
72
+ # Handle commandline overrides in configuration
73
+ [ 'debug', 'no_kill', 'no_kill_ppid', 'daemon', 'syslog', 'logfile', 'pidfile' ].each do |flag|
74
+ unless @override_config[flag].nil?
75
+ @config[flag] = @override_config[flag]
76
+ end
77
+ end
78
+
79
+ # Start kunoichi
80
+ begin
81
+ Kunoichi::Daemon.new @config
82
+ rescue => e
83
+ puts 'Cannot start kunoichi: ' + e.message
84
+ puts e.backtrace if @config['debug']
85
+ end
@@ -0,0 +1,9 @@
1
+
2
+ # Syntax sugar
3
+
4
+ class Object
5
+ # Provide is_bool? to all objects
6
+ def is_bool?
7
+ [ true, false ].include? self
8
+ end
9
+ end
data/lib/kunoichi.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'set'
2
+ require 'etc'
3
+ require 'yaml'
4
+ require 'logger'
5
+ require 'syslog-logger'
6
+ require 'getoptlong'
7
+
8
+ module Kunoichi
9
+ DEFAULTS = {
10
+ 'group' => 0,
11
+ 'daemon' => true,
12
+ 'syslog' => true,
13
+ 'interval' => 1,
14
+ 'logfile' => '/var/log/kunoichi.log',
15
+ 'pidfile' => '/var/run/kunoichi.pid',
16
+ 'whitelist' => '/etc/kunoichi/whitelist.txt',
17
+ 'external_command' => '/bin/true',
18
+ 'no_kill' => true,
19
+ 'no_kill_ppid' => true,
20
+ 'ignore_root_procs' => true,
21
+ 'log_whitelist' => true,
22
+ 'require_init_wlist' => false,
23
+ 'proc_scan_offset' => 0,
24
+ }
25
+
26
+ CONFIGURATION_HEADER = '## Kunoichi configuration file.
27
+ # Same options as ninja.
28
+ # Check the documentation to know what each option means.
29
+ # Documentation can be found on the project home page on github,
30
+ # or in README.md bundled with Kunoichi.
31
+ '
32
+
33
+ end
34
+
35
+ require 'extensions/object'
36
+ require 'kunoichi/proctable'
37
+ require 'kunoichi/kunoichi'
38
+ require 'kunoichi/cli'
@@ -0,0 +1,99 @@
1
+ # Show help
2
+ def help
3
+ puts "Usage: #{$0} [options]"
4
+ puts "Options:"
5
+ puts "\t-h|--help\t\tThis help"
6
+ puts "\t-d|--debug\t\tDon't run in daemon mode"
7
+ puts "\t-n|--dry-run\t\tPerform no actions"
8
+ puts "\t-c|--config-file\tConfiguration file path.\n\t\t\t\t(defaults: config.rb, /etc/kunoichi/config.rb)"
9
+ puts "\t-i|--import\t\tImport configuration from ninja and writes to stdout."
10
+ puts "\t-g|--generate-config\tGenerate sample configuration file"
11
+ puts "\t-p|--pid-file\t\tPid file path"
12
+ exit
13
+ end
14
+
15
+ # Try to include yaml configuration
16
+ def include_conf(file)
17
+ begin
18
+ raise 'empty file' unless @config = YAML.load_file(file)
19
+ rescue => e
20
+ puts "Cannot load configuration: #{e.message}"
21
+ exit
22
+ end
23
+ end
24
+
25
+ # Parse a ninja configuration file and return kunoichi yaml configuration
26
+ def import_conf(file)
27
+
28
+ # Try to open the file
29
+ begin
30
+ ninja = File.read(file)
31
+ rescue => e
32
+ puts 'Cannot open ninja configuration file: ' + e.message
33
+ exit
34
+ end
35
+
36
+ # Initialize parsed old configuration and new configuration hashes
37
+ oldconf = {}
38
+ newconf = {}
39
+
40
+ # Transform ninja configuration, comments excluded, in a ruby hash
41
+ ninja.lines.grep(/^[^#\n].* = /).each do |x|
42
+ key=x.split('=')[0].strip!
43
+ value=x.split('=')[1].strip!
44
+ oldconf[key] = value
45
+ end
46
+
47
+ # For each of the values that kunoichi understands
48
+ Kunoichi::DEFAULTS.keys.each do |x|
49
+ # If it was not declared, return the default value
50
+ if oldconf[x].nil? then
51
+ newconf[x] = Kunoichi::DEFAULTS[x]
52
+ else
53
+ # If there was the setting, turn yes/no/(null) into booleans
54
+ case oldconf[x]
55
+ when 'yes'
56
+ newconf[x] = true
57
+ when 'no', '(null)'
58
+ newconf[x] = false
59
+ else
60
+ # Since all values are strings, turn to integer those that require it
61
+ if [ 'group', 'interval', 'proc_scan_offset' ].include? x
62
+ newconf[x] = oldconf[x].to_i
63
+ else
64
+ # Save the old value
65
+ newconf[x] = oldconf[x]
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # Return the newly filled hash in yaml format, preceded by some useful comments.
72
+ return Kunoichi::CONFIGURATION_HEADER + newconf.to_yaml
73
+ end
74
+
75
+ # Generate a configuration file, starting from defaults
76
+ def generate_conf(file)
77
+ # Take care not to overwrite the configuration by mistake
78
+ if File.exist? file
79
+ puts "File #{file} already existing."
80
+ return
81
+ end
82
+ begin
83
+ conf = File.open(file,'w+')
84
+ rescue => e
85
+ puts "Error creating file: #{e.message}"
86
+ return
87
+ end
88
+ begin
89
+ # Write some useful comments, and the defaults dump in yaml
90
+ conf.write Kunoichi::CONFIGURATION_HEADER +
91
+ Kunoichi::DEFAULTS.to_yaml
92
+ rescue => e
93
+ puts "Error writing to file: #{e.message}"
94
+ return
95
+ end
96
+ puts "Sample configuration written to #{file}."
97
+ conf.close
98
+ exit
99
+ end
@@ -0,0 +1,300 @@
1
+ module Kunoichi
2
+ # The main class, where all the dirty work happens
3
+ class Daemon
4
+
5
+ # Basic validator for configuration
6
+ def validate_conf(config)
7
+
8
+ # Base check, might be redundant
9
+ raise 'invalid configuration passed: not an hash' unless config.is_a? Hash
10
+
11
+ # Check for group validity
12
+ raise 'invalid group: not a number' unless config['group'].is_a? Fixnum
13
+ # Make sure the group exists on /etc/group
14
+ begin
15
+ Etc::getgrgid config['group']
16
+ rescue => e
17
+ raise "invalid group: #{e.message}"
18
+ end
19
+
20
+ # Check for daemon validity
21
+ raise 'invalid daemon setting: please set to true or false' unless config['daemon'].is_bool?
22
+
23
+ # Check interval
24
+ raise 'invalid interval: please set to a number' unless config['interval'].is_a? Fixnum or config['interval'].is_a? Float
25
+ # Useful warning in case no interval was declared
26
+ puts 'WARNING: an interval of 0 can severely impact your machine performance. Use with caution.' if config['interval'] == 0
27
+
28
+ # Check syslog
29
+ raise 'invalid value for syslog: please set to true or false' unless config['syslog'].is_bool?
30
+
31
+ # Check logfile
32
+ raise 'set either syslog or logfile if daemon mode is enabled' unless config['syslog'] or config['logfile'].is_a? String or !config['daemon']
33
+ raise 'invalid logfile setting: not writable' unless !config['logfile'] or File.writable? config['logfile'] or File.writable? File.dirname(config['logfile'])
34
+
35
+ # Check whitelist
36
+ raise 'invalid whitelist: set to a file path, or false to disable' unless config['whitelist'] == false or config['whitelist'].is_a? String
37
+ raise "invalid whitelist: cannot open #{config['whitelist']}" unless config['whitelist'] == false or File.readable?(config['whitelist'])
38
+
39
+ # Check external_command
40
+ raise 'invalid external_command: set to a file path, or false to disable' unless config['external_command'].is_a? String or config['external_command'] == false
41
+ raise 'invalid external_command: not executable' unless !config['external_command'] or File.executable?(config['external_command'].split[0])
42
+
43
+ # Check no_kill
44
+ raise 'invalid no_kill: set to true or false' unless config['no_kill'].is_bool?
45
+
46
+ # Check no_kill_ppid
47
+ raise 'invalid no_kill_ppid: set to true or false' unless config['no_kill_ppid'].is_bool?
48
+
49
+ # Check ignore_root_procs
50
+ raise 'invalid ignore_root_procs: set to true or false' unless config['ignore_root_procs'].is_bool?
51
+
52
+ # Check log_whitelist
53
+ raise 'invalid log_whitelist: set to true or false' unless config['log_whitelist'].is_bool?
54
+
55
+ # Check require_init_wlist
56
+ raise 'invalid require_init_wlist: set to true or false' unless config['require_init_wlist'].is_bool?
57
+
58
+ # Check proc_scan_offset
59
+ raise 'invalid proc_scan_offset: set to a number' unless config['proc_scan_offset'].is_a? Fixnum
60
+ raise "invalid proc_scan_offset: set lower than #{@max_pids}" unless config['proc_scan_offset'] < @max_pids
61
+
62
+ # Check pidfile
63
+ raise 'invalid pidfile setting: set to a string' unless config['pidfile'].is_a? String
64
+ raise 'invalid pidfile setting: empty' if config['pidfile'].empty?
65
+ raise 'invalid pidfile setting: not writable' unless File.writable? config['pidfile'] or File.writable? File.dirname(config['pidfile'])
66
+
67
+ # Return the configuration if no errors encountered
68
+ config
69
+ end
70
+
71
+ # Exit cleanly by logging our departure and deleting the pid file if we received a signal
72
+ def clean_exit(sig)
73
+ @log.info "Got SIG#{sig}, exiting."
74
+ File.unlink(@config['pidfile']) if @config['daemon'] and @config['pidfile']
75
+ exit
76
+ end
77
+
78
+ # Fetch the current running processes and put them into a ruby set
79
+ # TODO: make this multi platform
80
+ def get_process_list
81
+ # Initialize the set
82
+ entries = Set.new
83
+ # Look up all pids
84
+ Dir.glob('/proc/[0-9]*') { |x|
85
+ # Turn pids to numbers
86
+ pid = File.basename(x).to_i
87
+ # Formal check for unexpected files on /proc starting with a digit
88
+ next unless pid.to_s =~ /^[0-9]+$/
89
+ # Skip processes above the configured offset
90
+ if pid > @config['proc_scan_offset']
91
+ entries.add pid
92
+ end
93
+ }
94
+
95
+ return entries
96
+ end
97
+
98
+ # Load the whitelist
99
+ def load_whitelist(file)
100
+ # Open the file and read all words from lines that don't start with a #
101
+ begin
102
+ @whitelist = File.read(file).lines.grep(/^[^#]/).join.split
103
+ rescue => e
104
+ puts "Cannot load whitelist: #{e.message}"
105
+ exit
106
+ end
107
+ # Warn the user of the whitelist was loaded but found empty
108
+ if @whitelist.empty?
109
+ puts 'WARNING: Empty whitelist loaded.'
110
+ end
111
+ end
112
+
113
+ # Run the external command
114
+ def run_command(process,parent)
115
+
116
+ # Log our action
117
+ @log.info "Running external command: #{@config['external_command']}."
118
+
119
+ # Prepare the environment with some useful informations
120
+ ENV['EVIL_PID'] = process.pid.to_s
121
+ ENV['EVIL_CMD'] = process.cmdline
122
+ ENV['EVIL_NAME'] = process.name
123
+ ENV['EVIL_BIN'] = process.binary
124
+
125
+ ENV['EVIL_PPID'] = parent.pid.to_s
126
+ ENV['EVIL_PCMD'] = parent.cmdline
127
+ ENV['EVIL_PNAME'] = parent.name
128
+ ENV['EVIL_PBIN'] = parent.binary
129
+
130
+ # Launch the command
131
+ system(@config['external_command'])
132
+ end
133
+
134
+ # Daemon startup procedure
135
+ def initialize(config)
136
+ # Check max pids first
137
+ if RUBY_PLATFORM.downcase =~ /linux/
138
+ begin
139
+ @max_pids = File.read('/proc/sys/kernel/pid_max').to_i
140
+ rescue
141
+ raise 'Cannot read /proc/sys/kernel/pid_max (is /proc mounted?)'
142
+ end
143
+ else
144
+ # Default on newer FreeBSD. Solaris should have a cap of 30000.
145
+ # Makes only sense once get_process_list becomes multi-platform, but nice to have.
146
+ @max_pids = 99999
147
+ end
148
+
149
+ # Validate configuration and store if valid
150
+ @config = validate_conf config
151
+
152
+ # Save the full path to the pid file in @config
153
+ @config['pidfile'] = File.expand_path(@config['pidfile'])
154
+
155
+ # Get initial process list at startup
156
+ @initial_procs = get_process_list
157
+ raise 'No processes found (is /proc mounted?)' if @initial_procs.empty?
158
+
159
+ # Load the whitelist
160
+ load_whitelist @config['whitelist'] if @config['whitelist']
161
+
162
+ # Initialize @log as configured
163
+ if @config['syslog']
164
+ @log = Logger::Syslog.new('kunoichi', Syslog::LOG_DAEMON)
165
+ else
166
+ if @config['daemon']
167
+ @log = Logger.new(@config['logfile'])
168
+ else
169
+ @log = Logger.new(STDOUT)
170
+ end
171
+ end
172
+
173
+ # Enable debug info if running in debug mode, keep it quiet otherwise
174
+ if @config['debug']
175
+ @log.level = Logger::DEBUG
176
+ else
177
+ @log.level = Logger::INFO
178
+ end
179
+
180
+ @log.info 'Kunoichi starting up'
181
+
182
+ # Daemonize if we're configured for it
183
+ if @config['daemon']
184
+ Process.daemon
185
+ # Only write the pid file if we're running as daemon
186
+ if @config['pidfile']
187
+ begin
188
+ File.open(@config['pidfile'], 'w+') { |x| x.write Process.pid.to_s }
189
+ rescue => e
190
+ @log.error e.message
191
+ end
192
+ end
193
+ end
194
+
195
+ # Catch SIGINT (^C) and SIGTERM (default kill)
196
+ [ 'INT', 'TERM' ].each do |signal|
197
+ Signal.trap signal do clean_exit signal end
198
+ end
199
+
200
+ # Finally, enter the main loop
201
+ loop do
202
+ main_loop
203
+ sleep @config['interval']
204
+ end
205
+ end
206
+
207
+ # Main loop
208
+ def main_loop
209
+ @log.debug 'Checking processes:'
210
+ # Get running processes
211
+ procs = get_process_list
212
+ # Extract the new processes since last loop cycle
213
+ new_procs = procs - @initial_procs
214
+
215
+ # Skip the run if there are new processes
216
+ (@log.debug "\tNo new processes found."; return) if new_procs.empty?
217
+
218
+ @log.debug "\tNew processes:"
219
+
220
+ # For each new process:
221
+ new_procs.each do |pid|
222
+ # Extract information
223
+ begin
224
+ process = ProcEntry.new(pid)
225
+ rescue
226
+ # Skip if the process died before we could extract the info
227
+ @log.debug "\t\t#{pid} - Disappeared before analysis"
228
+ next
229
+ end
230
+ @log.debug "\t\t#{pid} - #{process.cmdline}"
231
+
232
+ # If the process runs as root
233
+ if process.uid == 0
234
+
235
+ # Attempt to identify the parent
236
+ begin
237
+ parent = ProcEntry.new(process.parent)
238
+ rescue => e
239
+ @log.warn "\t\tParent process of #{process.name} disappeared (#{e.message})."
240
+ next
241
+ end
242
+
243
+ # Skip if the parent has the whitelisted gid
244
+ next if parent.gid == @config['group']
245
+
246
+ # If the parent is root
247
+ if parent.uid == 0
248
+ # ..and we've chosen to ignore root spawned processes
249
+ if @config['ignore_root_procs'] or parent.gid == @config['group']
250
+ # if the parent is init
251
+ if parent.pid == 1
252
+ # spare the process unless configured otherwise
253
+ unless @config['require_init_wlist']
254
+ next
255
+ end
256
+ # if the parent is not init everything is normal, just skip this one
257
+ else
258
+ next
259
+ end
260
+ end
261
+ else
262
+ # Also skip if the parent is not root, but is in the magic gid
263
+ next if parent.gid == @config['group']
264
+ end
265
+
266
+ # Log our finding
267
+ @log.info "\t\tFound offending process: #{pid} - #{process.cmdline}"
268
+
269
+ # Skip if the executable is whitelisted
270
+ if @config['whitelist'] and @whitelist.include? process.binary
271
+ @log.info "\t\t\tAllowed(#{process.binary})." if @config['log_whitelist']
272
+ next
273
+ end
274
+
275
+ # If we got this far it means the process deserves our attention
276
+
277
+ # Terminate the process unless configured otherwise
278
+ unless @config['no_kill']
279
+ @log.info "\t\t\tTerminating."
280
+ Process.kill(:KILL,pid)
281
+ else
282
+ @log.info "\t\t\tNot killing."
283
+ end
284
+
285
+ # Also kill the parent unless configured otherwise (or the parent is init)
286
+ unless process.parent == 1 or @config['no_kill_ppid']
287
+ @log.info "\t\t\tKilling parent too."
288
+ Process.kill(:KILL,process.parent)
289
+ end
290
+
291
+ # Run the external command in a subprocess
292
+ fork { run_command(process,parent) } if @config['external_command']
293
+ end
294
+ end
295
+
296
+ # Save the current process list for the next run
297
+ @initial_procs = procs
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,23 @@
1
+ # Accessory class to access informations about a process
2
+ class ProcEntry
3
+
4
+ attr_accessor :pid, :cmdline, :name, :binary, :uid, :gid, :parent
5
+
6
+ # Fetch all the values when the object is created
7
+ def initialize(pid)
8
+ raise 'invalid pid: not a number' unless pid.is_a? Fixnum
9
+ pids = pid.to_s
10
+ raise 'invalid pid: not running' unless File.directory? "/proc/#{pids}"
11
+ begin
12
+ @pid = pid
13
+ @cmdline = File.read("/proc/#{pids}/cmdline").gsub("\0", ' ')
14
+ @name = @cmdline.split[0]
15
+ @uid = File.stat("/proc/#{pids}/").uid
16
+ @gid = File.stat("/proc/#{pids}/").gid
17
+ @binary = File.readlink("/proc/#{pids}/exe")
18
+ @parent = File.readlines("/proc/#{pids}/status").grep(/^PPid:/).first.split[1].to_i
19
+ rescue => e
20
+ raise "can't get process info: #{e.message}"
21
+ end
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kunoichi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Alessandro Grassi
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-01-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: logger
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.2.8
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 1.2.8
30
+ - !ruby/object:Gem::Dependency
31
+ name: syslog-logger
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 1.6.8
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 1.6.8
46
+ description: A Ruby rewrite of Tom Rune Flo's Ninja, a privilege escalation monitor.
47
+ email:
48
+ - alessandro@aggro.it
49
+ executables:
50
+ - kunoichi
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - lib/extensions/object.rb
55
+ - lib/kunoichi.rb
56
+ - lib/kunoichi/cli.rb
57
+ - lib/kunoichi/kunoichi.rb
58
+ - lib/kunoichi/proctable.rb
59
+ - bin/kunoichi
60
+ homepage: https://github.com/xstasi/kunoichi
61
+ licenses: []
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ! '>='
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubyforge_project:
80
+ rubygems_version: 1.8.24
81
+ signing_key:
82
+ specification_version: 3
83
+ summary: Process privilege escalation monitor
84
+ test_files: []