dorothy2 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/.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
|