bloodinary 1.0.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/LICENSE +21 -0
- data/README.md +93 -0
- data/assets/logo.webp +0 -0
- data/bin/bloodinary +91 -0
- data/lib/bloodinary/processor.rb +47 -0
- data/lib/bloodinary/reporter.rb +78 -0
- data/lib/bloodinary/rules/base_rule.rb +29 -0
- data/lib/bloodinary/rules/command_injection.rb +37 -0
- data/lib/bloodinary/rules/file_access.rb +31 -0
- data/lib/bloodinary/rules/insecure_redirect.rb +23 -0
- data/lib/bloodinary/rules/sql_injection.rb +32 -0
- data/lib/bloodinary/rules/weak_crypto.rb +24 -0
- data/lib/bloodinary/rules/xss_vulnerability.rb +52 -0
- data/lib/bloodinary/scanner.rb +112 -0
- data/lib/bloodinary.rb +26 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 69921337267f3dd4f7f5b1a5e08fa447936d7375a94017823dc92b8ef208b2a6
|
|
4
|
+
data.tar.gz: 9b24cd2fb45e569fe951f2cfe383ed0f2c778e4d1521861a088fd7f81c1e3421
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4c5eb35e452bd1b4f3c6b6877428dc54ca7ede23945248867a74fa719802b2461026f4fe460a8fdd07e179528a957a9eff1eecbf934e53f2399feaab0d88abb5
|
|
7
|
+
data.tar.gz: 9854d8f4021617498db3ed75771f1b0b227f268644b7c1e825301d8bfee6d31c194908104321c7c1e008bdb6e098e579dd749aec71d578c75a8afe83ad97217a
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IshikawaUta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.webp" alt="Bloodinary Logo" width="600">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 🩸 Bloodinary v1.0.0
|
|
8
|
+
|
|
9
|
+
[](https://rubygems.org/gems/bloodinary)
|
|
10
|
+
[](https://opensource.org/licenses/MIT)
|
|
11
|
+
|
|
12
|
+
**Bloodinary** adalah alat analisis keamanan statis (*Static Analysis Security Testing* - SAST) premium untuk aplikasi Ruby. Dirancang khusus untuk mendeteksi kerentanan keamanan tingkat tinggi dengan tingkat akurasi yang lebih tajam dibandingkan alat tradisional pada framework kustom.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## ✨ Fitur Unggulan
|
|
17
|
+
|
|
18
|
+
- **🔍 Analisis AST**: Melakukan pemindaian kode sumber tanpa mengeksekusinya, menjamin keamanan 100% pada lingkungan pengembangan.
|
|
19
|
+
- **🚀 Ultra Fast**: Performa pemindaian kilat, mampu memproses ribuan baris kode dalam hitungan milidetik.
|
|
20
|
+
- **📁 Dukungan ERB & Template**: Mendeteksi celah XSS di file `.erb`, `.html`, `.haml`, dan `.slim`.
|
|
21
|
+
- **🛡️ Aturan Keamanan Komprehensif**:
|
|
22
|
+
- **SQL Injection**: Mendeteksi kueri database yang tidak aman.
|
|
23
|
+
- **Cross-Site Scripting (XSS)**: Memantau output dinamis yang berbahaya.
|
|
24
|
+
- **Command Injection**: Melacak eksekusi sistem yang berisiko.
|
|
25
|
+
- **Path Traversal**: Mengaudit akses file sistem secara dinamis.
|
|
26
|
+
- **Insecure Redirect**: Mencegah pengalihan ke situs phishing.
|
|
27
|
+
- **Weak Crypto**: Mendeteksi algoritma hashing usang (MD5/SHA1).
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 🛠️ Instalasi
|
|
32
|
+
|
|
33
|
+
Tambahkan baris ini ke dalam Gemfile aplikasi Anda:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
gem 'bloodinary'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Lalu jalankan:
|
|
40
|
+
```bash
|
|
41
|
+
bundle install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Atau instal secara manual dengan:
|
|
45
|
+
```bash
|
|
46
|
+
gem install bloodinary
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 🚀 Penggunaan
|
|
52
|
+
|
|
53
|
+
Jalankan Bloodinary pada direktori proyek Anda:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Pemindaian standar
|
|
57
|
+
bloodinary .
|
|
58
|
+
|
|
59
|
+
# Output dalam format JSON untuk integrasi CI/CD
|
|
60
|
+
bloodinary . --format json
|
|
61
|
+
|
|
62
|
+
# Menyimpan laporan ke file
|
|
63
|
+
bloodinary . --output laporan_keamanan.txt
|
|
64
|
+
|
|
65
|
+
# Mengabaikan folder tertentu
|
|
66
|
+
bloodinary . --ignore-path vendor/,spec/
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Opsi CLI Lengkap:
|
|
70
|
+
|
|
71
|
+
| Opsi | Deskripsi |
|
|
72
|
+
|------|-----------|
|
|
73
|
+
| `-f, --format` | Pilih format laporan (`text`, `json`). |
|
|
74
|
+
| `-o, --output` | Simpan laporan ke file tertentu. |
|
|
75
|
+
| `--ignore-path` | Lewati folder atau file (pisahkan dengan koma). |
|
|
76
|
+
| `--exit-on-warn` | Berhenti dengan kode 1 jika menemukan celah keamanan. |
|
|
77
|
+
| `-v, --version` | Tampilkan versi Bloodinary. |
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🧩 Pengabaian Temuan (Ignore)
|
|
82
|
+
|
|
83
|
+
Jika Anda yakin suatu baris kode aman, Anda bisa menambah komentar di akhir baris untuk mengabaikannya:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
system("ls #{user_input}") # bloodinary:ignore
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 📄 Lisensi
|
|
92
|
+
|
|
93
|
+
Proyek ini dilisensikan di bawah **Lisensi MIT** - lihat file [LICENSE](LICENSE) untuk detail lebih lanjut.
|
data/assets/logo.webp
ADDED
|
Binary file
|
data/bin/bloodinary
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
|
3
|
+
require 'bloodinary'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'rainbow'
|
|
6
|
+
|
|
7
|
+
options = {
|
|
8
|
+
format: :text,
|
|
9
|
+
output: nil,
|
|
10
|
+
ignore_paths: [],
|
|
11
|
+
exit_on_warn: false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
OptionParser.new do |opts|
|
|
15
|
+
opts.banner = "Penggunaan: bloodinary [jalur] [opsi]"
|
|
16
|
+
|
|
17
|
+
opts.on("-f", "--format FORMAT", [:text, :json], "Pilih format laporan (text, json)") do |f|
|
|
18
|
+
options[:format] = f
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
opts.on("-o", "--output FILE", "Simpan laporan ke file") do |o|
|
|
22
|
+
options[:output] = o
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
opts.on("--ignore-path PATH", "Lewati folder/file tertentu (pisahkan dengan koma)") do |p|
|
|
26
|
+
options[:ignore_paths] = p.split(',').map(&:strip)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
opts.on("--exit-on-warn", "Keluar dengan kode 1 jika ditemukan kerentanan") do
|
|
30
|
+
options[:exit_on_warn] = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
opts.on("-v", "--version", "Tampilkan versi") do
|
|
34
|
+
puts "Bloodinary v1.0.0"
|
|
35
|
+
exit
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
opts.on("-h", "--help", "Tampilkan bantuan") do
|
|
39
|
+
puts opts
|
|
40
|
+
exit
|
|
41
|
+
end
|
|
42
|
+
end.parse!
|
|
43
|
+
|
|
44
|
+
path = ARGV[0] || '.'
|
|
45
|
+
|
|
46
|
+
unless File.exist?(path)
|
|
47
|
+
puts "Error: Jalur '#{path}' tidak ditemukan."
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Brand message
|
|
52
|
+
if options[:format] == :text
|
|
53
|
+
puts "\n" + Rainbow(" Bloodinary ").black.bg(:red).bold + " Sedang memindai target: " + Rainbow(path).underline
|
|
54
|
+
puts Rainbow(" (Mengabaikan: #{options[:ignore_paths].join(', ')})").faint unless options[:ignore_paths].empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# SCAN
|
|
58
|
+
begin
|
|
59
|
+
scanner = Bloodinary::Scanner.new
|
|
60
|
+
result = scanner.scan(path, options[:ignore_paths])
|
|
61
|
+
|
|
62
|
+
# REPORT
|
|
63
|
+
if options[:output]
|
|
64
|
+
File.open(options[:output], 'w') do |f|
|
|
65
|
+
# Temporarily redirect stdout for the reporter
|
|
66
|
+
original_stdout = $stdout
|
|
67
|
+
$stdout = f
|
|
68
|
+
Bloodinary::Reporter.report(result, options[:format])
|
|
69
|
+
$stdout = original_stdout
|
|
70
|
+
end
|
|
71
|
+
puts "\n" + Rainbow(" Laporan berhasil disimpan ke: ").green + Rainbow(options[:output]).underline if options[:format] == :text
|
|
72
|
+
else
|
|
73
|
+
Bloodinary::Reporter.report(result, options[:format])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# EXIT STATUS
|
|
77
|
+
if options[:exit_on_warn] && !result[:findings].empty?
|
|
78
|
+
exit 1
|
|
79
|
+
end
|
|
80
|
+
rescue Interrupt
|
|
81
|
+
puts "\n" + Rainbow("Pemindaian dibatalkan oleh pengguna.").yellow
|
|
82
|
+
exit 1
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
# If format is JSON, we should probably output JSON error
|
|
85
|
+
if options[:format] == :json
|
|
86
|
+
puts ({ error: e.message }).to_json
|
|
87
|
+
else
|
|
88
|
+
puts "\n" + Rainbow("Terjadi kesalahan sistem: #{e.message}").red
|
|
89
|
+
end
|
|
90
|
+
exit 1
|
|
91
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Bloodinary
|
|
2
|
+
class Processor < Parser::AST::Processor
|
|
3
|
+
attr_reader :findings
|
|
4
|
+
|
|
5
|
+
def initialize(file, comments = [])
|
|
6
|
+
@file = file
|
|
7
|
+
@findings = []
|
|
8
|
+
@comments = comments
|
|
9
|
+
@rules = [
|
|
10
|
+
Rules::SQLInjectionRule.new,
|
|
11
|
+
Rules::XSSVulnerabilityRule.new,
|
|
12
|
+
Rules::CommandInjectionRule.new,
|
|
13
|
+
Rules::FileAccessRule.new,
|
|
14
|
+
Rules::InsecureRedirectRule.new,
|
|
15
|
+
Rules::WeakCryptoRule.new
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ignore?(node)
|
|
20
|
+
return false unless node.loc && node.loc.expression
|
|
21
|
+
line = node.loc.line
|
|
22
|
+
@comments.any? { |c| c.location.line == line && c.text.include?('bloodinary:ignore') }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# on_send is called for method calls: object.method(args)
|
|
26
|
+
def on_send(node)
|
|
27
|
+
return super if ignore?(node)
|
|
28
|
+
|
|
29
|
+
@rules.each do |rule|
|
|
30
|
+
finding = rule.check(node, @file)
|
|
31
|
+
@findings << finding if finding
|
|
32
|
+
end
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# on_xstr is called for backticks: `command`
|
|
37
|
+
def on_xstr(node)
|
|
38
|
+
return super if ignore?(node)
|
|
39
|
+
|
|
40
|
+
@rules.each do |rule|
|
|
41
|
+
finding = rule.check(node, @file)
|
|
42
|
+
@findings << finding if finding
|
|
43
|
+
end
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Bloodinary
|
|
2
|
+
class Reporter
|
|
3
|
+
def self.report(result, format = :text)
|
|
4
|
+
if format == :json
|
|
5
|
+
puts JSON.pretty_generate(result)
|
|
6
|
+
else
|
|
7
|
+
print_text_report(result)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.print_text_report(result)
|
|
12
|
+
findings = result[:findings]
|
|
13
|
+
metadata = result[:metadata]
|
|
14
|
+
|
|
15
|
+
# Header
|
|
16
|
+
header = " BLOODINARY SECURITY SCANNER "
|
|
17
|
+
puts "\n" + Rainbow(" " * 80).bg(:red)
|
|
18
|
+
puts Rainbow(header.center(80)).black.bg(:red).bold
|
|
19
|
+
puts Rainbow(" " * 80).bg(:red)
|
|
20
|
+
puts "\n"
|
|
21
|
+
|
|
22
|
+
if findings.empty?
|
|
23
|
+
puts Rainbow(" PASSED: Tidak ditemukan kerentanan keamanan yang mencolok. ✨").green.bold
|
|
24
|
+
else
|
|
25
|
+
puts Rainbow(" TERDETEKSI #{findings.size} KERENTANAN:").red.bold
|
|
26
|
+
puts "\n"
|
|
27
|
+
|
|
28
|
+
findings.each_with_index do |f, i|
|
|
29
|
+
color = severity_color(f[:severity])
|
|
30
|
+
severity_text = " #{f[:severity]} "
|
|
31
|
+
print " " + Rainbow(severity_text).black.bg(color).bold
|
|
32
|
+
print " " + Rainbow(f[:message]).bold
|
|
33
|
+
puts "\n"
|
|
34
|
+
puts Rainbow(" File: ").bright + Rainbow("#{f[:file]}:#{f[:line]}").underline
|
|
35
|
+
puts Rainbow(" Kode: ").bright + Rainbow("> #{f[:code].strip}").italic
|
|
36
|
+
puts " " + Rainbow("-" * 76).faint
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
print_summary(result)
|
|
41
|
+
|
|
42
|
+
# Footer
|
|
43
|
+
puts "\n" + Rainbow(" Scan selesai pada #{metadata[:end_time].strftime('%H:%M:%S')} (Durasi: #{metadata[:duration].round(2)}s) ").faint.center(80)
|
|
44
|
+
puts "\n"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.print_summary(result)
|
|
48
|
+
findings = result[:findings]
|
|
49
|
+
metadata = result[:metadata]
|
|
50
|
+
|
|
51
|
+
counts = Hash.new(0)
|
|
52
|
+
findings.each { |f| counts[f[:severity]] += 1 }
|
|
53
|
+
|
|
54
|
+
puts "\n" + Rainbow(" RINGKASAN TEMUAN ").black.bg(:white).bold
|
|
55
|
+
puts Rainbow(" ┌────────────────────┬──────────┐ ").faint
|
|
56
|
+
puts Rainbow(" │ Tingkat Keparahan │ Jumlah │ ").faint
|
|
57
|
+
puts Rainbow(" ├────────────────────┼──────────┤ ").faint
|
|
58
|
+
[:CRITICAL, :HIGH, :MEDIUM].each do |sev|
|
|
59
|
+
color = severity_color(sev)
|
|
60
|
+
label = sev.to_s.ljust(18)
|
|
61
|
+
count = counts[sev].to_s.rjust(8)
|
|
62
|
+
puts Rainbow(" │ ").faint + Rainbow(label).color(color) + Rainbow(" │ ").faint + Rainbow(count).bold + Rainbow(" │ ").faint
|
|
63
|
+
end
|
|
64
|
+
puts Rainbow(" ├────────────────────┼──────────┤ ").faint
|
|
65
|
+
puts Rainbow(" │ Total File Scan │ ").faint + metadata[:file_count].to_s.rjust(8) + Rainbow(" │ ").faint
|
|
66
|
+
puts Rainbow(" └────────────────────┴──────────┘ ").faint
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.severity_color(severity)
|
|
70
|
+
case severity
|
|
71
|
+
when :CRITICAL then :magenta
|
|
72
|
+
when :HIGH then :red
|
|
73
|
+
when :MEDIUM then :yellow
|
|
74
|
+
else :blue
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Bloodinary
|
|
2
|
+
module Rules
|
|
3
|
+
class BaseRule
|
|
4
|
+
def check(node, file)
|
|
5
|
+
# To be implemented by subclasses
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def create_finding(node, file, severity, message)
|
|
9
|
+
{
|
|
10
|
+
file: file,
|
|
11
|
+
line: node.loc.line,
|
|
12
|
+
column: node.loc.column,
|
|
13
|
+
severity: severity,
|
|
14
|
+
message: message,
|
|
15
|
+
code: node.loc.expression.source
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Helper to check if a node is potentially dynamic/untrusted
|
|
20
|
+
def dynamic?(node)
|
|
21
|
+
return false unless node
|
|
22
|
+
# If it's a string literal, it's safe
|
|
23
|
+
return false if node.type == :str
|
|
24
|
+
# If it's a variable or interpolation, it's dynamic
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class CommandInjectionRule < BaseRule
|
|
6
|
+
TARGET_METHODS = [:system, :exec, :spawn].freeze
|
|
7
|
+
|
|
8
|
+
def check(node, file)
|
|
9
|
+
# Handle method calls (system, exec, spawn)
|
|
10
|
+
if node.type == :send
|
|
11
|
+
method_name = node.children[1]
|
|
12
|
+
if TARGET_METHODS.include?(method_name)
|
|
13
|
+
arg = node.children[2]
|
|
14
|
+
if arg && dynamic_command?(arg)
|
|
15
|
+
return create_finding(node, file, :CRITICAL, "Pemanggilan perintah sistem '#{method_name}' dengan data dinamis. Risiko Command Injection!")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Handle backticks (xstr)
|
|
21
|
+
if node.type == :xstr
|
|
22
|
+
if node.children.any? { |c| c.type == :begin } # Interpolation inside backticks
|
|
23
|
+
return create_finding(node, file, :CRITICAL, "Backticks dengan interpolasi variabel terdeteksi. Risiko Command Injection!")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def dynamic_command?(node)
|
|
32
|
+
# Check if argument is dynamic
|
|
33
|
+
[:dstr, :lvar, :ivar, :send].include?(node.type)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class FileAccessRule < BaseRule
|
|
6
|
+
TARGET_METHODS = [:read, :open, :readlines, :binread, :write, :delete, :unlink, :stat].freeze
|
|
7
|
+
|
|
8
|
+
def check(node, file)
|
|
9
|
+
return nil unless node.type == :send
|
|
10
|
+
|
|
11
|
+
receiver = node.children[0]
|
|
12
|
+
method_name = node.children[1]
|
|
13
|
+
|
|
14
|
+
# Mencari pola File.read, Dir.glob, IO.readlines, dll.
|
|
15
|
+
if receiver && receiver.type == :const
|
|
16
|
+
class_name = receiver.children[1]
|
|
17
|
+
|
|
18
|
+
if [:File, :Dir, :IO].include?(class_name)
|
|
19
|
+
if TARGET_METHODS.include?(method_name) || method_name == :glob
|
|
20
|
+
arg = node.children[2]
|
|
21
|
+
if arg && dynamic?(arg)
|
|
22
|
+
return create_finding(node, file, :HIGH, "Akses file/direktori dengan parameter dinamis terdeteksi. Pastikan untuk mencegah Path Traversal.")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class InsecureRedirectRule < BaseRule
|
|
6
|
+
def check(node, file)
|
|
7
|
+
return nil unless node.type == :send
|
|
8
|
+
|
|
9
|
+
method_name = node.children[1]
|
|
10
|
+
|
|
11
|
+
# redirect(params[:url]) atau redirect_to(url)
|
|
12
|
+
if [:redirect, :redirect_to].include?(method_name)
|
|
13
|
+
arg = node.children[2]
|
|
14
|
+
|
|
15
|
+
if arg && dynamic?(arg)
|
|
16
|
+
return create_finding(node, file, :MEDIUM, "Pengalihan (redirect) menggunakan data dinamis tanpa validasi. Risiko Open Redirect ke situs berbahaya.")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class SQLInjectionRule < BaseRule
|
|
6
|
+
TARGET_METHODS = [:where, :find_by, :execute, :query].freeze
|
|
7
|
+
|
|
8
|
+
def check(node, file)
|
|
9
|
+
method_name = node.children[1]
|
|
10
|
+
return nil unless TARGET_METHODS.include?(method_name)
|
|
11
|
+
|
|
12
|
+
# Check the first argument
|
|
13
|
+
arg = node.children[2]
|
|
14
|
+
return nil unless arg
|
|
15
|
+
|
|
16
|
+
if dynamic_query?(arg)
|
|
17
|
+
return create_finding(node, file, :HIGH, "Potensi SQL Injection terdeteksi pada pemanggilan metode '#{method_name}'.")
|
|
18
|
+
end
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def dynamic_query?(node)
|
|
25
|
+
# :dstr is a string with interpolation
|
|
26
|
+
# :lvar is a local variable
|
|
27
|
+
# :send is another method call
|
|
28
|
+
[:dstr, :lvar, :send, :ivar].include?(node.type)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class WeakCryptoRule < BaseRule
|
|
6
|
+
def check(node, file)
|
|
7
|
+
return nil unless node.type == :send
|
|
8
|
+
|
|
9
|
+
receiver = node.children[0]
|
|
10
|
+
method_name = node.children[1]
|
|
11
|
+
|
|
12
|
+
# Digest::MD5.hexdigest(data) -> node is hexdigest, receiver is Digest::MD5
|
|
13
|
+
# Digest::MD5 -> node is MD5, receiver is Digest
|
|
14
|
+
if method_name == :hexdigest || method_name == :new || method_name == :digest
|
|
15
|
+
if receiver && receiver.type == :const && [:MD5, :SHA1].include?(receiver.children[1])
|
|
16
|
+
algorithm = receiver.children[1]
|
|
17
|
+
return create_finding(node, file, :MEDIUM, "Algoritma hashing yang lemah (#{algorithm}) terdeteksi. Gunakan SHA256 atau lebih tinggi.")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative 'base_rule'
|
|
2
|
+
|
|
3
|
+
module Bloodinary
|
|
4
|
+
module Rules
|
|
5
|
+
class XSSVulnerabilityRule < BaseRule
|
|
6
|
+
def check(node, file)
|
|
7
|
+
method_name = node.children[1]
|
|
8
|
+
|
|
9
|
+
# Check for ERB output: <%= data %>
|
|
10
|
+
if method_name == :_bloodinary_output
|
|
11
|
+
arg = node.children[2]
|
|
12
|
+
if arg && dynamic_and_unsafe?(arg)
|
|
13
|
+
return create_finding(node, file, :HIGH, "Output dinamis di dalam template terdeteksi tanpa pembersihan (XSS). Gunakan pembersihan atau pastikan data aman.")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check for raw(data)
|
|
18
|
+
if method_name == :raw
|
|
19
|
+
arg = node.children[2]
|
|
20
|
+
if arg && [:lvar, :ivar, :send, :dstr].include?(arg.type)
|
|
21
|
+
return create_finding(node, file, :MEDIUM, "Penggunaan 'raw' terdeteksi. Pastikan data sudah dibersihkan untuk mencegah XSS.")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check for data.html_safe
|
|
26
|
+
if method_name == :html_safe
|
|
27
|
+
receiver = node.children[0]
|
|
28
|
+
if receiver && [:lvar, :ivar, :send, :dstr].include?(receiver.type)
|
|
29
|
+
return create_finding(node, file, :MEDIUM, "Metode 'html_safe' dipanggil pada objek dinamis. Ini adalah risiko XSS.")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def dynamic_and_unsafe?(node)
|
|
38
|
+
# Check if it's a variable, instance variable, method call, or interpolation
|
|
39
|
+
return false unless [:lvar, :ivar, :send, :dstr].include?(node.type)
|
|
40
|
+
|
|
41
|
+
# Exception: if it's already wrapped in raw or html_safe, we don't flag the output tag itself
|
|
42
|
+
# (Though those will be flagged by their own rules anyway)
|
|
43
|
+
if node.type == :send
|
|
44
|
+
inner_method = node.children[1]
|
|
45
|
+
return false if [:raw, :html_safe].include?(inner_method)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module Bloodinary
|
|
2
|
+
class Scanner
|
|
3
|
+
def initialize(options = {})
|
|
4
|
+
@options = options
|
|
5
|
+
@findings = []
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
TEMPLATE_EXTENSIONS = ['.erb', '.html', '.rhtml', '.haml', '.slim'].freeze
|
|
9
|
+
|
|
10
|
+
def scan(path, ignore_paths = [])
|
|
11
|
+
@start_time = Time.now
|
|
12
|
+
@file_count = 0
|
|
13
|
+
@findings = []
|
|
14
|
+
|
|
15
|
+
Find.find(path) do |f|
|
|
16
|
+
# Ignore logic (skip vendor, spec, etc if specified)
|
|
17
|
+
if ignore_paths.any? { |ignore| f.include?(ignore) }
|
|
18
|
+
Find.prune if File.directory?(f)
|
|
19
|
+
next
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
next if File.directory?(f)
|
|
23
|
+
|
|
24
|
+
ext = File.extname(f).downcase
|
|
25
|
+
if ext == '.rb'
|
|
26
|
+
@file_count += 1
|
|
27
|
+
@findings.concat(scan_file(f))
|
|
28
|
+
elsif TEMPLATE_EXTENSIONS.include?(ext)
|
|
29
|
+
@file_count += 1
|
|
30
|
+
@findings.concat(scan_template_file(f))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@end_time = Time.now
|
|
35
|
+
{
|
|
36
|
+
findings: @findings,
|
|
37
|
+
metadata: {
|
|
38
|
+
file_count: @file_count,
|
|
39
|
+
duration: @end_time - @start_time,
|
|
40
|
+
start_time: @start_time,
|
|
41
|
+
end_time: @end_time
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scan_file(file)
|
|
47
|
+
begin
|
|
48
|
+
source = File.read(file)
|
|
49
|
+
parse_and_process(source, file)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
puts "Error scanning #{file}: #{e.message}"
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scan_template_file(file)
|
|
57
|
+
begin
|
|
58
|
+
content = File.read(file)
|
|
59
|
+
ruby_code = extract_ruby_from_erb(content)
|
|
60
|
+
parse_and_process(ruby_code, file)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
# Fail silently for template noise
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def parse_and_process(source, file)
|
|
70
|
+
parser = Parser::CurrentRuby.new
|
|
71
|
+
buffer = Parser::Source::Buffer.new(file)
|
|
72
|
+
buffer.source = source
|
|
73
|
+
ast, comments = parser.parse_with_comments(buffer)
|
|
74
|
+
|
|
75
|
+
return [] unless ast
|
|
76
|
+
|
|
77
|
+
processor = Processor.new(file, comments)
|
|
78
|
+
processor.process(ast)
|
|
79
|
+
processor.findings
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def extract_ruby_from_erb(content)
|
|
83
|
+
output = ""
|
|
84
|
+
last_pos = 0
|
|
85
|
+
|
|
86
|
+
content.scan(/<%([=-]?)\s*(.*?)\s*-?%>/m) do |type, code|
|
|
87
|
+
start_pos = Regexp.last_match.begin(0)
|
|
88
|
+
end_pos = Regexp.last_match.end(0)
|
|
89
|
+
|
|
90
|
+
# Replace HTML with spaces to preserve line/column info
|
|
91
|
+
skipped = content[last_pos...start_pos]
|
|
92
|
+
output << skipped.gsub(/[^\n]/, ' ')
|
|
93
|
+
|
|
94
|
+
# Output tags are wrapped in a helper
|
|
95
|
+
if type == '='
|
|
96
|
+
output << "_bloodinary_output(#{code})"
|
|
97
|
+
else
|
|
98
|
+
output << code
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
last_pos = end_pos
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Add the rest of the file as spaces
|
|
105
|
+
if last_pos < content.length
|
|
106
|
+
output << content[last_pos..-1].gsub(/[^\n]/, ' ')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
output
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/bloodinary.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Suppress parser version warning
|
|
2
|
+
$VERBOSE, old_verbose = nil, $VERBOSE
|
|
3
|
+
require 'parser/current'
|
|
4
|
+
$VERBOSE = old_verbose
|
|
5
|
+
|
|
6
|
+
require 'find'
|
|
7
|
+
require 'rainbow'
|
|
8
|
+
require 'json'
|
|
9
|
+
|
|
10
|
+
# Core
|
|
11
|
+
require_relative 'bloodinary/scanner'
|
|
12
|
+
require_relative 'bloodinary/processor'
|
|
13
|
+
require_relative 'bloodinary/reporter'
|
|
14
|
+
|
|
15
|
+
# Rules
|
|
16
|
+
require_relative 'bloodinary/rules/base_rule'
|
|
17
|
+
require_relative 'bloodinary/rules/sql_injection'
|
|
18
|
+
require_relative 'bloodinary/rules/xss_vulnerability'
|
|
19
|
+
require_relative 'bloodinary/rules/command_injection'
|
|
20
|
+
require_relative 'bloodinary/rules/file_access'
|
|
21
|
+
require_relative 'bloodinary/rules/insecure_redirect'
|
|
22
|
+
require_relative 'bloodinary/rules/weak_crypto'
|
|
23
|
+
|
|
24
|
+
module Bloodinary
|
|
25
|
+
VERSION = "1.0.0"
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bloodinary
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- IshikawaUta
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: parser
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rainbow
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: bundler
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.3'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.3'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
description: Bloodinary detects high-severity vulnerabilities like SQLi, XSS, and
|
|
69
|
+
RCE in any Ruby application, including custom frameworks.
|
|
70
|
+
email:
|
|
71
|
+
- komikers09@gmail.com
|
|
72
|
+
executables:
|
|
73
|
+
- bloodinary
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- assets/logo.webp
|
|
80
|
+
- bin/bloodinary
|
|
81
|
+
- lib/bloodinary.rb
|
|
82
|
+
- lib/bloodinary/processor.rb
|
|
83
|
+
- lib/bloodinary/reporter.rb
|
|
84
|
+
- lib/bloodinary/rules/base_rule.rb
|
|
85
|
+
- lib/bloodinary/rules/command_injection.rb
|
|
86
|
+
- lib/bloodinary/rules/file_access.rb
|
|
87
|
+
- lib/bloodinary/rules/insecure_redirect.rb
|
|
88
|
+
- lib/bloodinary/rules/sql_injection.rb
|
|
89
|
+
- lib/bloodinary/rules/weak_crypto.rb
|
|
90
|
+
- lib/bloodinary/rules/xss_vulnerability.rb
|
|
91
|
+
- lib/bloodinary/scanner.rb
|
|
92
|
+
homepage: https://github.com/IshikawaUta/bloodinary
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata: {}
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- lib
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: 3.0.0
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 3.6.7
|
|
111
|
+
specification_version: 4
|
|
112
|
+
summary: Premium static analysis security testing (SAST) for Ruby applications.
|
|
113
|
+
test_files: []
|