shieldpress-tracker 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/CHANGELOG.md +9 -0
- data/LICENSE +6 -0
- data/README.md +57 -0
- data/lib/shieldpress_tracker/collectors.rb +120 -0
- data/lib/shieldpress_tracker/config.rb +30 -0
- data/lib/shieldpress_tracker/middleware.rb +20 -0
- data/lib/shieldpress_tracker/railtie.rb +10 -0
- data/lib/shieldpress_tracker/reporter.rb +42 -0
- data/lib/shieldpress_tracker/tracker.rb +64 -0
- data/lib/shieldpress_tracker/version.rb +4 -0
- data/lib/shieldpress_tracker.rb +14 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4ba3c8cf44d7637a43997a65762cebe985040105944be0f767009d6e6587769a
|
|
4
|
+
data.tar.gz: 955a4f2848f902e752e042de0d5ea9b7653083fb3bdc0958232aed4ef101659c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61cc4a6aa6a6584cf9b80d7197593a11a46bf19172c79bb516cd3565e0ee5fddfda80dbefb4b3fecbc19da3d6d0e9f019f0e6288973a5954f68d6c8ccc8a6da4
|
|
7
|
+
data.tar.gz: '0148c792dbdd13c93964fbab0b257908eb383b79c62fa00d28233b0603f19640145b0611a0ffccc66e33d2b15f9e34b5baebf4d1a89d9582dd2c7a03d83d1dbc'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 - 2026-07-19
|
|
4
|
+
|
|
5
|
+
- Initial RubyGems release.
|
|
6
|
+
- Added system, runtime, HTTP, error, security, environment and dependency collectors.
|
|
7
|
+
- Added periodic reporting, heartbeat, request filtering and Rack middleware.
|
|
8
|
+
- Added automatic Rails middleware registration after initialization.
|
|
9
|
+
- Added recursive credential redaction before report serialization.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShieldPress
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files, to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies, subject to the conditions of the MIT License.
|
|
6
|
+
|
data/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://shieldpress.net/">
|
|
3
|
+
<img src="https://shieldpress.net/logo.png" alt="ShieldPress" width="500">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
# shieldpress-tracker for Ruby
|
|
8
|
+
|
|
9
|
+
Monitoring, performance and application-security telemetry for Ruby 2.7+, Rack and Rails. It uses only Ruby standard-library dependencies.
|
|
10
|
+
|
|
11
|
+
Dashboard: [https://shieldpress.net/](https://shieldpress.net/)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install shieldpress-tracker
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require 'shieldpress_tracker'
|
|
23
|
+
|
|
24
|
+
tracker = ShieldPress.init(
|
|
25
|
+
api_key: 'YOUR_SHIELDPRESS_API_KEY',
|
|
26
|
+
site_id: 'auto',
|
|
27
|
+
app_name: 'my-ruby-app'
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Configuration can also use `SHIELDPRESS_API_KEY`, `SHIELDPRESS_SITE_ID`, `SHIELDPRESS_API_URL`, `SHIELDPRESS_APP_NAME` and `SHIELDPRESS_APP_VERSION` environment variables.
|
|
32
|
+
|
|
33
|
+
## Rack
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
use ShieldPressTracker::Middleware, ShieldPress.instance
|
|
37
|
+
run MyApp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Rails automatically adds the Rack middleware when `ShieldPress.init` runs from an initializer.
|
|
41
|
+
|
|
42
|
+
## Manual instrumentation
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
tracker.record_request(path: '/orders', method: 'POST', status_code: 201, duration_ms: 42.5)
|
|
46
|
+
tracker.capture_error(StandardError.new('Invalid order'), order_id: 123)
|
|
47
|
+
tracker.analyze_request(url: '/login', method: 'POST', headers: headers, body: body, ip: '203.0.113.2')
|
|
48
|
+
tracker.record_auth_failure(ip: '203.0.113.2', url: '/login', reason: 'bad password')
|
|
49
|
+
tracker.record_rate_limit(ip: '203.0.113.2', url: '/api')
|
|
50
|
+
tracker.record_cors_violation(ip: '203.0.113.2', origin: 'https://untrusted.example', url: '/api')
|
|
51
|
+
tracker.flush
|
|
52
|
+
tracker.stop
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Options: `api_url`, `report_interval`, `system_metrics`, `http_tracking`, `error_tracking`, `security_tracking`, `dep_audit`, `dep_audit_interval`, `runtime_metrics`, `env_security`, `heartbeat`, `app_version`, `environment`, `tags`, `ignore_paths`, `max_error_buffer`, `max_security_buffer`, and `debug`.
|
|
56
|
+
|
|
57
|
+
The SDK never raises a network/reporting failure into the host application. Reports are sent to `/api/tracker/report` with Bearer authentication.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
require "etc"
|
|
2
|
+
require "json"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module ShieldPressTracker
|
|
8
|
+
module Helpers
|
|
9
|
+
def self.percentile(values, percentile)
|
|
10
|
+
return 0 if values.empty?
|
|
11
|
+
values.sort[[(percentile / 100.0 * values.length).ceil - 1, 0].max]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class HttpCollector
|
|
16
|
+
def initialize
|
|
17
|
+
@records, @started, @mutex = [], Process.clock_gettime(Process::CLOCK_MONOTONIC), Mutex.new
|
|
18
|
+
end
|
|
19
|
+
def record(path:, method:, status_code:, duration_ms:)
|
|
20
|
+
@mutex.synchronize { @records.shift(1000) if @records.length >= 10_000; @records << [path, method, status_code.to_i, duration_ms.to_f] }
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
def flush
|
|
25
|
+
records = @mutex.synchronize { rows = @records; @records = []; rows }
|
|
26
|
+
return nil if records.empty?
|
|
27
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC); elapsed = [now - @started, 0.001].max; @started = now
|
|
28
|
+
durations, statuses, grouped = records.map(&:last), Hash.new(0), Hash.new { |h, k| h[k] = [] }
|
|
29
|
+
records.each { |path, method, status, ms| statuses[status.to_s] += 1; grouped[[method, path]] << [status, ms] }
|
|
30
|
+
top = grouped.map do |(method, path), rows|
|
|
31
|
+
times = rows.map(&:last)
|
|
32
|
+
{ path: path, method: method, count: rows.length, avgMs: (times.sum / times.length).round,
|
|
33
|
+
p95Ms: Helpers.percentile(times, 95), errorCount: rows.count { |r| r[0] >= 400 } }
|
|
34
|
+
end.sort_by { |x| -x[:count] }.first(20)
|
|
35
|
+
{ totalRequests: records.length, totalErrors: records.count { |r| r[2] >= 500 }, avgResponseMs: (durations.sum / durations.length).round,
|
|
36
|
+
p50ResponseMs: Helpers.percentile(durations, 50), p95ResponseMs: Helpers.percentile(durations, 95),
|
|
37
|
+
p99ResponseMs: Helpers.percentile(durations, 99), maxResponseMs: durations.max, statusCodes: statuses,
|
|
38
|
+
topPaths: top, requestsPerSecond: (records.length / elapsed).round(2) }
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class ErrorCollector
|
|
45
|
+
def initialize(maximum); @maximum, @events, @mutex = maximum, [], Mutex.new end
|
|
46
|
+
def capture(error, meta = {})
|
|
47
|
+
event = { message: error.to_s[0, 4096], type: error.class.name, timestamp: Time.now.utc.iso8601 }
|
|
48
|
+
event[:stack] = Array(error.backtrace).join("\n")[0, 4096] if error.respond_to?(:backtrace)
|
|
49
|
+
meta = meta.dup
|
|
50
|
+
{ url: :url, method: :method, status_code: :statusCode }.each { |from, to| event[to] = meta.delete(from) if meta.key?(from) }
|
|
51
|
+
event[:meta] = meta unless meta.empty?
|
|
52
|
+
@mutex.synchronize { @events << event; @events = @events.last(@maximum) }
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
def flush; @mutex.synchronize { rows = @events; @events = []; rows } end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class SecurityCollector
|
|
60
|
+
RULES = [
|
|
61
|
+
[:sql_injection, :critical, /\b(union|select|drop|delete)\b.*\b(from|table|where)\b|--|\/\*/i],
|
|
62
|
+
[:xss_attempt, :high, /<script|javascript:|onerror\s*=/i],
|
|
63
|
+
[:command_injection, :critical, /[;&|`]\s*(rm|curl|wget|bash|sh|python)\b|\$\(/i],
|
|
64
|
+
[:suspicious_path, :high, /\.\.\/|%2e%2e|\/\.env|\/\.git/i],
|
|
65
|
+
[:bot_detected, :medium, /sqlmap|nikto|nmap|burp|nuclei|wpscan/i]
|
|
66
|
+
].freeze
|
|
67
|
+
def initialize(maximum); @maximum, @events, @ips, @mutex = maximum, [], {}, Mutex.new end
|
|
68
|
+
def record(type:, severity:, message:, **data)
|
|
69
|
+
event = { type: type, severity: severity, message: message.to_s[0, 4096], timestamp: Time.now.utc.iso8601 }.merge(data.compact)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@events << event; @events = @events.last(@maximum)
|
|
72
|
+
if data[:ip]; row = (@ips[data[:ip]] ||= { count: 0, types: [] }); row[:count] += 1; row[:types] |= [type]; end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
def analyze(url:, method: "GET", headers: {}, body: nil, ip: nil, query: nil)
|
|
76
|
+
headers = headers.transform_keys { |k| k.to_s.downcase }; text = "#{url} #{body} #{query.to_json}"; ua = headers["user-agent"].to_s
|
|
77
|
+
RULES.each do |type, severity, pattern|
|
|
78
|
+
target = type == :bot_detected ? ua : text
|
|
79
|
+
record(type: type, severity: severity, message: "#{type.to_s.tr('_', ' ')} detected in request", ip: ip, url: url,
|
|
80
|
+
method: method, userAgent: ua, payload: %i[sql_injection xss_attempt command_injection].include?(type) ? text[0, 500] : nil) if pattern.match?(target)
|
|
81
|
+
end
|
|
82
|
+
record(type: :oversized_payload, severity: :medium, message: "Request payload exceeds 10MB", ip: ip, url: url, method: method) if headers["content-length"].to_i > 10 * 1024 * 1024
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
def flush
|
|
87
|
+
events = @mutex.synchronize { rows = @events; @events = []; rows }
|
|
88
|
+
return nil if events.empty? && @ips.empty?
|
|
89
|
+
severity = %i[critical high medium low info].to_h { |x| [x, 0] }; types = Hash.new(0)
|
|
90
|
+
events.each { |e| severity[e[:severity]] += 1; types[e[:type]] += 1 }
|
|
91
|
+
ips = @ips.map { |ip, row| { ip: ip, count: row[:count], types: row[:types] } }.sort_by { |x| -x[:count] }.first(10)
|
|
92
|
+
{ totalEvents: events.length, bySeverity: severity, byType: types, topOffendingIps: ips, events: events.last(50) }
|
|
93
|
+
rescue StandardError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
module Metrics
|
|
99
|
+
def self.system
|
|
100
|
+
{ cpuPercent: 0, cpuCount: Etc.nprocessors, memUsedMb: 0, memTotalMb: 0, memPercent: 0,
|
|
101
|
+
uptimeSeconds: Process.clock_gettime(Process::CLOCK_MONOTONIC).round, loadAvg: [0, 0, 0], platform: RUBY_PLATFORM,
|
|
102
|
+
rubyVersion: RUBY_VERSION, pid: Process.pid, osName: RbConfig::CONFIG["host_os"], osArch: RbConfig::CONFIG["host_cpu"],
|
|
103
|
+
hostname: Socket.gethostname, cwd: Dir.pwd, execPath: RbConfig.ruby }
|
|
104
|
+
end
|
|
105
|
+
def self.runtime
|
|
106
|
+
{ threadCount: Thread.list.length, gcCount: GC.stat[:count], heapLiveSlots: GC.stat[:heap_live_slots], rubyEngine: defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby" }
|
|
107
|
+
end
|
|
108
|
+
def self.environment
|
|
109
|
+
names = ENV.keys.select { |k| k.match?(/KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i) }.first(50)
|
|
110
|
+
{ rubyVersion: RUBY_VERSION, rubyPlatform: RUBY_PLATFORM, debugModeEnabled: !!$DEBUG,
|
|
111
|
+
envVarWarnings: names.map { |k| "Potential secret present in environment variable: #{k}" }, securityHeaders: [], opensslVersion: defined?(OpenSSL) ? OpenSSL::OPENSSL_VERSION : nil }
|
|
112
|
+
end
|
|
113
|
+
def self.dependencies
|
|
114
|
+
specs = Gem.loaded_specs.values
|
|
115
|
+
{ totalDeps: specs.length, totalVulnerabilities: 0, bySeverity: {}, vulnerabilities: [], outdatedCount: 0,
|
|
116
|
+
lastAuditAt: Time.now.utc.iso8601, packages: specs.first(200).map { |s| { name: s.name, version: s.version.to_s } } }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ShieldPressTracker
|
|
2
|
+
class Config
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
api_url: "https://api.shieldpress.net", report_interval: 60, system_metrics: true,
|
|
5
|
+
http_tracking: true, error_tracking: true, security_tracking: true, dep_audit: true,
|
|
6
|
+
dep_audit_interval: 3600, runtime_metrics: true, env_security: true, heartbeat: true,
|
|
7
|
+
app_name: "ruby-app", app_version: "0.0.0", environment: "production", tags: {},
|
|
8
|
+
ignore_paths: ["/health", "/healthz", "/ready", "/favicon.ico", "/assets/*"],
|
|
9
|
+
max_error_buffer: 100, max_security_buffer: 500, debug: false
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader(*DEFAULTS.keys, :api_key, :site_id)
|
|
13
|
+
|
|
14
|
+
def initialize(values = {})
|
|
15
|
+
values = values.transform_keys(&:to_sym)
|
|
16
|
+
@api_key = values[:api_key] || ENV["SHIELDPRESS_API_KEY"] || ENV["SHIELDPRESS_SECRET"]
|
|
17
|
+
@site_id = values[:site_id] || ENV["SHIELDPRESS_SITE_ID"] || ENV["SHIELDPRESS_APP_ID"]
|
|
18
|
+
raise ArgumentError, "[ShieldPress] api_key is required" if @api_key.to_s.empty?
|
|
19
|
+
raise ArgumentError, "[ShieldPress] site_id is required" if @site_id.to_s.empty?
|
|
20
|
+
DEFAULTS.each do |key, default|
|
|
21
|
+
env = { api_url: "SHIELDPRESS_API_URL", app_name: "SHIELDPRESS_APP_NAME", app_version: "SHIELDPRESS_APP_VERSION" }[key]
|
|
22
|
+
value = values.key?(key) ? values[key] : (env && ENV[env]) || default
|
|
23
|
+
instance_variable_set("@#{key}", value)
|
|
24
|
+
end
|
|
25
|
+
@api_url = @api_url.sub(%r{/$}, "")
|
|
26
|
+
@environment = values[:environment] || ENV["APP_ENV"] || ENV["RACK_ENV"] || "production"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module ShieldPressTracker
|
|
2
|
+
class Middleware
|
|
3
|
+
def initialize(app, tracker = nil); @app, @tracker = app, tracker || ShieldPress.instance end
|
|
4
|
+
def call(env)
|
|
5
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC); path = env["PATH_INFO"] || "/"; method = env["REQUEST_METHOD"] || "GET"
|
|
6
|
+
headers = env.select { |k, _| k.start_with?("HTTP_") }.to_h { |k, v| [k.delete_prefix("HTTP_").tr("_", "-").downcase, v] }
|
|
7
|
+
@tracker.analyze_request(url: path, method: method, headers: headers, ip: env["REMOTE_ADDR"], query: env["QUERY_STRING"])
|
|
8
|
+
status, response_headers, body = @app.call(env)
|
|
9
|
+
[status, response_headers, body]
|
|
10
|
+
rescue StandardError => e
|
|
11
|
+
@tracker.capture_error(e, url: path, method: method); raise
|
|
12
|
+
ensure
|
|
13
|
+
if started
|
|
14
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
15
|
+
@tracker.record_request(path: path, method: method, status_code: status || 500, duration_ms: duration)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ShieldPressTracker
|
|
6
|
+
class Reporter
|
|
7
|
+
SENSITIVE_KEY = /authorization|cookie|password|passwd|secret|token|api[_-]?key|credential/i
|
|
8
|
+
SENSITIVE_TEXT = /(bearer\s+)[A-Za-z0-9._~+\-\/=]+|([?&](?:password|passwd|secret|token|api[_-]?key)=)[^&\s]+/i
|
|
9
|
+
|
|
10
|
+
def redact(value, depth = 0)
|
|
11
|
+
return "[MAX_DEPTH]" if depth > 12
|
|
12
|
+
case value
|
|
13
|
+
when Hash
|
|
14
|
+
value.each_with_object({}) { |(key, item), out| out[key] = key.to_s.match?(SENSITIVE_KEY) ? "[REDACTED]" : redact(item, depth + 1) }
|
|
15
|
+
when Array
|
|
16
|
+
value.first(500).map { |item| redact(item, depth + 1) }
|
|
17
|
+
when String
|
|
18
|
+
value.gsub(SENSITIVE_TEXT) { "#{Regexp.last_match(1) || Regexp.last_match(2)}[REDACTED]" }[0, 16_384]
|
|
19
|
+
else
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(api_url, api_key, debug = false); @uri, @api_key, @debug = URI(api_url + "/api/tracker/report"), api_key, debug end
|
|
25
|
+
def send(payload)
|
|
26
|
+
payload = redact(payload)
|
|
27
|
+
body = JSON.generate(payload)
|
|
28
|
+
if body.bytesize > 512 * 1024
|
|
29
|
+
payload[:_truncated] = true; payload[:errors] = Array(payload[:errors]).first(20)
|
|
30
|
+
payload[:security][:events] = Array(payload[:security][:events]).first(10) if payload[:security]
|
|
31
|
+
body = JSON.generate(payload)
|
|
32
|
+
end
|
|
33
|
+
request = Net::HTTP::Post.new(@uri); request["Content-Type"] = "application/json"
|
|
34
|
+
request["Authorization"] = "Bearer #{@api_key}"; request["User-Agent"] = "shieldpress-tracker-ruby/1.0.0"; request.body = body
|
|
35
|
+
response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: 5, read_timeout: 10) { |http| http.request(request) }
|
|
36
|
+
response.code.to_i.between?(200, 299)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
warn("[ShieldPress] report failed: #{e.message}") if @debug
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "config"
|
|
3
|
+
require_relative "collectors"
|
|
4
|
+
require_relative "reporter"
|
|
5
|
+
|
|
6
|
+
module ShieldPressTracker
|
|
7
|
+
class Tracker
|
|
8
|
+
attr_reader :config, :http_collector, :error_collector, :security_collector
|
|
9
|
+
def initialize(config = {})
|
|
10
|
+
@config = Config.new(config); @http_collector = HttpCollector.new
|
|
11
|
+
@error_collector = ErrorCollector.new(@config.max_error_buffer); @security_collector = SecurityCollector.new(@config.max_security_buffer)
|
|
12
|
+
@reporter = Reporter.new(@config.api_url, @config.api_key, @config.debug); @stopped = false; @last_audit = Time.at(0)
|
|
13
|
+
end
|
|
14
|
+
def start
|
|
15
|
+
return self if @thread&.alive?
|
|
16
|
+
@stopped = false
|
|
17
|
+
@thread = Thread.new do
|
|
18
|
+
Thread.current.name = "shieldpress-reporter" if Thread.current.respond_to?(:name=)
|
|
19
|
+
until @stopped; sleep(@config.report_interval.to_i); flush unless @stopped; end
|
|
20
|
+
end
|
|
21
|
+
@thread.abort_on_exception = false
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
def stop(flush: true); @stopped = true; @thread&.kill; self.flush if flush; self end
|
|
25
|
+
def ignored?(path)
|
|
26
|
+
path = URI.parse(path).path rescue path
|
|
27
|
+
@config.ignore_paths.any? { |pattern| pattern.end_with?("/*") ? path.start_with?(pattern[0..-2]) : path == pattern }
|
|
28
|
+
end
|
|
29
|
+
def record_request(path:, method: "GET", status_code: 200, duration_ms: 0)
|
|
30
|
+
@http_collector.record(path: path, method: method, status_code: status_code, duration_ms: duration_ms) if @config.http_tracking && !ignored?(path)
|
|
31
|
+
end
|
|
32
|
+
def analyze_request(url:, method: "GET", headers: {}, body: nil, ip: nil, query: nil)
|
|
33
|
+
@security_collector.analyze(url: url, method: method, headers: headers, body: body, ip: ip, query: query) if @config.security_tracking && !ignored?(url)
|
|
34
|
+
end
|
|
35
|
+
def capture_error(error, meta = {}); @error_collector.capture(error, meta) if @config.error_tracking end
|
|
36
|
+
def record_auth_failure(ip: nil, url: nil, method: nil, reason: nil)
|
|
37
|
+
@security_collector.record(type: :auth_failure, severity: :medium, message: "Authentication failed#{reason ? ": #{reason}" : ""}", ip: ip, url: url, method: method)
|
|
38
|
+
end
|
|
39
|
+
def record_rate_limit(ip: nil, url: nil, method: nil, limit: nil)
|
|
40
|
+
@security_collector.record(type: :rate_limit_hit, severity: :low, message: "Rate limit triggered", ip: ip, url: url, method: method, meta: { limit: limit })
|
|
41
|
+
end
|
|
42
|
+
def record_cors_violation(ip: nil, origin: nil, url: nil)
|
|
43
|
+
@security_collector.record(type: :cors_violation, severity: :medium, message: "CORS policy blocked request from origin: #{origin || 'unknown'}", ip: ip, url: url)
|
|
44
|
+
end
|
|
45
|
+
def record_security_event(type:, severity:, message:, **data)
|
|
46
|
+
@security_collector.record(type: type, severity: severity, message: message, **data)
|
|
47
|
+
end
|
|
48
|
+
def flush
|
|
49
|
+
c = @config
|
|
50
|
+
payload = { siteId: c.site_id, appName: c.app_name, appVersion: c.app_version, environment: c.environment,
|
|
51
|
+
tags: c.tags, timestamp: Time.now.utc.iso8601 }
|
|
52
|
+
payload[:system] = Metrics.system if c.system_metrics
|
|
53
|
+
http = @http_collector.flush if c.http_tracking; payload[:http] = http if http
|
|
54
|
+
errors = @error_collector.flush if c.error_tracking; payload[:errors] = errors unless errors.nil? || errors.empty?
|
|
55
|
+
security = @security_collector.flush if c.security_tracking; payload[:security] = security if security
|
|
56
|
+
payload[:runtime] = Metrics.runtime if c.runtime_metrics; payload[:envSecurity] = Metrics.environment if c.env_security
|
|
57
|
+
if c.dep_audit && Time.now - @last_audit >= c.dep_audit_interval.to_i; payload[:depAudit] = Metrics.dependencies; @last_audit = Time.now; end
|
|
58
|
+
payload[:heartbeat] = true if c.heartbeat
|
|
59
|
+
@reporter.send(payload)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative "shieldpress_tracker/version"
|
|
2
|
+
require_relative "shieldpress_tracker/tracker"
|
|
3
|
+
require_relative "shieldpress_tracker/middleware"
|
|
4
|
+
|
|
5
|
+
module ShieldPress
|
|
6
|
+
class << self
|
|
7
|
+
def init(**config); @instance&.stop(flush: false); @instance = ShieldPressTracker::Tracker.new(config).start end
|
|
8
|
+
def instance; @instance || raise("ShieldPress.init must be called first") end
|
|
9
|
+
def configured?; !@instance.nil? end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
require_relative "shieldpress_tracker/railtie" if defined?(Rails::Railtie)
|
|
14
|
+
|
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: shieldpress-tracker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ShieldPress
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- hello@shieldpress.net
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/shieldpress_tracker.rb
|
|
24
|
+
- lib/shieldpress_tracker/collectors.rb
|
|
25
|
+
- lib/shieldpress_tracker/config.rb
|
|
26
|
+
- lib/shieldpress_tracker/middleware.rb
|
|
27
|
+
- lib/shieldpress_tracker/railtie.rb
|
|
28
|
+
- lib/shieldpress_tracker/reporter.rb
|
|
29
|
+
- lib/shieldpress_tracker/tracker.rb
|
|
30
|
+
- lib/shieldpress_tracker/version.rb
|
|
31
|
+
homepage: https://shieldpress.net
|
|
32
|
+
licenses:
|
|
33
|
+
- MIT
|
|
34
|
+
metadata:
|
|
35
|
+
source_code_uri: https://github.com/vithanhlam/ShieldPress-Tracker-Ruby-Gem
|
|
36
|
+
changelog_uri: https://github.com/vithanhlam/ShieldPress-Tracker-Ruby-Gem/blob/main/CHANGELOG.md
|
|
37
|
+
rubygems_mfa_required: 'true'
|
|
38
|
+
post_install_message:
|
|
39
|
+
rdoc_options: []
|
|
40
|
+
require_paths:
|
|
41
|
+
- lib
|
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 2.7.0
|
|
47
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
requirements: []
|
|
53
|
+
rubygems_version: 3.2.33
|
|
54
|
+
signing_key:
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Monitoring, performance and security SDK for Ruby applications
|
|
57
|
+
test_files: []
|