scnr-introspector 0.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/scnr/introspector/data_flow/sink.rb +27 -0
- data/lib/scnr/introspector/execution_flow/point.rb +23 -1
- data/lib/scnr/introspector/execution_flow.rb +3 -0
- data/lib/scnr/introspector/utilities/my_method_source/code_helpers.rb +156 -0
- data/lib/scnr/introspector/version +1 -1
- data/lib/scnr/introspector.rb +97 -46
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8725bd56ebb19d4dc43d75c4c19a2fde62996e6b0df84ac074f7baf5049f95db
|
4
|
+
data.tar.gz: b5b35b5cd86bbb75554b2ba8b144f87606b17990c90351df37417f3ec79cd3f2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2ce2e0fa5215945851e1249a00f258a9ad542e090fe5f64402d2f804e1944f472c46438468a86b3fbf6cbb7d2e4aa2a092b89f5a7d50a90b92007b9a1bd1ca88
|
7
|
+
data.tar.gz: 5f281728c6423aa8222eb3b362dbac6a5715c60361a8d52666ea37a9b075542bac4fa4342e3b26e2aff80bb57bedaa7da44c8eb57ffc8e56cfb26467c3018b6d
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require_relative '../utilities/my_method_source/code_helpers'
|
2
|
+
|
1
3
|
module SCNR
|
2
4
|
class Introspector
|
3
5
|
class DataFlow
|
@@ -7,6 +9,10 @@ class Sink
|
|
7
9
|
attr_accessor :object
|
8
10
|
|
9
11
|
attr_accessor :method_name
|
12
|
+
attr_accessor :method_source
|
13
|
+
attr_accessor :method_source_location
|
14
|
+
|
15
|
+
attr_accessor :source
|
10
16
|
|
11
17
|
attr_accessor :arguments
|
12
18
|
|
@@ -23,6 +29,27 @@ class Sink
|
|
23
29
|
|
24
30
|
send( "#{k}=", v )
|
25
31
|
end
|
32
|
+
|
33
|
+
if !@method_source && @method_source_location
|
34
|
+
filepath = @method_source_location.first
|
35
|
+
lineno = @method_source_location.last
|
36
|
+
|
37
|
+
if File.exists? filepath
|
38
|
+
File.open filepath do |f|
|
39
|
+
begin
|
40
|
+
@method_source = MyMethodSource::CodeHelpers.expression_at( File.open( f ), lineno )
|
41
|
+
rescue SyntaxError
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
if !@source && @backtrace
|
48
|
+
source_location = @backtrace.first.split( ':' ).first
|
49
|
+
if File.exists? source_location
|
50
|
+
@source = IO.read( source_location )
|
51
|
+
end
|
52
|
+
end
|
26
53
|
end
|
27
54
|
|
28
55
|
def marshal_dump
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
1
3
|
module SCNR
|
2
4
|
class Introspector
|
3
5
|
class ExecutionFlow
|
@@ -26,6 +28,9 @@ class Point
|
|
26
28
|
# Event name.
|
27
29
|
attr_accessor :event
|
28
30
|
|
31
|
+
attr_accessor :source
|
32
|
+
attr_accessor :file_contents
|
33
|
+
|
29
34
|
# @param [Hash] options
|
30
35
|
def initialize( options = {} )
|
31
36
|
options.each do |k, v|
|
@@ -76,10 +81,27 @@ class Point
|
|
76
81
|
line_number: tp.lineno,
|
77
82
|
class_name: defined_class,
|
78
83
|
method_name: tp.method_id,
|
79
|
-
event: tp.event
|
84
|
+
event: tp.event,
|
85
|
+
source: source_line( tp.path, tp.lineno ),
|
86
|
+
file_contents: IO.read( tp.path )
|
80
87
|
})
|
81
88
|
end
|
89
|
+
|
90
|
+
def source_line_mutex( &block )
|
91
|
+
(@mutex ||= Mutex.new).synchronize( &block )
|
92
|
+
end
|
93
|
+
|
94
|
+
def source_line( path, line )
|
95
|
+
return if !path || !line
|
96
|
+
|
97
|
+
source_line_mutex do
|
98
|
+
@@lines ||= {}
|
99
|
+
@@lines[path] ||= IO.readlines( path )
|
100
|
+
@@lines[path][line-1]
|
101
|
+
end
|
102
|
+
end
|
82
103
|
end
|
104
|
+
source_line_mutex {}
|
83
105
|
|
84
106
|
end
|
85
107
|
|
@@ -6,6 +6,8 @@ class Introspector
|
|
6
6
|
|
7
7
|
class ExecutionFlow
|
8
8
|
|
9
|
+
ALLOWED_EVENTS = Set.new([:line, :call, :c_call])
|
10
|
+
|
9
11
|
# @return [Scope]
|
10
12
|
attr_accessor :scope
|
11
13
|
|
@@ -49,6 +51,7 @@ class ExecutionFlow
|
|
49
51
|
# `self`
|
50
52
|
def trace( &block )
|
51
53
|
TracePoint.new do |tp|
|
54
|
+
next if !ALLOWED_EVENTS.include? tp.event
|
52
55
|
next if @scope.out?( tp.path )
|
53
56
|
|
54
57
|
@points << create_point_from_trace_point( tp )
|
@@ -0,0 +1,156 @@
|
|
1
|
+
module MyMethodSource
|
2
|
+
|
3
|
+
module CodeHelpers
|
4
|
+
# Retrieve the first expression starting on the given line of the given file.
|
5
|
+
#
|
6
|
+
# This is useful to get module or method source code.
|
7
|
+
#
|
8
|
+
# @param [Array<String>, File, String] file The file to parse, either as a File or as
|
9
|
+
# @param [Integer] line_number The line number at which to look.
|
10
|
+
# NOTE: The first line in a file is
|
11
|
+
# line 1!
|
12
|
+
# @param [Hash] options The optional configuration parameters.
|
13
|
+
# @option options [Boolean] :strict If set to true, then only completely
|
14
|
+
# valid expressions are returned. Otherwise heuristics are used to extract
|
15
|
+
# expressions that may have been valid inside an eval.
|
16
|
+
# @option options [Integer] :consume A number of lines to automatically
|
17
|
+
# consume (add to the expression buffer) without checking for validity.
|
18
|
+
# @return [String] The first complete expression
|
19
|
+
# @raise [SyntaxError] If the first complete expression can't be identified
|
20
|
+
def expression_at(file, line_number, options={})
|
21
|
+
options = {
|
22
|
+
:strict => false,
|
23
|
+
:consume => 0
|
24
|
+
}.merge!(options)
|
25
|
+
|
26
|
+
lines = file.is_a?(Array) ? file : file.each_line.to_a
|
27
|
+
|
28
|
+
relevant_lines = lines[(line_number - 1)..-1] || []
|
29
|
+
|
30
|
+
extract_first_expression(relevant_lines, options[:consume])
|
31
|
+
rescue SyntaxError => e
|
32
|
+
raise if options[:strict]
|
33
|
+
|
34
|
+
begin
|
35
|
+
extract_first_expression(relevant_lines) do |code|
|
36
|
+
code.gsub(/\#\{.*?\}/, "temp")
|
37
|
+
end
|
38
|
+
rescue SyntaxError
|
39
|
+
raise e
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Retrieve the comment describing the expression on the given line of the given file.
|
44
|
+
#
|
45
|
+
# This is useful to get module or method documentation.
|
46
|
+
#
|
47
|
+
# @param [Array<String>, File, String] file The file to parse, either as a File or as
|
48
|
+
# a String or an Array of lines.
|
49
|
+
# @param [Integer] line_number The line number at which to look.
|
50
|
+
# NOTE: The first line in a file is line 1!
|
51
|
+
# @return [String] The comment
|
52
|
+
def comment_describing(file, line_number)
|
53
|
+
lines = file.is_a?(Array) ? file : file.each_line.to_a
|
54
|
+
|
55
|
+
extract_last_comment(lines[0..(line_number - 2)])
|
56
|
+
end
|
57
|
+
|
58
|
+
# Determine if a string of code is a complete Ruby expression.
|
59
|
+
# @param [String] code The code to validate.
|
60
|
+
# @return [Boolean] Whether or not the code is a complete Ruby expression.
|
61
|
+
# @raise [SyntaxError] Any SyntaxError that does not represent incompleteness.
|
62
|
+
# @example
|
63
|
+
# complete_expression?("class Hello") #=> false
|
64
|
+
# complete_expression?("class Hello; end") #=> true
|
65
|
+
# complete_expression?("class 123") #=> SyntaxError: unexpected tINTEGER
|
66
|
+
def complete_expression?(str)
|
67
|
+
old_verbose = $VERBOSE
|
68
|
+
$VERBOSE = nil
|
69
|
+
|
70
|
+
catch(:valid) do
|
71
|
+
eval("BEGIN{throw :valid}\n#{str}")
|
72
|
+
end
|
73
|
+
|
74
|
+
# Assert that a line which ends with a , or \ is incomplete.
|
75
|
+
str !~ /[,\\]\s*\z/
|
76
|
+
rescue IncompleteExpression
|
77
|
+
false
|
78
|
+
ensure
|
79
|
+
$VERBOSE = old_verbose
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# Get the first expression from the input.
|
85
|
+
#
|
86
|
+
# @param [Array<String>] lines
|
87
|
+
# @param [Integer] consume A number of lines to automatically
|
88
|
+
# consume (add to the expression buffer) without checking for validity.
|
89
|
+
# @yield a clean-up function to run before checking for complete_expression
|
90
|
+
# @return [String] a valid ruby expression
|
91
|
+
# @raise [SyntaxError]
|
92
|
+
def extract_first_expression(lines, consume=0, &block)
|
93
|
+
code = consume.zero? ? +"" : lines.slice!(0..(consume - 1)).join
|
94
|
+
|
95
|
+
lines.each do |v|
|
96
|
+
code << v
|
97
|
+
return code if complete_expression?(block ? block.call(code) : code)
|
98
|
+
end
|
99
|
+
raise SyntaxError, "unexpected $end"
|
100
|
+
end
|
101
|
+
|
102
|
+
# Get the last comment from the input.
|
103
|
+
#
|
104
|
+
# @param [Array<String>] lines
|
105
|
+
# @return [String]
|
106
|
+
def extract_last_comment(lines)
|
107
|
+
buffer = +""
|
108
|
+
|
109
|
+
lines.each do |line|
|
110
|
+
# Add any line that is a valid ruby comment,
|
111
|
+
# but clear as soon as we hit a non comment line.
|
112
|
+
if (line =~ /^\s*#/) || (line =~ /^\s*$/)
|
113
|
+
buffer << line.lstrip
|
114
|
+
else
|
115
|
+
buffer.clear
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
buffer
|
120
|
+
end
|
121
|
+
|
122
|
+
# An exception matcher that matches only subsets of SyntaxErrors that can be
|
123
|
+
# fixed by adding more input to the buffer.
|
124
|
+
module IncompleteExpression
|
125
|
+
GENERIC_REGEXPS = [
|
126
|
+
/unexpected (\$end|end-of-file|end-of-input|END_OF_FILE)/, # mri, jruby, ruby-2.0, ironruby
|
127
|
+
/embedded document meets end of file/, # =begin
|
128
|
+
/unterminated (quoted string|string|regexp|list) meets end of file/, # "quoted string" is ironruby
|
129
|
+
/can't find string ".*" anywhere before EOF/, # rbx and jruby
|
130
|
+
/missing 'end' for/, /expecting kWHEN/ # rbx
|
131
|
+
]
|
132
|
+
|
133
|
+
RBX_ONLY_REGEXPS = [
|
134
|
+
/expecting '[})\]]'(?:$|:)/, /expecting keyword_end/
|
135
|
+
]
|
136
|
+
|
137
|
+
def self.===(ex)
|
138
|
+
return false unless SyntaxError === ex
|
139
|
+
case ex.message
|
140
|
+
when *GENERIC_REGEXPS
|
141
|
+
true
|
142
|
+
when *RBX_ONLY_REGEXPS
|
143
|
+
rbx?
|
144
|
+
else
|
145
|
+
false
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def self.rbx?
|
150
|
+
RbConfig::CONFIG['ruby_install_name'] == 'rbx'
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
extend self
|
155
|
+
end
|
156
|
+
end
|
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.3.0
|
data/lib/scnr/introspector.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
require 'rbconfig'
|
2
|
+
require 'securerandom'
|
2
3
|
require 'rack/utils'
|
4
|
+
require 'base64'
|
3
5
|
require 'pp'
|
4
6
|
|
5
7
|
module SCNR
|
@@ -23,59 +25,41 @@ class Introspector
|
|
23
25
|
module Overloads
|
24
26
|
end
|
25
27
|
|
26
|
-
OVERLOAD.each do |m, object|
|
27
|
-
if object.is_a? Array
|
28
|
-
name = object.pop
|
29
|
-
namespace = Object
|
30
|
-
|
31
|
-
n = false
|
32
|
-
object.each do |o|
|
33
|
-
begin
|
34
|
-
namespace = namespace.const_get( o )
|
35
|
-
rescue
|
36
|
-
n = true
|
37
|
-
break
|
38
|
-
end
|
39
|
-
end
|
40
|
-
next if n
|
41
|
-
|
42
|
-
object = namespace.const_get( name ) rescue next
|
43
|
-
else
|
44
|
-
object = Object.const_get( object ) rescue next
|
45
|
-
end
|
46
|
-
|
47
|
-
overload( object, m )
|
48
|
-
end
|
49
|
-
|
50
28
|
@mutex = Mutex.new
|
51
29
|
class <<self
|
52
30
|
def overload( object, m )
|
31
|
+
method_source_location = object.allocate.method(m).source_location
|
32
|
+
rnd = SecureRandom.hex(10)
|
33
|
+
|
53
34
|
ov = <<EORUBY
|
54
35
|
module Overloads
|
55
|
-
module #{object.to_s.split( '::' ).join}Overload
|
36
|
+
module #{object.to_s.split( '::' ).join}#{rnd}Overload
|
56
37
|
def #{m}( *args )
|
57
|
-
SCNR::Introspector.find_and_log_taint( #{object}, :#{m}, args )
|
38
|
+
SCNR::Introspector.find_and_log_taint( #{object}, :#{m}, #{method_source_location.inspect}, args )
|
58
39
|
super *args
|
59
40
|
end
|
60
41
|
end
|
61
42
|
end
|
62
43
|
|
63
|
-
#{object}.prepend Overloads::#{object.to_s.split( '::' ).join}Overload
|
44
|
+
#{object}.prepend Overloads::#{object.to_s.split( '::' ).join}#{rnd}Overload
|
64
45
|
EORUBY
|
65
46
|
eval ov
|
66
|
-
|
47
|
+
rescue => e
|
48
|
+
# puts ov
|
49
|
+
# pp e
|
50
|
+
# pp e.backtrace
|
67
51
|
end
|
68
52
|
|
69
53
|
def taint_seed=( t )
|
70
|
-
|
54
|
+
Thread.current[:taint] = t
|
71
55
|
end
|
72
56
|
|
73
57
|
def taint_seed
|
74
|
-
|
58
|
+
Thread.current[:taint]
|
75
59
|
end
|
76
60
|
|
77
61
|
def data_flows
|
78
|
-
|
62
|
+
Thread.current[:data_flows] ||= {}
|
79
63
|
end
|
80
64
|
|
81
65
|
def synchronize( &block )
|
@@ -88,6 +72,12 @@ EORUBY
|
|
88
72
|
end
|
89
73
|
end
|
90
74
|
|
75
|
+
def flush_sinks( taint )
|
76
|
+
synchronize do
|
77
|
+
self.data_flows.delete taint
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
91
81
|
def filter_caller( a )
|
92
82
|
dir = File.dirname( __FILE__ )
|
93
83
|
a.reject do |c|
|
@@ -95,8 +85,8 @@ EORUBY
|
|
95
85
|
end
|
96
86
|
end
|
97
87
|
|
98
|
-
def find_and_log_taint( object, method, args )
|
99
|
-
taint =
|
88
|
+
def find_and_log_taint( object, method, method_source_location, args )
|
89
|
+
taint = self.taint_seed
|
100
90
|
return if !taint
|
101
91
|
|
102
92
|
tainted = find_taint_in_arguments( taint, args )
|
@@ -108,7 +98,8 @@ EORUBY
|
|
108
98
|
arguments: args,
|
109
99
|
tainted_argument_index: tainted[0],
|
110
100
|
tainted_value: tainted[1].to_s,
|
111
|
-
backtrace: filter_caller( Kernel.caller )
|
101
|
+
backtrace: filter_caller( Kernel.caller[1..-1] ),
|
102
|
+
method_source_location: method_source_location
|
112
103
|
)
|
113
104
|
log_sinks( taint, sink )
|
114
105
|
end
|
@@ -149,19 +140,65 @@ EORUBY
|
|
149
140
|
end
|
150
141
|
end
|
151
142
|
|
143
|
+
OVERLOAD.each do |m, object|
|
144
|
+
if object.is_a? Array
|
145
|
+
name = object.pop
|
146
|
+
namespace = Object
|
147
|
+
|
148
|
+
n = false
|
149
|
+
object.each do |o|
|
150
|
+
begin
|
151
|
+
namespace = namespace.const_get( o )
|
152
|
+
rescue
|
153
|
+
n = true
|
154
|
+
break
|
155
|
+
end
|
156
|
+
end
|
157
|
+
next if n
|
158
|
+
|
159
|
+
object = namespace.const_get( name ) rescue next
|
160
|
+
else
|
161
|
+
object = Object.const_get( object ) rescue next
|
162
|
+
end
|
163
|
+
|
164
|
+
overload( object, m )
|
165
|
+
end
|
166
|
+
|
152
167
|
def initialize( app, options = {} )
|
153
168
|
@app = app
|
154
169
|
@options = options
|
155
170
|
|
156
171
|
overload_application
|
172
|
+
overload_rails if rails?
|
157
173
|
|
158
174
|
@mutex = Mutex.new
|
159
175
|
end
|
160
176
|
|
161
177
|
def overload_application
|
162
|
-
@app.
|
163
|
-
|
164
|
-
|
178
|
+
overload_class @app.class
|
179
|
+
end
|
180
|
+
|
181
|
+
def overload_rails
|
182
|
+
Rails.application.eager_load!
|
183
|
+
|
184
|
+
klasses = [
|
185
|
+
ActionController::Base,
|
186
|
+
ActiveRecord::Base
|
187
|
+
]
|
188
|
+
descendants = klasses.map do |k|
|
189
|
+
ObjectSpace.each_object( Class ).select { |klass| klass < k }
|
190
|
+
end.flatten.reject { |k| k.to_s.start_with? '#' }
|
191
|
+
|
192
|
+
descendants.each do |klass|
|
193
|
+
overload_class klass
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def overload_class( klass )
|
198
|
+
k = klass.allocate
|
199
|
+
k.methods.each do |m|
|
200
|
+
next if k.method( m ).parameters.empty?
|
201
|
+
self.class.overload( klass, m )
|
165
202
|
end
|
166
203
|
end
|
167
204
|
|
@@ -174,10 +211,13 @@ EORUBY
|
|
174
211
|
info << :platforms
|
175
212
|
|
176
213
|
if env.delete( 'HTTP_X_SCNR_INTROSPECTOR_TRACE' )
|
177
|
-
info << :data_flow
|
178
214
|
info << :execution_flow
|
179
215
|
end
|
180
216
|
|
217
|
+
if env['HTTP_X_SCNR_INTROSPECTOR_TAINT']
|
218
|
+
info << :data_flow
|
219
|
+
end
|
220
|
+
|
181
221
|
inject( env, info )
|
182
222
|
|
183
223
|
rescue => e
|
@@ -187,7 +227,12 @@ EORUBY
|
|
187
227
|
|
188
228
|
def inject( env, info = [] )
|
189
229
|
self.class.taint_seed = env.delete( 'HTTP_X_SCNR_INTROSPECTOR_TAINT' )
|
190
|
-
|
230
|
+
if self.class.taint_seed
|
231
|
+
self.class.taint_seed = Base64.decode64( self.class.taint_seed )
|
232
|
+
self.class.taint_seed = nil if self.class.taint_seed.empty?
|
233
|
+
end
|
234
|
+
|
235
|
+
seed = env.delete( 'HTTP_X_SCNR_ENGINE_SCAN_SEED' )
|
191
236
|
|
192
237
|
data = {}
|
193
238
|
|
@@ -215,17 +260,25 @@ EORUBY
|
|
215
260
|
end
|
216
261
|
|
217
262
|
if info.include?( :data_flow ) && self.class.taint_seed
|
218
|
-
data['data_flow'] = self.class.
|
263
|
+
data['data_flow'] = self.class.flush_sinks( self.class.taint_seed )&.to_rpc_data
|
219
264
|
end
|
220
265
|
|
221
266
|
code = response.shift
|
222
267
|
headers = response.shift
|
223
268
|
body = response.shift
|
224
269
|
|
225
|
-
|
226
|
-
|
270
|
+
if headers['Content-Type'] && headers['Content-Type'].include?( 'html' )
|
271
|
+
body = body.respond_to?( :body ) ? body.body : body
|
272
|
+
body = [body].flatten
|
273
|
+
body << "<!-- #{seed}\n#{JSON.dump( data )}\n#{seed} -->"
|
274
|
+
|
275
|
+
headers['Content-Length'] = body.map(&:bytesize).inject(:+)
|
276
|
+
end
|
227
277
|
|
228
|
-
[code, headers, body ]
|
278
|
+
[code, headers, [body].flatten ]
|
279
|
+
rescue => e
|
280
|
+
pp e
|
281
|
+
pp e.backtrace
|
229
282
|
end
|
230
283
|
|
231
284
|
def platforms
|
@@ -282,9 +335,7 @@ EORUBY
|
|
282
335
|
end
|
283
336
|
|
284
337
|
def rails?
|
285
|
-
|
286
|
-
return @app.is_a? Rails::Application
|
287
|
-
end
|
338
|
+
!!defined?( Rails )
|
288
339
|
end
|
289
340
|
|
290
341
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: scnr-introspector
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tasos Laskos
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-01-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -100,6 +100,7 @@ files:
|
|
100
100
|
- lib/scnr/introspector/execution_flow/point.rb
|
101
101
|
- lib/scnr/introspector/execution_flow/scope.rb
|
102
102
|
- lib/scnr/introspector/scope.rb
|
103
|
+
- lib/scnr/introspector/utilities/my_method_source/code_helpers.rb
|
103
104
|
- lib/scnr/introspector/version
|
104
105
|
- lib/scnr/introspector/version.rb
|
105
106
|
homepage: http://ecsypno.com
|