dorothy2 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +21 -0
- data/Gemfile +4 -0
- data/LICENSE +644 -0
- data/README.md +231 -0
- data/Rakefile +1 -0
- data/bin/dorothy_start +176 -0
- data/bin/dorothy_stop +28 -0
- data/bin/dparser_start +66 -0
- data/bin/dparser_stop +23 -0
- data/dorothy2.gemspec +30 -0
- data/etc/ddl/dorothive.ddl +1803 -0
- data/etc/dorothy copy.yml.example +39 -0
- data/etc/sandboxes.yml.example +20 -0
- data/etc/sources.yml.example +32 -0
- data/lib/doroParser.rb +518 -0
- data/lib/dorothy2/BFM.rb +156 -0
- data/lib/dorothy2/MAM.rb +239 -0
- data/lib/dorothy2/Settings.rb +35 -0
- data/lib/dorothy2/deep_symbolize.rb +67 -0
- data/lib/dorothy2/do-init.rb +296 -0
- data/lib/dorothy2/do-logger.rb +43 -0
- data/lib/dorothy2/do-parsers.rb +468 -0
- data/lib/dorothy2/do-utils.rb +223 -0
- data/lib/dorothy2/environment.rb +29 -0
- data/lib/dorothy2/version.rb +3 -0
- data/lib/dorothy2/vtotal.rb +84 -0
- data/lib/dorothy2.rb +470 -0
- data/share/img/Dorothy-Basic.pdf +0 -0
- data/share/img/Setup-Advanced.pdf +0 -0
- data/share/img/The_big_picture.pdf +0 -0
- data/test/tc_dorothy_full.rb +95 -0
- data/var/log/parser.log +0 -0
- metadata +260 -0
data/lib/dorothy2/BFM.rb
ADDED
@@ -0,0 +1,156 @@
|
|
1
|
+
# Copyright (C) 2010-2013 marco riccardi.
|
2
|
+
# This file is part of Dorothy - http://www.honeynet.it/dorothy
|
3
|
+
# See the file 'LICENSE' for copying permission.
|
4
|
+
|
5
|
+
|
6
|
+
###########################
|
7
|
+
###BINARY FETCHER MODULE###
|
8
|
+
### ###
|
9
|
+
###########################
|
10
|
+
|
11
|
+
module Dorothy
|
12
|
+
|
13
|
+
|
14
|
+
class DorothyFetcher
|
15
|
+
attr_reader :bins
|
16
|
+
|
17
|
+
|
18
|
+
def initialize(source) #source struct: Hash, {:dir => "#{HOME}/bins/honeypot", :typeid=> 0 ..}
|
19
|
+
ndownloaded = 0
|
20
|
+
|
21
|
+
@bins = []
|
22
|
+
#case source.honeypot1[:type]
|
23
|
+
|
24
|
+
case source["type"]
|
25
|
+
|
26
|
+
when "ssh" then
|
27
|
+
LOGGER.info "BFM", " Fetching trojan from > Honeypot"
|
28
|
+
#file = "/opt/dionaea/var/dionaea/binaries/"
|
29
|
+
|
30
|
+
#puts "Start to download malware"
|
31
|
+
|
32
|
+
files = []
|
33
|
+
|
34
|
+
begin
|
35
|
+
Net::SSH.start(source["ip"], source["user"], :password => source["pass"], :port => source["port"]) do |ssh|
|
36
|
+
ssh.scp.download!(source["remotedir"],source["localdir"], :recursive => true) do |ch, name, sent, total|
|
37
|
+
unless files.include? "#{source["localdir"]}/" + File.basename(name)
|
38
|
+
ndownloaded += 1
|
39
|
+
files.push "#{source["localdir"]}/" + File.basename(name)
|
40
|
+
# puts ""
|
41
|
+
end
|
42
|
+
# print "#{File.basename(name)}: #{sent}/#{total}\r"
|
43
|
+
# $stdout.flush
|
44
|
+
end
|
45
|
+
LOGGER.info "BFM", "#{ndownloaded} files downloaded"
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
rescue => e
|
50
|
+
LOGGER.error "BFM", "An error occurred while downloading malwares from honeypot sensor: " + $!
|
51
|
+
LOGGER.error "BFM", "Error: #{$!}, #{e.inspect}, #{e.backtrace}"
|
52
|
+
end
|
53
|
+
|
54
|
+
#DIRTY WORKAROUND for scp-ing only files without directory
|
55
|
+
|
56
|
+
FileUtils.mv(Dir.glob(source["localdir"] + "/binaries/*"), source["localdir"])
|
57
|
+
Dir.rmdir(source["localdir"] + "/binaries")
|
58
|
+
|
59
|
+
|
60
|
+
begin
|
61
|
+
|
62
|
+
unless DoroSettings.env[:testmode]
|
63
|
+
Net::SSH.start(source["ip"], source["user"], :password => source["pass"], :port => source["port"]) do |ssh|
|
64
|
+
ssh.exec "mv #{source["remotedir"]}/* #{source["remotedir"]}/../analyzed "
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
rescue
|
69
|
+
LOGGER.error "BFM", "An error occurred while erasing parsed malwares in the honeypot sensor: " + $!
|
70
|
+
end
|
71
|
+
|
72
|
+
files.each do |f|
|
73
|
+
next unless load_malw(f, source[skey][:typeid])
|
74
|
+
end
|
75
|
+
|
76
|
+
when "system" then
|
77
|
+
LOGGER.info "BFM", "Fetching trojan from > filesystem: " + source["localdir"]
|
78
|
+
empty = true
|
79
|
+
Dir.foreach(source["localdir"]) do |file|
|
80
|
+
bin = source["localdir"] + "/" + file
|
81
|
+
next if File.directory?(bin) || !load_malw(bin,source["typeid"])
|
82
|
+
empty = false
|
83
|
+
end
|
84
|
+
LOGGER.warn "BFM", "There are no files to analyze in the selected source" if empty
|
85
|
+
else
|
86
|
+
LOGGER.fatal "BFM", "Source #{skey} is not yet configured"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
|
92
|
+
private
|
93
|
+
def load_malw(f, typeid, sourceinfo = nil)
|
94
|
+
|
95
|
+
filename = File.basename f
|
96
|
+
bin = Loadmalw.new(f)
|
97
|
+
if bin.size == 0 || bin.sha.empty?
|
98
|
+
LOGGER.warn "BFM", "Warning - Empty file #{filename}, deleting and skipping.."
|
99
|
+
FileUtils.rm bin.binpath
|
100
|
+
return false
|
101
|
+
end
|
102
|
+
|
103
|
+
samplevalues = [bin.sha, bin.size, bin.dbtype, bin.dir_bin, filename, bin.md5, bin.type ]
|
104
|
+
sighvalues = [bin.sha, typeid, bin.ctime, "null"]
|
105
|
+
|
106
|
+
begin
|
107
|
+
updatedb(samplevalues, sighvalues)
|
108
|
+
rescue => e
|
109
|
+
LOGGER.error "DB", $!
|
110
|
+
LOGGER.debug "DB", e.inspect
|
111
|
+
return false
|
112
|
+
end
|
113
|
+
|
114
|
+
#FileUtils.rm(bin.binpath)
|
115
|
+
@bins.push bin
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
|
120
|
+
def updatedb(samplevalues, sighvalues, airisvalues=nil)
|
121
|
+
|
122
|
+
db = Insertdb.new
|
123
|
+
db.begin_t
|
124
|
+
|
125
|
+
unless db.select("samples", "hash", samplevalues[0]).one? #is bin.sha already present in my db?
|
126
|
+
raise "A DB error occurred" unless db.insert("samples", samplevalues) #no it isn't, insert it
|
127
|
+
|
128
|
+
else #yes it is, don't insert in sample table
|
129
|
+
date = db.select("sightings", "sample", samplevalues[0]).first["date"]
|
130
|
+
LOGGER.warn "BFM", " The binary #{samplevalues[0]} has been already added on #{date}"
|
131
|
+
end
|
132
|
+
|
133
|
+
raise "A DB error occurred" unless db.insert("sightings", sighvalues)
|
134
|
+
|
135
|
+
# explanation: I don't want to insert/analyze the same malware but I do want to
|
136
|
+
# insert the sighting value anyway ("the malware X has been downloaded 1 time but
|
137
|
+
# has been spoted 32 times")
|
138
|
+
|
139
|
+
db.commit
|
140
|
+
db.close
|
141
|
+
true
|
142
|
+
|
143
|
+
end
|
144
|
+
|
145
|
+
|
146
|
+
|
147
|
+
end
|
148
|
+
|
149
|
+
end
|
150
|
+
|
151
|
+
|
152
|
+
|
153
|
+
|
154
|
+
|
155
|
+
|
156
|
+
|
data/lib/dorothy2/MAM.rb
ADDED
@@ -0,0 +1,239 @@
|
|
1
|
+
# Copyright (C) 2010-2013 marco riccardi.
|
2
|
+
# This file is part of Dorothy - http://www.honeynet.it/dorothy
|
3
|
+
# See the file 'LICENSE' for copying permission.
|
4
|
+
|
5
|
+
module Dorothy
|
6
|
+
|
7
|
+
class Doro_VSM
|
8
|
+
|
9
|
+
#Creates a new instance for communicating with ESX through the vSpere5's API
|
10
|
+
class ESX
|
11
|
+
|
12
|
+
def initialize(server,user,pass,vmname,guestuser,guestpass)
|
13
|
+
|
14
|
+
begin
|
15
|
+
vim = RbVmomi::VIM.connect(:host => server , :user => user, :password=> pass, :insecure => true)
|
16
|
+
rescue Timeout::Error
|
17
|
+
raise "Fail to connect to the ESXi server #{server} - TimeOut (Are you sure that is the right address?)"
|
18
|
+
end
|
19
|
+
|
20
|
+
@server = server
|
21
|
+
dc = vim.serviceInstance.find_datacenter
|
22
|
+
@vm = dc.find_vm(vmname)
|
23
|
+
|
24
|
+
raise "Virtual Machine #{vmname} not present within ESX!!" if @vm.nil?
|
25
|
+
|
26
|
+
om = vim.serviceContent.guestOperationsManager
|
27
|
+
am = om.authManager
|
28
|
+
@pm = om.processManager
|
29
|
+
@fm = om.fileManager
|
30
|
+
|
31
|
+
#AUTHENTICATION
|
32
|
+
guestauth = {:interactiveSession => false, :username => guestuser, :password => guestpass}
|
33
|
+
@auth=RbVmomi::VIM::NamePasswordAuthentication(guestauth)
|
34
|
+
abort if am.ValidateCredentialsInGuest(:vm => @vm, :auth => @auth) != nil
|
35
|
+
end
|
36
|
+
|
37
|
+
def revert_vm
|
38
|
+
@vm.RevertToCurrentSnapshot_Task
|
39
|
+
end
|
40
|
+
|
41
|
+
def copy_file(filename,file)
|
42
|
+
filepath = "C:\\#{filename}" #put md5 hash
|
43
|
+
|
44
|
+
begin
|
45
|
+
url = @fm.InitiateFileTransferToGuest(:vm => @vm, :auth=> @auth, :guestFilePath=> filepath, :fileSize => file.size, :fileAttributes => '', :overwrite => true).sub('*:443', @server)
|
46
|
+
|
47
|
+
RestClient.put(url, file)
|
48
|
+
|
49
|
+
rescue RbVmomi::Fault
|
50
|
+
LOGGER.error "VSM", "Fail to copy the file #{file} to #{@vm}: #{$!}"
|
51
|
+
abort
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
def exec_file(filename, arguments="")
|
57
|
+
filepath = "C:\\#{filename}"
|
58
|
+
|
59
|
+
if File.extname(filename) == ".dll"
|
60
|
+
cmd = { :programPath => "C:\\windows\\system32\\rundll32.exe", :arguments => filepath}
|
61
|
+
LOGGER.info "VSM", ".:: Executing dll #{filename}"
|
62
|
+
|
63
|
+
else
|
64
|
+
cmd = { :programPath => filepath, :arguments => arguments }
|
65
|
+
end
|
66
|
+
|
67
|
+
pid = @pm.StartProgramInGuest(:vm => @vm , :auth => @auth, :spec => cmd )
|
68
|
+
pid.to_i
|
69
|
+
end
|
70
|
+
|
71
|
+
def check_internet
|
72
|
+
exec_file("windows\\system32\\ping.exe", "-n 1 www.google.com") #make www.google.com customizable, move to doroconf
|
73
|
+
end
|
74
|
+
|
75
|
+
|
76
|
+
def get_status(pid)
|
77
|
+
p = @pm.ListProcessesInGuest(:vm => @vm , :auth => @auth, :pids => Array(pid) ).inspect
|
78
|
+
status = (p =~ /exitCode=>([0-9])/ ? $1.to_i : nil )
|
79
|
+
return status
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
def screenshot
|
84
|
+
a = @vm.CreateScreenshot_Task.wait_for_completion.split(" ")
|
85
|
+
ds = @vm.datastore.find { |ds| ds.name == a[0].delete("[]")}
|
86
|
+
screenpath = "/vmfs/volumes/" + a[0].delete("[]") + "/" + a[1]
|
87
|
+
return screenpath
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
#TODO. Example of how a new VSM´s structure should look like
|
92
|
+
class VirtualBox
|
93
|
+
def initialize
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
def revert_vm
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
def copy_file
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
def exec_file
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
def check_internet
|
110
|
+
|
111
|
+
end
|
112
|
+
|
113
|
+
def get_status
|
114
|
+
|
115
|
+
end
|
116
|
+
|
117
|
+
def screenshot
|
118
|
+
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
class Loadmalw
|
125
|
+
attr_reader :pcaprid
|
126
|
+
attr_reader :type
|
127
|
+
attr_reader :dbtype
|
128
|
+
attr_accessor :sha
|
129
|
+
attr_reader :md5
|
130
|
+
attr_reader :binpath
|
131
|
+
attr_reader :filename
|
132
|
+
attr_reader :ctime
|
133
|
+
attr_reader :size
|
134
|
+
attr_reader :pcapsize
|
135
|
+
attr_reader :extension
|
136
|
+
attr_accessor :sourceinfo #used for storing info about where the binary come from (if needed)
|
137
|
+
|
138
|
+
# attr_accessor :dir_home
|
139
|
+
attr_accessor :dir_pcap
|
140
|
+
attr_accessor :dir_bin
|
141
|
+
attr_accessor :dir_screens
|
142
|
+
attr_accessor :dir_downloads
|
143
|
+
|
144
|
+
def initialize(file)
|
145
|
+
|
146
|
+
fm = FileMagic.new
|
147
|
+
sha = Digest::SHA2.new
|
148
|
+
md5 = Digest::MD5.new
|
149
|
+
@binpath = file
|
150
|
+
@filename = File.basename file
|
151
|
+
@extension = File.extname file
|
152
|
+
@dbtype = "null" #TODO: remove type column in sample table
|
153
|
+
|
154
|
+
File.open(file, 'rb') do |fh1|
|
155
|
+
while buffer1 = fh1.read(1024)
|
156
|
+
@sha = sha << buffer1
|
157
|
+
@md5 = md5 << buffer1
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
@sha = @sha.to_s
|
162
|
+
@md5 = @md5.to_s
|
163
|
+
@sourceinfo = nil
|
164
|
+
|
165
|
+
timetmp = File.ctime(file)
|
166
|
+
@ctime= timetmp.strftime("%m/%d/%y %H:%M:%S")
|
167
|
+
@type = fm.file(file)
|
168
|
+
|
169
|
+
if @extension.empty? #no extension, trying to put the right one..
|
170
|
+
case @type
|
171
|
+
when /^PE32/ then
|
172
|
+
@extension = (@type =~ /DLL/ ? ".dll" : ".exe")
|
173
|
+
when /^MS-DOS/ then
|
174
|
+
@extension = ".bat"
|
175
|
+
when /^HTML/ then
|
176
|
+
@extension = ".html"
|
177
|
+
else
|
178
|
+
@extension = nil
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
|
183
|
+
@size = File.size(file)
|
184
|
+
# @dir_pcap = "#{ANALYSIS_DIR}/#{@md5}/pcap/"
|
185
|
+
# @dir_bin = "#{ANALYSIS_DIR}/#{@md5}/bin/"
|
186
|
+
# @dir_screens = "#{ANALYSIS_DIR}/#{@md5}/screens/"
|
187
|
+
# @dir_downloads = "#{ANALYSIS_DIR}/#{@md5}/downloads/"
|
188
|
+
end
|
189
|
+
|
190
|
+
|
191
|
+
|
192
|
+
def self.calc_pcaprid(file, size)
|
193
|
+
#t = file.split('/')
|
194
|
+
#dumpname = t[t.length - 1]
|
195
|
+
@pcaprid = Digest::MD5.new
|
196
|
+
@pcaprid << "#{file}:#{size}"
|
197
|
+
@pcaprid =@pcaprid.dup.to_s
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
end
|
202
|
+
|
203
|
+
class Doro_NAM
|
204
|
+
|
205
|
+
#Create a dotothy user in the NSM machine, and add this line to the sudoers :
|
206
|
+
# dorothy ALL = NOPASSWD: /usr/sbin/tcpdump, /bin/kill
|
207
|
+
#
|
208
|
+
|
209
|
+
def initialize(namdata)
|
210
|
+
@server = namdata[:host]
|
211
|
+
@user= namdata[:user]
|
212
|
+
@pass= namdata[:pass]
|
213
|
+
@port = namdata[:port]
|
214
|
+
end
|
215
|
+
|
216
|
+
def start_sniffer(vmaddress, interface, name, pcaphome)
|
217
|
+
Net::SSH.start(@server, @user, :password => @pass, :port =>@port) do |@ssh|
|
218
|
+
# @ssh.exec "nohup sudo tcpdump -i eth0 -s 1514 -w ~/pcaps/#{name}.pcap host #{vmaddress} > blah.log 2>&1 & "
|
219
|
+
@ssh.exec "nohup sudo tcpdump -i #{interface} -s 1514 -w #{pcaphome}/#{name}.pcap host #{vmaddress} > log.tmp 2>&1 & "
|
220
|
+
t = @ssh.exec!"ps aux |grep #{vmaddress}|grep -v grep|grep -v bash"
|
221
|
+
pid = t.split(" ")[1]
|
222
|
+
return pid.to_i
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
def stop_sniffer(pid)
|
227
|
+
Net::SSH.start(@server, @user, :password => @pass, :port =>@port) do |ssh|
|
228
|
+
ssh.exec "sudo kill -2 #{pid}"
|
229
|
+
#LOGGER.info "[NAM]".yellow + "Tcpdump instance #{pid} stopped"
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
end
|
234
|
+
|
235
|
+
end
|
236
|
+
|
237
|
+
|
238
|
+
|
239
|
+
|
@@ -0,0 +1,35 @@
|
|
1
|
+
#Thx to morhekil :
|
2
|
+
#http://speakmy.name/2011/05/29/simple-configuration-for-ruby-apps/
|
3
|
+
module Dorothy
|
4
|
+
|
5
|
+
module DoroSettings
|
6
|
+
|
7
|
+
extend self
|
8
|
+
|
9
|
+
@_settings = {}
|
10
|
+
attr_reader :_settings
|
11
|
+
|
12
|
+
def load!(filename, options = {})
|
13
|
+
newsets = YAML::load_file(filename).deep_symbolize
|
14
|
+
newsets = newsets[options[:env].to_sym] if \
|
15
|
+
options[:env] && \
|
16
|
+
newsets[options[:env].to_sym]
|
17
|
+
deep_merge!(@_settings, newsets)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Deep merging of hashes
|
21
|
+
# deep_merge by Stefan Rusterholz, see http://www.ruby-forum.com/topic/142809
|
22
|
+
def deep_merge!(target, data)
|
23
|
+
merger = proc{|key, v1, v2|
|
24
|
+
Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
|
25
|
+
target.merge! data, &merger
|
26
|
+
end
|
27
|
+
|
28
|
+
def method_missing(name, *args, &block)
|
29
|
+
@_settings[name.to_sym] ||
|
30
|
+
fail(NoMethodError, "unknown configuration root #{name}", caller)
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# Symbolizes all of hash's keys and subkeys.
|
2
|
+
# Also allows for custom pre-processing of keys (e.g. downcasing, etc)
|
3
|
+
# if the block is given:
|
4
|
+
#
|
5
|
+
# somehash.deep_symbolize { |key| key.downcase }
|
6
|
+
#
|
7
|
+
# Usage: either include it into global Hash class to make it available to
|
8
|
+
# to all hashes, or extend only your own hash objects with this
|
9
|
+
# module.
|
10
|
+
# E.g.:
|
11
|
+
# 1) class Hash; include DeepSymbolizable; end
|
12
|
+
# 2) myhash.extend DeepSymbolizable
|
13
|
+
|
14
|
+
module DeepSymbolizable
|
15
|
+
|
16
|
+
class Hash
|
17
|
+
include DeepSymbolizable
|
18
|
+
end
|
19
|
+
|
20
|
+
def deep_symbolize(&block)
|
21
|
+
method = self.class.to_s.downcase.to_sym
|
22
|
+
syms = DeepSymbolizable::Symbolizers
|
23
|
+
syms.respond_to?(method) ? syms.send(method, self, &block) : self
|
24
|
+
end
|
25
|
+
|
26
|
+
module Symbolizers
|
27
|
+
extend self
|
28
|
+
|
29
|
+
# the primary method - symbolizes keys of the given hash,
|
30
|
+
# preprocessing them with a block if one was given, and recursively
|
31
|
+
# going into all nested enumerables
|
32
|
+
def hash(hash, &block)
|
33
|
+
hash.inject({}) do |result, (key, value)|
|
34
|
+
# Recursively deep-symbolize subhashes
|
35
|
+
value = _recurse_(value, &block)
|
36
|
+
|
37
|
+
# Pre-process the key with a block if it was given
|
38
|
+
key = yield key if block_given?
|
39
|
+
# Symbolize the key string if it responds to to_sym
|
40
|
+
sym_key = key.to_sym rescue key
|
41
|
+
|
42
|
+
# write it back into the result and return the updated hash
|
43
|
+
result[sym_key] = value
|
44
|
+
result
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# walking over arrays and symbolizing all nested elements
|
49
|
+
def array(ary, &block)
|
50
|
+
ary.map { |v| _recurse_(v, &block) }
|
51
|
+
end
|
52
|
+
|
53
|
+
# handling recursion - any Enumerable elements (except String)
|
54
|
+
# is being extended with the module, and then symbolized
|
55
|
+
def _recurse_(value, &block)
|
56
|
+
if value.is_a?(Enumerable) && !value.is_a?(String)
|
57
|
+
# support for a use case without extended core Hash
|
58
|
+
value.extend DeepSymbolizable unless value.class.include?(DeepSymbolizable)
|
59
|
+
value = value.deep_symbolize(&block)
|
60
|
+
end
|
61
|
+
value
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
|
67
|
+
class Hash; include DeepSymbolizable; end
|