auditea 0.1.0.beta.2 → 0.1.0.beta.4
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/CHANGELOG.md +45 -1
- data/README.md +24 -3
- data/RELEASE.md +5 -5
- data/lib/auditea/host_inventory/collector.rb +47 -1
- data/lib/auditea/host_inventory/component_detectors.rb +312 -0
- data/lib/auditea/instrumentation/active_job.rb +3 -0
- data/lib/auditea/instrumentation/error_subscriber.rb +93 -0
- data/lib/auditea/instrumentation/exception.rb +134 -16
- data/lib/auditea/instrumentation/structural_request.rb +136 -0
- data/lib/auditea/inventory/collector.rb +126 -0
- data/lib/auditea/inventory/components.rb +80 -0
- data/lib/auditea/inventory/database_server.rb +57 -0
- data/lib/auditea/middleware/context.rb +2 -0
- data/lib/auditea/railtie.rb +5 -3
- data/lib/auditea/sanitizer.rb +11 -1
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +21 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
|
|
4
|
+
data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: faf9f0876938d536fe4aad106bba8b13ee72031ecb78c14d259a9a313eb046c84601b2b0ec3af9cb510d0b9581c7eb142aea169170f364e813b79ef7ae65f32d
|
|
7
|
+
data.tar.gz: df66f3cab946bb98383c903d550f4f7cf842386e4fea0b2fb11b6d037b4c0a2beee1d652c3381199e9c35a30c743a17039844b68c78494429fc64d04448fbc96
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.0.beta.
|
|
3
|
+
## 0.1.0.beta.4 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Platform component inventory (AUD-PLATFORM-INVENTORY01)
|
|
6
|
+
|
|
7
|
+
- Additive optional `evidence.inventory.components[]` on `runtime.inventory` and `host.inventory` (schema_version remains 1)
|
|
8
|
+
- Component fields: `product`, `version`, `scope`, `observation_method`, optional `package` / `evidence`
|
|
9
|
+
- Scopes: `application_runtime`, `application_server`, `database_server`, `linked_library`, `host_os`, `host_component`, `reverse_proxy`, `externally_observed`
|
|
10
|
+
- Runtime: promote Ruby/Rails/Bundler/RubyGems/linked OpenSSL; promote Puma (and peers) from packages; PostgreSQL server via fail-open `SHOW server_version` (never host-attached)
|
|
11
|
+
- Host: OS/kernel components; allowlisted detectors (`openssl version`, `apache2`/`httpd -v`, `nginx -v`) with timeout, bounded I/O, no shell, no sudo
|
|
12
|
+
- Absent reverse proxies are simply not observed (never claimed “not installed”)
|
|
13
|
+
|
|
14
|
+
### FIX01 hardening
|
|
15
|
+
|
|
16
|
+
- Detectors resolve allowlisted basenames from fixed trusted system directories only (realpath boundary); never `ENV["PATH"]`
|
|
17
|
+
- Detector evidence is basename + observation metadata only — no raw stdout/stderr
|
|
18
|
+
- Aggregate detector wall-clock budget; hanging children are process-group killed and reaped
|
|
19
|
+
- Rails `after_initialize` schedules existing background inventory refresh (does not block boot)
|
|
20
|
+
- PostgreSQL version uses `connection_pool.with_connection` and releases the checkout
|
|
21
|
+
|
|
22
|
+
### FIX02 executable trust
|
|
23
|
+
|
|
24
|
+
- After realpath containment, require root-owned (uid 0) directory and executable
|
|
25
|
+
- Reject group/world-writable directories or executables; reject app-UID-owned material
|
|
26
|
+
- Ordinary `/bin` → `/usr/bin` realpath equivalence remains supported; symlink escapes fail open as not observed
|
|
27
|
+
|
|
28
|
+
### FIX03 process lifecycle
|
|
29
|
+
|
|
30
|
+
- After `Process.spawn`, every exit path terminates/reaps the child (ensure-owned cleanup; not timeout-only)
|
|
31
|
+
- Fail-open cleanup tolerates ESRCH / ECHILD / EINTR; no double-wait escape
|
|
32
|
+
|
|
33
|
+
## 0.1.0.beta.3 (unreleased)
|
|
34
|
+
|
|
35
|
+
### Exception capture parity (AUD-ERROR-PARITY01)
|
|
36
|
+
|
|
37
|
+
- Public `Auditea.capture_exception(error, handled:, mechanism:, context:, metadata:)` → `exception.raised`
|
|
38
|
+
- Rails `ErrorReporter` subscriber via public `subscribe` API (Rails 7.2 / 8.1); graceful no-op when absent
|
|
39
|
+
- Identity-safe WeakMap dedupe of exception objects (no integer `object_id` reuse false negatives); cleared at request/job boundaries
|
|
40
|
+
- When `CurrentContext` is already cleared (post-unwind `application.action_dispatch`), derive method/path/request_id/controller/action from the ErrorReporter controller/request via public APIs only
|
|
41
|
+
- `process_action.action_controller` remains as fallback when ErrorReporter is not installed
|
|
42
|
+
- Structured evidence: type, message, bounded frames (≤40) with deterministic `in_app` when knowable, `handled`, `mechanism`, HTTP path/method (no query secrets), controller/action when available
|
|
43
|
+
- ActiveJob: ErrorReporter sources matching `active_job` are skipped when `capture_active_job` is enabled (dedicated `job.failed` path unchanged)
|
|
44
|
+
- Real Rails 7.2 / 8.1 integration coverage for unhandled/handled controller exceptions and ActiveJob no-duplicate behavior
|
|
45
|
+
- Provenance unchanged: sdk/ruby/rails versions + configured `release`
|
|
46
|
+
|
|
47
|
+
## 0.1.0.beta.2
|
|
4
48
|
|
|
5
49
|
### Runtime / dependency inventory (opt-in, default OFF)
|
|
6
50
|
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Ruby / Rails SDK for [AudiTea](https://github.com/reznis/AudiTea) event collection.
|
|
4
4
|
|
|
5
|
-
Version: **0.1.0.beta.
|
|
5
|
+
Version: **0.1.0.beta.4** (not published yet)
|
|
6
6
|
|
|
7
7
|
Captures durable technical and business evidence. AudiTea **server** owns taxonomy, dictionary, human labels, vulnerability matching, correlation, and reinterpretation.
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ The SDK does **not** perform vulnerability scanning, OS package/CVE inventory, d
|
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
# Gemfile
|
|
15
|
-
gem "auditea", "0.1.0.beta.
|
|
15
|
+
gem "auditea", "0.1.0.beta.4"
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
```bash
|
|
@@ -48,6 +48,19 @@ Without a Source token (and with the SDK enabled), capture is skipped (`not_conf
|
|
|
48
48
|
| `capture_host_inventory` | `true` only if multi-host/container identity matters | Default OFF; enable per environment decision |
|
|
49
49
|
| `capture_requests` | `false` initially | Avoid high-volume request spam on first connect |
|
|
50
50
|
|
|
51
|
+
Example environment (replace the secret and release id; never commit real tokens):
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
AUDITEA_ENDPOINT=https://auditea.ca
|
|
55
|
+
AUDITEA_SOURCE_TOKEN=<secret>
|
|
56
|
+
AUDITEA_RELEASE=<actual deployed SHA>
|
|
57
|
+
AUDITEA_CAPTURE_EXCEPTIONS=true
|
|
58
|
+
AUDITEA_CAPTURE_RUNTIME_INVENTORY=true
|
|
59
|
+
AUDITEA_CAPTURE_HOST_INVENTORY=true
|
|
60
|
+
AUDITEA_RAISE_ERRORS=false
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
|
|
51
64
|
### Optional
|
|
52
65
|
|
|
53
66
|
Async tuning (`AUDITEA_ASYNC`, batch/flush/queue/timeouts), `AUDITEA_INVENTORY_REFRESH_INTERVAL`, `AUDITEA_HOST_KEY` / `AUDITEA_HOST_LABEL`, `capture_request_params`, `raise_errors` (debug only).
|
|
@@ -114,9 +127,17 @@ Auditea.capture(
|
|
|
114
127
|
evidence: { business: { from_status: "draft", to_status: "approved" } }
|
|
115
128
|
)
|
|
116
129
|
|
|
130
|
+
Auditea.capture_exception(
|
|
131
|
+
error,
|
|
132
|
+
handled: true,
|
|
133
|
+
mechanism: "application_rescue"
|
|
134
|
+
)
|
|
135
|
+
|
|
117
136
|
Auditea.log(:info, "Export finished", context: { export_id: export.id })
|
|
118
137
|
```
|
|
119
138
|
|
|
139
|
+
Automatic Rails exception observation uses `Rails.error` (ErrorReporter) when available, with `process_action.action_controller` as fallback. Same-exception dedupe is request/thread-local by object id so ErrorReporter and controller notifications do not double-count.
|
|
140
|
+
|
|
120
141
|
Wire format follows AudiTea **EVENT_CONTRACT_V1**:
|
|
121
142
|
|
|
122
143
|
- Bearer Source credential authentication
|
|
@@ -147,7 +168,7 @@ AUDITEA_CAPTURE_RUNTIME_INVENTORY=true
|
|
|
147
168
|
# optional: AUDITEA_INVENTORY_REFRESH_INTERVAL=86400
|
|
148
169
|
```
|
|
149
170
|
|
|
150
|
-
With Rails, when capture is enabled, the Railtie
|
|
171
|
+
With Rails, when capture is enabled, the Railtie **schedules** a single-flight background inventory refresh after `after_initialize` (boot is not blocked on detectors or database work). Opportunistic Rack refresh uses the same scheduler when due — collection/delivery are never inline on the request path.
|
|
151
172
|
|
|
152
173
|
Enabling inventory reveals internal gem names to AudiTea. Keep it off unless that is intentional.
|
|
153
174
|
|
data/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Release: auditea 0.1.0.beta.
|
|
1
|
+
# Release: auditea 0.1.0.beta.4
|
|
2
2
|
|
|
3
3
|
Authoritative runbook for controlled beta publication of the Ruby SDK.
|
|
4
4
|
|
|
@@ -153,10 +153,10 @@ PR status may say `READY FOR HUMAN PUBLICATION REVIEW` while still listing exter
|
|
|
153
153
|
```bash
|
|
154
154
|
git checkout main
|
|
155
155
|
git pull
|
|
156
|
-
# confirm VERSION and CHANGELOG still say 0.1.0.beta.
|
|
156
|
+
# confirm VERSION and CHANGELOG still say 0.1.0.beta.4
|
|
157
157
|
gem build auditea.gemspec
|
|
158
158
|
# inspect package once more
|
|
159
|
-
gem push auditea-0.1.0.beta.
|
|
159
|
+
gem push auditea-0.1.0.beta.4.gem
|
|
160
160
|
```
|
|
161
161
|
|
|
162
162
|
Then mark CHANGELOG released with the publication date in a follow-up commit.
|
|
@@ -166,9 +166,9 @@ Then mark CHANGELOG released with the publication date in a follow-up commit.
|
|
|
166
166
|
## Post-publication verification
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
|
-
gem install auditea -v 0.1.0.beta.
|
|
169
|
+
gem install auditea -v 0.1.0.beta.4 --pre
|
|
170
170
|
ruby -rauditea -e 'puts Auditea::VERSION'
|
|
171
|
-
# expect: 0.1.0.beta.
|
|
171
|
+
# expect: 0.1.0.beta.4
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
Prefer installing the RubyGems artifact (not a git branch) into the controlled TEST app.
|
|
@@ -6,6 +6,8 @@ require "rbconfig"
|
|
|
6
6
|
require "socket"
|
|
7
7
|
require_relative "../inventory/string_bound"
|
|
8
8
|
require_relative "../inventory/digest"
|
|
9
|
+
require_relative "../inventory/components"
|
|
10
|
+
require_relative "component_detectors"
|
|
9
11
|
|
|
10
12
|
module Auditea
|
|
11
13
|
module HostInventory
|
|
@@ -17,7 +19,7 @@ module Auditea
|
|
|
17
19
|
MAX_FIELD_BYTES = 128
|
|
18
20
|
MAX_HOST_KEY = 128
|
|
19
21
|
HOST_KEY_FORMAT = /\A[A-Za-z0-9._:-]{8,128}\z/
|
|
20
|
-
MAX_INVENTORY_BYTES =
|
|
22
|
+
MAX_INVENTORY_BYTES = 16_384
|
|
21
23
|
OS_RELEASE_PATH = "/etc/os-release"
|
|
22
24
|
|
|
23
25
|
module_function
|
|
@@ -38,11 +40,54 @@ module Auditea
|
|
|
38
40
|
label = configured_label
|
|
39
41
|
inventory["label"] = label if label
|
|
40
42
|
|
|
43
|
+
components = Inventory::Components.compact_list(
|
|
44
|
+
host_os_components(inventory["platform"]) + ComponentDetectors.collect
|
|
45
|
+
)
|
|
46
|
+
inventory["components"] = components unless components.empty?
|
|
47
|
+
|
|
41
48
|
inventory = enforce_size_bound(inventory)
|
|
42
49
|
inventory["digest"] = Inventory::DigestComputer.compute(inventory)
|
|
43
50
|
inventory
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
def host_os_components(platform)
|
|
54
|
+
list = []
|
|
55
|
+
os_name = platform["os_name"]
|
|
56
|
+
os_version = platform["os_version"]
|
|
57
|
+
if present?(os_name) || present?(os_version)
|
|
58
|
+
product =
|
|
59
|
+
if os_name.to_s.match?(/ubuntu/i)
|
|
60
|
+
"Ubuntu"
|
|
61
|
+
elsif os_name.to_s.match?(/debian/i)
|
|
62
|
+
"Debian"
|
|
63
|
+
elsif present?(os_name)
|
|
64
|
+
os_name
|
|
65
|
+
else
|
|
66
|
+
"Host OS"
|
|
67
|
+
end
|
|
68
|
+
version = present?(os_version) ? os_version : "unknown"
|
|
69
|
+
list << Inventory::Components.build(
|
|
70
|
+
product: product,
|
|
71
|
+
version: version,
|
|
72
|
+
scope: "host_os",
|
|
73
|
+
observation_method: "os_release"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
kernel_name = platform["kernel_name"]
|
|
78
|
+
kernel_release = platform["kernel_release"]
|
|
79
|
+
if present?(kernel_release)
|
|
80
|
+
list << Inventory::Components.build(
|
|
81
|
+
product: present?(kernel_name) ? "#{kernel_name} kernel" : "Linux kernel",
|
|
82
|
+
version: kernel_release,
|
|
83
|
+
scope: "host_os",
|
|
84
|
+
observation_method: "uname"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
list
|
|
89
|
+
end
|
|
90
|
+
|
|
46
91
|
def resolve_host_key!
|
|
47
92
|
configured = Auditea.configuration.host_key.to_s.strip
|
|
48
93
|
configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
|
|
@@ -224,6 +269,7 @@ module Auditea
|
|
|
224
269
|
# Drop optional fields first; keep identity + required platform.
|
|
225
270
|
trimmed = inventory.dup
|
|
226
271
|
trimmed.delete("label")
|
|
272
|
+
trimmed.delete("components")
|
|
227
273
|
trimmed["hardware"] = {}
|
|
228
274
|
trimmed["runtime_context"] = {}
|
|
229
275
|
trimmed
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Auditea
|
|
6
|
+
module HostInventory
|
|
7
|
+
# Allowlisted local component detectors.
|
|
8
|
+
# Fixed argv only — no shell, no sudo, no PATH search, no caller-supplied paths.
|
|
9
|
+
module ComponentDetectors
|
|
10
|
+
# Wall-clock budget for the entire collect pass (all detectors/candidates).
|
|
11
|
+
AGGREGATE_TIMEOUT_SECONDS = 2.5
|
|
12
|
+
# Per-command cap; also clipped by remaining aggregate budget.
|
|
13
|
+
PER_COMMAND_TIMEOUT_SECONDS = 1.0
|
|
14
|
+
MAX_OUTPUT_BYTES = 2_048
|
|
15
|
+
ALLOWED_BASENAMES = %w[openssl apache2 httpd nginx].freeze
|
|
16
|
+
|
|
17
|
+
# Fixed trusted system directories only — never ENV["PATH"] or writable app/tmp dirs.
|
|
18
|
+
TRUSTED_BIN_DIRS = %w[
|
|
19
|
+
/usr/bin
|
|
20
|
+
/bin
|
|
21
|
+
/usr/sbin
|
|
22
|
+
/sbin
|
|
23
|
+
/usr/local/bin
|
|
24
|
+
/usr/local/sbin
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
DETECTORS = [
|
|
28
|
+
{
|
|
29
|
+
product: "OpenSSL",
|
|
30
|
+
scope: "host_component",
|
|
31
|
+
observation_method: "host.openssl_version",
|
|
32
|
+
candidates: [%w[openssl version]],
|
|
33
|
+
parser: :parse_openssl
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
product: "Apache HTTP Server",
|
|
37
|
+
scope: "reverse_proxy",
|
|
38
|
+
observation_method: "host.apache_v",
|
|
39
|
+
candidates: [%w[apache2 -v], %w[httpd -v]],
|
|
40
|
+
parser: :parse_apache
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
product: "nginx",
|
|
44
|
+
scope: "reverse_proxy",
|
|
45
|
+
observation_method: "host.nginx_v",
|
|
46
|
+
candidates: [%w[nginx -v]],
|
|
47
|
+
parser: :parse_nginx
|
|
48
|
+
}
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
def collect
|
|
54
|
+
deadline = monotonic_now + AGGREGATE_TIMEOUT_SECONDS
|
|
55
|
+
DETECTORS.filter_map { |detector| detect(detector, deadline: deadline) }
|
|
56
|
+
rescue StandardError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def detect(detector, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
|
|
61
|
+
detector.fetch(:candidates).each do |argv|
|
|
62
|
+
break if monotonic_now >= deadline
|
|
63
|
+
|
|
64
|
+
output = capture(argv, deadline: deadline)
|
|
65
|
+
next if output.nil?
|
|
66
|
+
|
|
67
|
+
version = send(detector.fetch(:parser), output)
|
|
68
|
+
next if version.nil? || version.empty?
|
|
69
|
+
|
|
70
|
+
return Inventory::Components.build(
|
|
71
|
+
product: detector.fetch(:product),
|
|
72
|
+
version: version,
|
|
73
|
+
scope: detector.fetch(:scope),
|
|
74
|
+
observation_method: detector.fetch(:observation_method),
|
|
75
|
+
evidence: {
|
|
76
|
+
"command" => argv.first.to_s
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Resolve basename from trusted directories only; execute absolute path + fixed args.
|
|
86
|
+
def capture(argv, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
|
|
87
|
+
basename = argv.first.to_s
|
|
88
|
+
return nil unless ALLOWED_BASENAMES.include?(basename)
|
|
89
|
+
|
|
90
|
+
remaining = deadline - monotonic_now
|
|
91
|
+
return nil if remaining <= 0
|
|
92
|
+
|
|
93
|
+
executable = resolve_executable(basename)
|
|
94
|
+
return nil unless executable
|
|
95
|
+
|
|
96
|
+
full_argv = [executable] + argv.drop(1).map(&:to_s)
|
|
97
|
+
timeout_seconds = [PER_COMMAND_TIMEOUT_SECONDS, remaining].min
|
|
98
|
+
return nil if timeout_seconds <= 0
|
|
99
|
+
|
|
100
|
+
run_bounded(full_argv, timeout_seconds)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_executable(basename)
|
|
106
|
+
return nil unless ALLOWED_BASENAMES.include?(basename)
|
|
107
|
+
return nil if basename.include?("/") || basename.include?("\\") || basename.include?("\0")
|
|
108
|
+
|
|
109
|
+
TRUSTED_BIN_DIRS.each do |dir|
|
|
110
|
+
next if dir.empty? || dir.include?("\0")
|
|
111
|
+
|
|
112
|
+
candidate = File.join(dir, basename)
|
|
113
|
+
next unless File.file?(candidate)
|
|
114
|
+
next unless File.executable?(candidate)
|
|
115
|
+
|
|
116
|
+
real_dir = begin
|
|
117
|
+
File.realpath(dir)
|
|
118
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
real_file = begin
|
|
123
|
+
File.realpath(candidate)
|
|
124
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Symlinks must not escape the trusted real directory boundary
|
|
129
|
+
# (supports ordinary /bin -> /usr/bin realpath equivalence).
|
|
130
|
+
next unless File.dirname(real_file) == real_dir
|
|
131
|
+
next unless trusted_material?(real_dir, real_file)
|
|
132
|
+
|
|
133
|
+
return real_file
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
rescue StandardError
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Trust is ownership + mode on the resolved real paths — not directory-name
|
|
141
|
+
# membership alone. Fail open (reject) when trust cannot be established.
|
|
142
|
+
#
|
|
143
|
+
# Policy:
|
|
144
|
+
# - material must be root-owned (uid 0); app-UID ownership is never enough
|
|
145
|
+
# - directory and executable must not be group- or world-writable
|
|
146
|
+
# - caller-configurable paths are never accepted
|
|
147
|
+
def trusted_material?(real_dir, real_file)
|
|
148
|
+
dir_stat = stat_path(real_dir)
|
|
149
|
+
file_stat = stat_path(real_file)
|
|
150
|
+
return false unless dir_stat && file_stat
|
|
151
|
+
|
|
152
|
+
trusted_stats?(dir_stat, file_stat)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def trusted_stats?(dir_stat, file_stat)
|
|
158
|
+
return false unless root_owned?(dir_stat) && root_owned?(file_stat)
|
|
159
|
+
return false if group_or_world_writable?(dir_stat)
|
|
160
|
+
return false if group_or_world_writable?(file_stat)
|
|
161
|
+
return false if app_user_owned_untrusted?(dir_stat)
|
|
162
|
+
return false if app_user_owned_untrusted?(file_stat)
|
|
163
|
+
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def root_owned?(stat)
|
|
168
|
+
stat.uid.zero?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Root-owned system material remains trusted even when the process euid is 0.
|
|
172
|
+
# Non-root owners (including the effective application user) are rejected.
|
|
173
|
+
def app_user_owned_untrusted?(stat)
|
|
174
|
+
return false if stat.uid.zero?
|
|
175
|
+
|
|
176
|
+
stat.uid == Process.euid
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def group_or_world_writable?(stat)
|
|
180
|
+
(stat.mode & 0o022).nonzero?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def stat_path(path)
|
|
184
|
+
File.stat(path)
|
|
185
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def run_bounded(full_argv, timeout_seconds)
|
|
190
|
+
out_r, out_w = IO.pipe
|
|
191
|
+
err_r, err_w = IO.pipe
|
|
192
|
+
pid = nil
|
|
193
|
+
|
|
194
|
+
begin
|
|
195
|
+
pid = Process.spawn(
|
|
196
|
+
*full_argv,
|
|
197
|
+
in: :close,
|
|
198
|
+
out: out_w,
|
|
199
|
+
err: err_w,
|
|
200
|
+
pgroup: true,
|
|
201
|
+
close_others: true
|
|
202
|
+
)
|
|
203
|
+
out_w.close
|
|
204
|
+
err_w.close
|
|
205
|
+
out_w = err_w = nil
|
|
206
|
+
|
|
207
|
+
stdout = ""
|
|
208
|
+
stderr = ""
|
|
209
|
+
status = nil
|
|
210
|
+
|
|
211
|
+
Timeout.timeout(timeout_seconds) do
|
|
212
|
+
stdout = out_r.read(MAX_OUTPUT_BYTES).to_s
|
|
213
|
+
stderr = err_r.read(MAX_OUTPUT_BYTES).to_s
|
|
214
|
+
_pid, status = Process.wait2(pid)
|
|
215
|
+
pid = nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
combined = "#{stdout}\n#{stderr}".byteslice(0, MAX_OUTPUT_BYTES).to_s
|
|
219
|
+
return nil if combined.strip.empty?
|
|
220
|
+
return nil if status && !status.success? && combined !~ /version|Server version|nginx/i
|
|
221
|
+
|
|
222
|
+
combined
|
|
223
|
+
rescue Timeout::Error
|
|
224
|
+
nil
|
|
225
|
+
ensure
|
|
226
|
+
# Any post-spawn exit path (timeout, other exceptions, early return after wait)
|
|
227
|
+
# must account for the child while pid remains owned by this method.
|
|
228
|
+
if pid
|
|
229
|
+
begin
|
|
230
|
+
reap_process_group(pid)
|
|
231
|
+
rescue StandardError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
pid = nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
begin
|
|
238
|
+
out_w&.close
|
|
239
|
+
rescue StandardError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
begin
|
|
243
|
+
err_w&.close
|
|
244
|
+
rescue StandardError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
begin
|
|
248
|
+
out_r.close
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
begin
|
|
253
|
+
err_r.close
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def reap_process_group(pid)
|
|
261
|
+
return unless pid
|
|
262
|
+
|
|
263
|
+
begin
|
|
264
|
+
Process.kill("KILL", -pid)
|
|
265
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
266
|
+
begin
|
|
267
|
+
Process.kill("KILL", pid)
|
|
268
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
loop do
|
|
274
|
+
Process.waitpid(pid)
|
|
275
|
+
break
|
|
276
|
+
rescue Errno::ECHILD
|
|
277
|
+
break
|
|
278
|
+
rescue Errno::EINTR
|
|
279
|
+
# Child may still be exiting; retry wait.
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
rescue StandardError
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def parse_openssl(output)
|
|
287
|
+
# OpenSSL 3.0.2 15 Mar 2022
|
|
288
|
+
if (match = output.match(/OpenSSL\s+([0-9][^\s]*)/i))
|
|
289
|
+
match[1]
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def parse_apache(output)
|
|
294
|
+
# Server version: Apache/2.4.52 (Ubuntu)
|
|
295
|
+
if (match = output.match(%r{Apache/([0-9][^\s)]*)}i))
|
|
296
|
+
match[1]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_nginx(output)
|
|
301
|
+
# nginx version: nginx/1.18.0 (Ubuntu)
|
|
302
|
+
if (match = output.match(%r{nginx/([0-9][^\s)]*)}i))
|
|
303
|
+
match[1]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def monotonic_now
|
|
308
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -23,10 +23,13 @@ module Auditea
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
::ActiveJob::Base.around_perform do |job, block|
|
|
26
|
+
Auditea::Instrumentation::Exception::Deduper.clear!
|
|
26
27
|
block.call
|
|
27
28
|
rescue StandardError => error
|
|
28
29
|
Auditea::Instrumentation::ActiveJob.report_unhandled(job, error)
|
|
29
30
|
raise
|
|
31
|
+
ensure
|
|
32
|
+
Auditea::Instrumentation::Exception::Deduper.clear!
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Rails / ActiveSupport::ErrorReporter subscriber (public API only).
|
|
6
|
+
# Installed when Rails.error / ActiveSupport.error_reporter#subscribe exists.
|
|
7
|
+
class ErrorSubscriber
|
|
8
|
+
ACTIVE_JOB_SOURCE = /active_job/i
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def install!
|
|
12
|
+
return if @installed
|
|
13
|
+
|
|
14
|
+
reporter = resolve_reporter
|
|
15
|
+
return unless reporter.respond_to?(:subscribe)
|
|
16
|
+
|
|
17
|
+
@subscriber = new
|
|
18
|
+
reporter.subscribe(@subscriber)
|
|
19
|
+
@installed = true
|
|
20
|
+
true
|
|
21
|
+
rescue StandardError => error
|
|
22
|
+
Auditea.handle_error(error)
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def installed?
|
|
27
|
+
!!@installed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset_for_tests!
|
|
31
|
+
reporter = resolve_reporter
|
|
32
|
+
reporter.unsubscribe(@subscriber) if @subscriber && reporter.respond_to?(:unsubscribe)
|
|
33
|
+
@subscriber = nil
|
|
34
|
+
@installed = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolve_reporter
|
|
38
|
+
if defined?(::Rails) && ::Rails.respond_to?(:error)
|
|
39
|
+
::Rails.error
|
|
40
|
+
elsif defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:error_reporter)
|
|
41
|
+
::ActiveSupport.error_reporter
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ActiveSupport::ErrorReporter subscriber contract — must never raise.
|
|
47
|
+
def report(error, handled:, severity: :error, context: {}, source: nil)
|
|
48
|
+
return unless Auditea.configuration.capture_exceptions
|
|
49
|
+
return if skip_active_job?(source)
|
|
50
|
+
|
|
51
|
+
structural = StructuralRequest.from_reporter_context(context)
|
|
52
|
+
|
|
53
|
+
Auditea::Instrumentation::Exception.capture(
|
|
54
|
+
error,
|
|
55
|
+
handled: handled,
|
|
56
|
+
mechanism: mechanism_for(source),
|
|
57
|
+
severity: map_severity(severity, handled),
|
|
58
|
+
evidence: structural["evidence"],
|
|
59
|
+
context: structural["context"],
|
|
60
|
+
metadata: {
|
|
61
|
+
"sdk_event_kind" => "auto_observed"
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
rescue StandardError => error
|
|
65
|
+
Auditea.handle_error(error)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def skip_active_job?(source)
|
|
71
|
+
return false unless source.to_s.match?(ACTIVE_JOB_SOURCE)
|
|
72
|
+
return true if Auditea.configuration.capture_active_job
|
|
73
|
+
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def mechanism_for(source)
|
|
78
|
+
value = source.to_s.strip
|
|
79
|
+
value.empty? ? "rails.error_reporter" : value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def map_severity(severity, handled)
|
|
83
|
+
case severity.to_s
|
|
84
|
+
when "warning" then "warning"
|
|
85
|
+
when "info" then "info"
|
|
86
|
+
when "error" then "error"
|
|
87
|
+
else
|
|
88
|
+
handled ? "warning" : "error"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|