rack-libinjection 0.1.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 +7 -0
- data/.github/workflows/ci.yml +55 -0
- data/CHANGELOG.md +112 -0
- data/GET_STARTED.md +418 -0
- data/LICENSE-libinjection.txt +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +68 -0
- data/SECURITY.md +65 -0
- data/ext/libinjection/extconf.rb +113 -0
- data/ext/libinjection/libinjection_ext.c +1132 -0
- data/ext/libinjection/vendor/libinjection/.vendored +5 -0
- data/ext/libinjection/vendor/libinjection/COPYING +33 -0
- data/ext/libinjection/vendor/libinjection/MIGRATION.md +393 -0
- data/ext/libinjection/vendor/libinjection/README.md +251 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection.h +70 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_error.h +26 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.c +830 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_html5.h +56 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.c +2342 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli.h +297 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_sqli_data.h +9651 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.c +1203 -0
- data/ext/libinjection/vendor/libinjection/src/libinjection_xss.h +23 -0
- data/lib/libinjection/version.rb +6 -0
- data/lib/libinjection.rb +31 -0
- data/lib/rack/libinjection.rb +586 -0
- data/lib/rack-libinjection.rb +3 -0
- data/samples/README.md +67 -0
- data/samples/libinjection_detect_raw_hot_path.rb +161 -0
- data/samples/rack_all_surfaces_hot_path.rb +198 -0
- data/samples/rack_params_hot_path.rb +166 -0
- data/samples/rack_query_hot_path.rb +176 -0
- data/samples/results/.gitkeep +0 -0
- data/script/fuzz_smoke.rb +39 -0
- data/script/vendor_libs.rb +227 -0
- data/test/test_helper.rb +7 -0
- data/test/test_libinjection.rb +223 -0
- data/test/test_middleware.rb +404 -0
- metadata +148 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rack middleware hot-path sample for the raw query-string surface:
|
|
4
|
+
# Rack::LibInjection.new(app, scan: [:query]).call(env)
|
|
5
|
+
#
|
|
6
|
+
# This path avoids Rack nested param parsing and scans the raw query string plus
|
|
7
|
+
# decoded variants. It is a fast WAF-style signal surface, not a semantic params
|
|
8
|
+
# walker replacement.
|
|
9
|
+
#
|
|
10
|
+
# Run:
|
|
11
|
+
# bundle exec ruby samples/rack_query_hot_path.rb
|
|
12
|
+
#
|
|
13
|
+
# Optional env:
|
|
14
|
+
# PAYLOAD=clean|sqli|xss DURATION=20 PREHEAT_ITERATIONS=100 DISABLE_GC=0 \
|
|
15
|
+
# THREATS=sqli,xss PATH_DECODE_DEPTH=2 bundle exec ruby samples/rack_query_hot_path.rb
|
|
16
|
+
|
|
17
|
+
$stdout.sync = true
|
|
18
|
+
$stderr.sync = true
|
|
19
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
20
|
+
|
|
21
|
+
require "rack/mock"
|
|
22
|
+
require "rack/libinjection"
|
|
23
|
+
|
|
24
|
+
payload_mode = ENV.fetch("PAYLOAD", "clean")
|
|
25
|
+
threats = ENV.fetch("THREATS", "sqli,xss").split(",").map { |value| value.strip.to_sym }
|
|
26
|
+
path_decode_depth = Integer(ENV.fetch("PATH_DECODE_DEPTH", "2"))
|
|
27
|
+
sleep_before_hot_loop = Float(ENV.fetch("SLEEP_BEFORE_HOT_LOOP", "7.0"))
|
|
28
|
+
duration = Float(ENV.fetch("DURATION", "20.0"))
|
|
29
|
+
preheat_iterations = Integer(ENV.fetch("PREHEAT_ITERATIONS", "100"))
|
|
30
|
+
disable_gc = ENV.fetch("DISABLE_GC", "0") != "0"
|
|
31
|
+
sample_name = "rack_libinjection_query_hot_path"
|
|
32
|
+
native_grep = "Rack::LibInjection|rack/libinjection|LibInjection|libinjection|rb_li_|li_input_|li_scan_|libinjection_sqli|libinjection_xss|collect_attacks_from_env|scan_query_into|scan_query_value_into|scan_url_encoded_string_into|scan_string_into|detect_url_encoded_raw|li_url_decode|li_url_encoded_candidate"
|
|
33
|
+
|
|
34
|
+
ATTACK_ENV_KEY = Rack::LibInjection::ATTACK_ENV_KEY
|
|
35
|
+
|
|
36
|
+
def monotonic
|
|
37
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def gc_snapshot
|
|
41
|
+
stat = GC.stat
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
total_allocated_objects: stat.fetch(:total_allocated_objects),
|
|
45
|
+
minor_gc_count: stat.fetch(:minor_gc_count),
|
|
46
|
+
major_gc_count: stat.fetch(:major_gc_count)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def gc_delta(before, after)
|
|
51
|
+
before.each_with_object({}) do |(key, value), out|
|
|
52
|
+
out[key] = after.fetch(key) - value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def payload_for(mode)
|
|
57
|
+
case mode
|
|
58
|
+
when "clean" then "normal search term"
|
|
59
|
+
when "sqli" then "1 OR 1=1--"
|
|
60
|
+
when "xss" then "<script>alert(1)</script>"
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "PAYLOAD must be one of: clean, sqli, xss"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expected_min_attacks(mode, threats)
|
|
67
|
+
return 0 if mode == "clean"
|
|
68
|
+
return 0 if mode == "sqli" && !threats.include?(:sqli)
|
|
69
|
+
return 0 if mode == "xss" && !threats.include?(:xss)
|
|
70
|
+
|
|
71
|
+
1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_query(payload)
|
|
75
|
+
Rack::Utils.build_nested_query(
|
|
76
|
+
"q" => payload,
|
|
77
|
+
"profile" => {
|
|
78
|
+
"name" => "Roman",
|
|
79
|
+
"filters" => ["recent", payload, "safe"]
|
|
80
|
+
},
|
|
81
|
+
"page" => "1"
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
payload = payload_for(payload_mode)
|
|
86
|
+
expected_min = expected_min_attacks(payload_mode, threats)
|
|
87
|
+
query = build_query(payload)
|
|
88
|
+
path = "/search?#{query}"
|
|
89
|
+
base_env = Rack::MockRequest.env_for(path)
|
|
90
|
+
|
|
91
|
+
app = ->(_env) { [204, {}, []] }
|
|
92
|
+
middleware = Rack::LibInjection.new(
|
|
93
|
+
app,
|
|
94
|
+
mode: :report,
|
|
95
|
+
scan: [:query],
|
|
96
|
+
threats: threats,
|
|
97
|
+
path_decode_depth: path_decode_depth,
|
|
98
|
+
notify_skipped: false
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
preheat_iterations.times do
|
|
102
|
+
env = base_env.dup
|
|
103
|
+
status, = middleware.call(env)
|
|
104
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
105
|
+
raise "preheat: unexpected status #{status}" unless status == 204
|
|
106
|
+
raise "preheat: expected >= #{expected_min} attacks, got #{attacks.inspect}" if attacks.length < expected_min
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
sample_seconds = (sleep_before_hot_loop + duration + 15).ceil
|
|
110
|
+
sample_file = "/tmp/#{sample_name}.sample"
|
|
111
|
+
txt_file = File.expand_path("results/#{sample_name}.txt", __dir__)
|
|
112
|
+
txt_dir = File.dirname(txt_file)
|
|
113
|
+
|
|
114
|
+
puts "pid=#{Process.pid}"
|
|
115
|
+
puts "ruby=#{RUBY_DESCRIPTION}"
|
|
116
|
+
puts "platform=#{RUBY_PLATFORM}"
|
|
117
|
+
puts "mode=#{sample_name}"
|
|
118
|
+
puts "call=Rack::LibInjection.new(app, scan: [:query]).call(env)"
|
|
119
|
+
puts "payload=#{payload_mode}"
|
|
120
|
+
puts "threats=#{threats.join(',')}"
|
|
121
|
+
puts "path_decode_depth=#{path_decode_depth}"
|
|
122
|
+
puts "query_bytes=#{query.bytesize}"
|
|
123
|
+
puts "disable_gc=#{disable_gc}"
|
|
124
|
+
puts "duration=#{duration}"
|
|
125
|
+
puts "preheat_iterations=#{preheat_iterations}"
|
|
126
|
+
puts "sample_seconds=#{sample_seconds}"
|
|
127
|
+
puts "sample_file=#{sample_file}"
|
|
128
|
+
puts "txt_file=#{txt_file}"
|
|
129
|
+
puts
|
|
130
|
+
puts "Copy this one-line capture command:"
|
|
131
|
+
puts %(mkdir -p "#{txt_dir}"; OUT="#{txt_file}"; SAMPLE="#{sample_file}"; { sample #{Process.pid} #{sample_seconds} -f "$SAMPLE"; echo; echo "===== focused Rack/native symbols ====="; filtercalltree "$SAMPLE" | grep -E "#{native_grep}" | head -360; echo; echo "===== filtercalltree head -360 ====="; filtercalltree "$SAMPLE" | head -360; } 2>&1 | tee "$OUT")
|
|
132
|
+
puts
|
|
133
|
+
puts "Expected hot symbols:"
|
|
134
|
+
puts " Rack::LibInjection#call / #collect_attacks_from_env"
|
|
135
|
+
puts " Rack::LibInjection#scan_query_value_into / #scan_url_encoded_string_into"
|
|
136
|
+
puts " LibInjection.detect_url_encoded_raw -> rb_li_detect_url_encoded_raw"
|
|
137
|
+
puts " li_input_prepare / li_scan_run / li_scan_perform"
|
|
138
|
+
puts " libinjection_sqli / libinjection_xss"
|
|
139
|
+
puts
|
|
140
|
+
puts "sleep=#{sleep_before_hot_loop} seconds before hot loop"
|
|
141
|
+
puts
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
GC.start
|
|
145
|
+
GC.disable if disable_gc
|
|
146
|
+
before_gc = gc_snapshot
|
|
147
|
+
sleep sleep_before_hot_loop
|
|
148
|
+
|
|
149
|
+
count = 0
|
|
150
|
+
total_attacks = 0
|
|
151
|
+
started = monotonic
|
|
152
|
+
deadline = started + duration
|
|
153
|
+
|
|
154
|
+
while monotonic < deadline
|
|
155
|
+
env = base_env.dup
|
|
156
|
+
status, = middleware.call(env)
|
|
157
|
+
raise "hot_loop: unexpected status #{status}" unless status == 204
|
|
158
|
+
|
|
159
|
+
attacks = env.fetch(ATTACK_ENV_KEY)
|
|
160
|
+
total_attacks += attacks.length
|
|
161
|
+
count += 1
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
elapsed = monotonic - started
|
|
165
|
+
after_gc = gc_snapshot
|
|
166
|
+
|
|
167
|
+
puts "count=#{count}"
|
|
168
|
+
puts "total_attacks=#{total_attacks}"
|
|
169
|
+
puts "attacks_per_request=#{format('%.6f', total_attacks.to_f / [count, 1].max)}"
|
|
170
|
+
puts "elapsed=#{format('%.6f', elapsed)}"
|
|
171
|
+
puts "requests_per_sec=#{format('%.6f', count / elapsed)}"
|
|
172
|
+
puts "sec_per_request=#{format('%.9f', elapsed / [count, 1].max)}"
|
|
173
|
+
puts "gc_delta=#{gc_delta(before_gc, after_gc)}"
|
|
174
|
+
ensure
|
|
175
|
+
GC.enable
|
|
176
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
5
|
+
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "libinjection"
|
|
8
|
+
|
|
9
|
+
PAYLOADS = [
|
|
10
|
+
"",
|
|
11
|
+
"1 OR 1=1--",
|
|
12
|
+
"<script>alert(1)</script>",
|
|
13
|
+
"abc\0def".b,
|
|
14
|
+
"\xFF\xFE1 OR 1=1--".b,
|
|
15
|
+
("a" * 2_048) + " 1 OR 1=1--",
|
|
16
|
+
"1 OR 1=1--".encode(Encoding::UTF_16LE)
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
PAYLOADS.each do |payload|
|
|
20
|
+
LibInjection.detect(payload)
|
|
21
|
+
LibInjection.sqli?(payload)
|
|
22
|
+
LibInjection.xss?(payload)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
1_000.times do |i|
|
|
26
|
+
bytes = SecureRandom.random_bytes(rand(0..4096))
|
|
27
|
+
bytes.force_encoding([Encoding::BINARY, Encoding::UTF_8].sample)
|
|
28
|
+
|
|
29
|
+
LibInjection.detect_raw(bytes)
|
|
30
|
+
LibInjection.sqli?(bytes)
|
|
31
|
+
LibInjection.xss?(bytes)
|
|
32
|
+
rescue LibInjection::ParserError
|
|
33
|
+
next
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
warn "fuzz smoke failed at iteration #{i}: #{e.class}: #{e.message}"
|
|
36
|
+
raise
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts "fuzz smoke: ok"
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "rubygems/package"
|
|
10
|
+
require "tmpdir"
|
|
11
|
+
require "zlib"
|
|
12
|
+
|
|
13
|
+
VENDOR_ROOT = File.expand_path("../ext/libinjection/vendor", __dir__)
|
|
14
|
+
LIB_DIR = File.join(VENDOR_ROOT, "libinjection")
|
|
15
|
+
MANIFEST_PATH = File.join(LIB_DIR, ".vendored")
|
|
16
|
+
NORMALIZED_MTIME = Time.utc(2000, 1, 1).freeze
|
|
17
|
+
MANIFEST_HEADER = "# rack-libinjection vendor manifest. Do not edit by hand. Regenerate with: ruby script/vendor_libs.rb"
|
|
18
|
+
|
|
19
|
+
PIN = {
|
|
20
|
+
name: "libinjection",
|
|
21
|
+
version: "4.0.0",
|
|
22
|
+
url: "https://codeload.github.com/libinjection/libinjection/tar.gz/v%<version>s?dummy=/",
|
|
23
|
+
strip_prefix: "libinjection-%<version>s",
|
|
24
|
+
sha256: "a69d27e3d98608df89203c4e1c00c034fe0f8c723017e4088ab53ce3ff5a9129",
|
|
25
|
+
size: 2_237_310,
|
|
26
|
+
keep: %w[
|
|
27
|
+
COPYING
|
|
28
|
+
README.md
|
|
29
|
+
MIGRATION.md
|
|
30
|
+
src/libinjection.h
|
|
31
|
+
src/libinjection_error.h
|
|
32
|
+
src/libinjection_html5.h
|
|
33
|
+
src/libinjection_sqli.h
|
|
34
|
+
src/libinjection_sqli_data.h
|
|
35
|
+
src/libinjection_xss.h
|
|
36
|
+
src/libinjection_sqli.c
|
|
37
|
+
src/libinjection_xss.c
|
|
38
|
+
src/libinjection_html5.c
|
|
39
|
+
]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
options = { mode: :sync }
|
|
43
|
+
OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: script/vendor_libs.rb [--sync | --verify]"
|
|
45
|
+
opts.on("--sync", "Download and vendor pinned libinjection source") { options[:mode] = :sync }
|
|
46
|
+
opts.on("--verify", "Verify vendored source tree without network") { options[:mode] = :verify }
|
|
47
|
+
end.parse!
|
|
48
|
+
|
|
49
|
+
def normalize_tree!(directory)
|
|
50
|
+
Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
51
|
+
base = File.basename(path)
|
|
52
|
+
next if base == "." || base == ".." || File.symlink?(path)
|
|
53
|
+
|
|
54
|
+
if File.file?(path)
|
|
55
|
+
File.chmod(0o644, path)
|
|
56
|
+
File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, path)
|
|
57
|
+
elsif File.directory?(path)
|
|
58
|
+
File.chmod(0o755, path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tree_sha256_for(directory)
|
|
64
|
+
entries = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
|
|
65
|
+
.reject { |path| File.directory?(path) || File.symlink?(path) || %w[. .. .vendored].include?(File.basename(path)) }
|
|
66
|
+
.sort
|
|
67
|
+
|
|
68
|
+
digest = Digest::SHA256.new
|
|
69
|
+
entries.each do |path|
|
|
70
|
+
relative = path.sub(/\A#{Regexp.escape(directory)}\/?/, "")
|
|
71
|
+
digest << relative << "\0"
|
|
72
|
+
digest << File.binread(path)
|
|
73
|
+
digest << "\0"
|
|
74
|
+
end
|
|
75
|
+
digest.hexdigest
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def manifest_body(tree_sha256)
|
|
79
|
+
[
|
|
80
|
+
"libinjection_version=#{PIN[:version]}",
|
|
81
|
+
"libinjection_url=#{format(PIN[:url], version: PIN[:version])}",
|
|
82
|
+
"libinjection_archive_sha256=#{PIN[:sha256]}",
|
|
83
|
+
"libinjection_tree_sha256=#{tree_sha256}"
|
|
84
|
+
]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_manifest!(tree_sha256)
|
|
88
|
+
content = ([MANIFEST_HEADER] + manifest_body(tree_sha256)).join("\n") + "\n"
|
|
89
|
+
File.write(MANIFEST_PATH, content)
|
|
90
|
+
File.chmod(0o644, MANIFEST_PATH)
|
|
91
|
+
File.utime(NORMALIZED_MTIME, NORMALIZED_MTIME, MANIFEST_PATH)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_manifest
|
|
95
|
+
return {} unless File.file?(MANIFEST_PATH)
|
|
96
|
+
|
|
97
|
+
File.readlines(MANIFEST_PATH, chomp: true).each_with_object({}) do |line, kv|
|
|
98
|
+
next if line.empty? || line.start_with?("#")
|
|
99
|
+
|
|
100
|
+
key, value = line.split("=", 2)
|
|
101
|
+
kv[key] = value if key && value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def verify_archive!(path)
|
|
106
|
+
size = File.size(path)
|
|
107
|
+
abort "Archive size mismatch: expected #{PIN[:size]}, got #{size}" unless size == PIN[:size]
|
|
108
|
+
|
|
109
|
+
actual = Digest::SHA256.file(path).hexdigest
|
|
110
|
+
abort "SHA256 mismatch: expected #{PIN[:sha256]}, got #{actual}" unless actual == PIN[:sha256]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def http_download_to_file!(url, path, redirect_limit: 3)
|
|
114
|
+
abort "too many redirects while downloading #{url}" if redirect_limit.negative?
|
|
115
|
+
|
|
116
|
+
uri = URI(url)
|
|
117
|
+
Net::HTTP.start(
|
|
118
|
+
uri.host,
|
|
119
|
+
uri.port,
|
|
120
|
+
use_ssl: uri.scheme == "https",
|
|
121
|
+
open_timeout: 10,
|
|
122
|
+
read_timeout: 30
|
|
123
|
+
) do |http|
|
|
124
|
+
request = Net::HTTP::Get.new(uri)
|
|
125
|
+
http.request(request) do |response|
|
|
126
|
+
case response
|
|
127
|
+
when Net::HTTPSuccess
|
|
128
|
+
File.open(path, "wb") { |file| response.read_body { |chunk| file.write(chunk) } }
|
|
129
|
+
when Net::HTTPRedirection
|
|
130
|
+
location = response["location"]
|
|
131
|
+
abort "redirect without Location while downloading #{url}" unless location
|
|
132
|
+
|
|
133
|
+
return http_download_to_file!(URI.join(uri, location).to_s, path, redirect_limit: redirect_limit - 1)
|
|
134
|
+
else
|
|
135
|
+
abort "download failed: HTTP #{response.code} #{response.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def download_archive!(path)
|
|
142
|
+
url = format(PIN[:url], version: PIN[:version])
|
|
143
|
+
puts "Downloading #{url}"
|
|
144
|
+
|
|
145
|
+
attempts = 0
|
|
146
|
+
begin
|
|
147
|
+
attempts += 1
|
|
148
|
+
http_download_to_file!(url, path)
|
|
149
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::OpenTimeout, Net::ReadTimeout => e
|
|
150
|
+
retry if attempts < 3
|
|
151
|
+
|
|
152
|
+
abort "download failed after #{attempts} attempts: #{e.class}: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
verify_archive!(path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extract_archive!(archive)
|
|
159
|
+
strip_prefix = format(PIN[:strip_prefix], version: PIN[:version])
|
|
160
|
+
prefix_re = /\A#{Regexp.escape(strip_prefix)}\//
|
|
161
|
+
|
|
162
|
+
FileUtils.rm_rf(LIB_DIR)
|
|
163
|
+
FileUtils.mkdir_p(LIB_DIR)
|
|
164
|
+
|
|
165
|
+
Gem::Package::TarReader.new(Zlib::GzipReader.open(archive)) do |tar|
|
|
166
|
+
tar.each do |entry|
|
|
167
|
+
relative = entry.full_name.sub(prefix_re, "")
|
|
168
|
+
next if relative.empty? || relative == entry.full_name
|
|
169
|
+
next unless PIN[:keep].include?(relative)
|
|
170
|
+
next unless entry.file?
|
|
171
|
+
|
|
172
|
+
target = File.join(LIB_DIR, relative)
|
|
173
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
174
|
+
File.binwrite(target, entry.read)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
missing = PIN[:keep].reject { |relative| File.file?(File.join(LIB_DIR, relative)) }
|
|
179
|
+
abort "Missing expected vendored files:\n #{missing.join("\n ")}" unless missing.empty?
|
|
180
|
+
|
|
181
|
+
normalize_tree!(LIB_DIR)
|
|
182
|
+
tree_sha256_for(LIB_DIR)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def verify_vendor!
|
|
186
|
+
failures = []
|
|
187
|
+
manifest = parse_manifest
|
|
188
|
+
|
|
189
|
+
expected_files = PIN[:keep] + [".vendored"]
|
|
190
|
+
missing = expected_files.reject { |relative| File.file?(File.join(LIB_DIR, relative)) }
|
|
191
|
+
failures << "missing files:\n #{missing.join("\n ")}" unless missing.empty?
|
|
192
|
+
|
|
193
|
+
if manifest["libinjection_archive_sha256"] != PIN[:sha256]
|
|
194
|
+
failures << "manifest archive sha256 does not match PIN"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if File.directory?(LIB_DIR)
|
|
198
|
+
actual_tree = tree_sha256_for(LIB_DIR)
|
|
199
|
+
manifest_tree = manifest["libinjection_tree_sha256"]
|
|
200
|
+
failures << "tree_sha256 mismatch: manifest=#{manifest_tree.inspect} actual=#{actual_tree}" if manifest_tree && manifest_tree != actual_tree
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if failures.empty?
|
|
204
|
+
puts "vendor verify: ok"
|
|
205
|
+
exit 0
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
failures.each { |failure| warn failure }
|
|
209
|
+
exit 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
case options[:mode]
|
|
213
|
+
when :verify
|
|
214
|
+
verify_vendor!
|
|
215
|
+
when :sync
|
|
216
|
+
FileUtils.mkdir_p(VENDOR_ROOT)
|
|
217
|
+
Dir.mktmpdir("rack-libinjection-vendor-") do |tmpdir|
|
|
218
|
+
archive = File.join(tmpdir, "libinjection-#{PIN[:version]}.tar.gz")
|
|
219
|
+
download_archive!(archive)
|
|
220
|
+
tree = extract_archive!(archive)
|
|
221
|
+
write_manifest!(tree)
|
|
222
|
+
puts "vendor sync: ok"
|
|
223
|
+
puts " libinjection: version=#{PIN[:version]} archive_sha256=#{PIN[:sha256]} tree_sha256=#{tree}"
|
|
224
|
+
end
|
|
225
|
+
else
|
|
226
|
+
abort "unknown mode: #{options[:mode].inspect}"
|
|
227
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class TestLibInjection < Minitest::Test
|
|
6
|
+
def test_sqli_detection
|
|
7
|
+
assert_equal true, LibInjection.sqli?("1 OR 1=1--")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def test_sqli_fingerprint
|
|
11
|
+
fingerprint = LibInjection.sqli_fingerprint("1 OR 1=1--")
|
|
12
|
+
assert_kind_of String, fingerprint
|
|
13
|
+
refute_empty fingerprint
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_benign_string
|
|
17
|
+
assert_equal false, LibInjection.sqli?("ordinary search text")
|
|
18
|
+
assert_nil LibInjection.sqli_fingerprint("ordinary search text")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_empty_string_is_safe
|
|
22
|
+
assert_equal false, LibInjection.sqli?("")
|
|
23
|
+
assert_equal false, LibInjection.xss?("")
|
|
24
|
+
assert_nil LibInjection.detect_raw("")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_nil_is_rejected
|
|
28
|
+
assert_raises TypeError do
|
|
29
|
+
LibInjection.detect_raw(nil)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_binary_invalid_utf8_is_scanned_as_bytes
|
|
34
|
+
input = "\xFF\xFE1 OR 1=1--".b
|
|
35
|
+
input.force_encoding(Encoding::UTF_8)
|
|
36
|
+
|
|
37
|
+
assert_equal false, input.valid_encoding?
|
|
38
|
+
assert_equal :sqli, LibInjection.detect_raw(input).fetch(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_null_bytes_do_not_crash
|
|
42
|
+
input = "abc\0<script>alert(1)</script>".b
|
|
43
|
+
|
|
44
|
+
assert_equal :xss, LibInjection.detect_raw(input).fetch(0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_utf16_is_not_implicitly_decoded
|
|
48
|
+
input = "1 OR 1=1--".encode(Encoding::UTF_16LE)
|
|
49
|
+
|
|
50
|
+
assert_nil LibInjection.detect_raw(input)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_large_input_uses_nogvl_path_safely
|
|
54
|
+
input = "1 OR 1=1--" + ("a" * 2_048)
|
|
55
|
+
|
|
56
|
+
assert_equal :sqli, LibInjection.detect_raw(input).fetch(0)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_detect_raw_sqli
|
|
60
|
+
match = LibInjection.detect_raw("1 OR 1=1--")
|
|
61
|
+
|
|
62
|
+
assert_equal :sqli, match[0]
|
|
63
|
+
assert_kind_of String, match[1]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_detect_raw_xss
|
|
67
|
+
match = LibInjection.detect_raw("<script>alert(1)</script>")
|
|
68
|
+
|
|
69
|
+
assert_equal :xss, match[0]
|
|
70
|
+
assert_nil match[1]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_detect_raw_benign
|
|
74
|
+
assert_nil LibInjection.detect_raw("ordinary search text")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_detect_result
|
|
78
|
+
result = LibInjection.detect("1 OR 1=1--")
|
|
79
|
+
assert result.detected?
|
|
80
|
+
assert result.sqli?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_sqli_result
|
|
84
|
+
result = LibInjection.sqli_result("1 OR 1=1--")
|
|
85
|
+
|
|
86
|
+
assert_equal :sqli, result[:type]
|
|
87
|
+
assert_equal true, result[:detected]
|
|
88
|
+
assert_kind_of String, result[:fingerprint]
|
|
89
|
+
assert_kind_of Hash, result[:stats]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_sqli_result_rejects_invalid_options
|
|
93
|
+
assert_raises ArgumentError do
|
|
94
|
+
LibInjection.sqli_result("1 OR 1=1--", context: :unknown)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
assert_raises ArgumentError do
|
|
98
|
+
LibInjection.sqli_result("1 OR 1=1--", quote: :unknown)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
assert_raises ArgumentError do
|
|
102
|
+
LibInjection.sqli_result("1 OR 1=1--", dialect: :unknown)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_sqli_contexts
|
|
107
|
+
contexts = LibInjection.sqli_contexts("1 OR 1=1--")
|
|
108
|
+
|
|
109
|
+
refute_empty contexts
|
|
110
|
+
assert contexts.any? { |ctx| ctx[:detected] }
|
|
111
|
+
assert contexts.all? { |ctx| ctx.key?(:context) && ctx.key?(:flags) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_sqli_context_flags
|
|
115
|
+
assert_equal LibInjection::SQLI_CONTEXTS.fetch(:single_mysql),
|
|
116
|
+
LibInjection.sqli_flags(context: :single_mysql)
|
|
117
|
+
assert_equal LibInjection::SQLI_QUOTES.fetch(:single) | LibInjection::SQLI_DIALECTS.fetch(:mysql),
|
|
118
|
+
LibInjection.sqli_flags(quote: :single, dialect: :mysql)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_sqli_fingerprint_for_context
|
|
122
|
+
fp = LibInjection.sqli_fingerprint_for("1 OR 1=1--", context: :none_ansi)
|
|
123
|
+
|
|
124
|
+
assert_kind_of String, fp
|
|
125
|
+
refute_empty fp
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_sqli_tokens
|
|
129
|
+
tokens = LibInjection.sqli_tokens("1 OR 1=1--")
|
|
130
|
+
|
|
131
|
+
refute_empty tokens
|
|
132
|
+
assert_equal :number, tokens.first[:type]
|
|
133
|
+
assert_equal "1", tokens.first[:value]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_sqli_tokens_rejects_invalid_options
|
|
137
|
+
assert_raises ArgumentError do
|
|
138
|
+
LibInjection.sqli_tokens("1 OR 1=1--", context: :unknown)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def test_sqli_folded_tokens
|
|
143
|
+
tokens = LibInjection.sqli_tokens("1 OR 1=1--", fold: true)
|
|
144
|
+
|
|
145
|
+
refute_empty tokens
|
|
146
|
+
assert tokens.size <= LibInjection.sqli_tokens("1 OR 1=1--").size
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_xss_detection
|
|
150
|
+
assert_equal true, LibInjection.xss?("<script>alert(1)</script>")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_xss_result
|
|
154
|
+
result = LibInjection.xss_result("<script>alert(1)</script>")
|
|
155
|
+
|
|
156
|
+
assert_equal :xss, result[:type]
|
|
157
|
+
assert_equal true, result[:detected]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def test_xss_result_rejects_invalid_options
|
|
161
|
+
assert_raises ArgumentError do
|
|
162
|
+
LibInjection.xss_result("<script>alert(1)</script>", context: :unknown)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def test_xss_contexts
|
|
167
|
+
contexts = LibInjection.xss_contexts("<script>alert(1)</script>")
|
|
168
|
+
|
|
169
|
+
refute_empty contexts
|
|
170
|
+
assert contexts.any? { |ctx| ctx[:detected] }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_html5_tokens
|
|
174
|
+
tokens = LibInjection.html5_tokens("<script>alert(1)</script>")
|
|
175
|
+
|
|
176
|
+
refute_empty tokens
|
|
177
|
+
assert_equal :tag_name_open, tokens.first[:type]
|
|
178
|
+
assert_equal "script", tokens.first[:value]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_html5_tokens_rejects_invalid_options
|
|
182
|
+
assert_raises ArgumentError do
|
|
183
|
+
LibInjection.html5_tokens("<script>alert(1)</script>", context: :unknown)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def test_html5_context_flags
|
|
188
|
+
assert_equal LibInjection::HTML5_CONTEXTS.fetch(:value_double_quote),
|
|
189
|
+
LibInjection.xss_flags(context: :value_double_quote)
|
|
190
|
+
end
|
|
191
|
+
def test_detect_url_encoded_raw_decodes_twice
|
|
192
|
+
match = LibInjection.detect_url_encoded_raw("q=1%2520OR%25201%253D1--", 2, true, 3)
|
|
193
|
+
|
|
194
|
+
assert_equal :sqli, match.fetch(0)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def test_detect_url_encoded_raw_respects_depth
|
|
198
|
+
assert_nil LibInjection.detect_url_encoded_raw("q=1%2520OR%25201%253D1--", 1, true, 3)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def test_detect_url_encoded_raw_can_scan_only_xss
|
|
202
|
+
match = LibInjection.detect_url_encoded_raw("q=%3Cscript%3Ealert(1)%3C/script%3E", 2, true, 2)
|
|
203
|
+
|
|
204
|
+
assert_equal :xss, match.fetch(0)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def test_detect_url_encoded_raw_accepts_malformed_percent_escapes
|
|
208
|
+
assert_nil LibInjection.detect_url_encoded_raw("q=%ZZordinary", 2, true, 3)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def test_detect_url_encoded_raw_rejects_excessive_depth
|
|
212
|
+
assert_raises ArgumentError do
|
|
213
|
+
LibInjection.detect_url_encoded_raw("q=ordinary", 33, true, 3)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def test_detect_url_encoded_raw_rejects_invalid_mask
|
|
218
|
+
assert_raises ArgumentError do
|
|
219
|
+
LibInjection.detect_url_encoded_raw("q=ordinary", 2, true, 0)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
end
|