scnr-introspector 0.1 → 0.3.0
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.
- 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
|