camada 0.1.1 → 0.1.2
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/README.md +8 -8
- data/lib/camada/beacon_js.rb +3 -3
- data/lib/camada/env.rb +1 -2
- data/lib/camada/rack.rb +5 -4
- data/lib/camada/snapshot/client.rb +12 -2
- data/lib/camada/snapshot/match.rb +12 -36
- data/lib/camada/snapshot/parse.rb +132 -11
- data/lib/camada/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 450a1a841ac03fb9490c0a6c1c3e45d62b0eec927795bb3fc3b2064f89432349
|
|
4
|
+
data.tar.gz: 8cd0ffee6f1686a7ad8150094898f3faaaff7f7b797306b5bb2af936a05ccc84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27a5225cff7c94d36f2d5d1d85e91a4304bc71d7edff69c5706ffb9cba903132e67723e650f991a1445abd96f402ac3b2fa920240bc6184e82441870dc0a94cb
|
|
7
|
+
data.tar.gz: 677d632f45dab7351fd8982c396335a9ab02b8bfc1f6598301b4b1c334d520922d6b11e543353e314b8c5aead17b0b66ae4aefbaac6e5ccec0a9e773aa066f58
|
data/README.md
CHANGED
|
@@ -8,7 +8,7 @@ a Railtie that mounts it for Rails — the `sentry-ruby` model. Fails open by de
|
|
|
8
8
|
outage or bug never 5xxes your app.
|
|
9
9
|
|
|
10
10
|
Not yet on RubyGems — install it from a sibling checkout: `gem "camada", path: "../camada-ruby"`
|
|
11
|
-
in your Gemfile (as [`camada-ruby-example`](
|
|
11
|
+
in your Gemfile (as [`camada-ruby-example`](https://github.com/camada-app/camada-ruby-example) does); publishing is one
|
|
12
12
|
decision with the npm packages (SDK-G01). Ruby 3.1 or newer, no runtime dependencies (stdlib
|
|
13
13
|
only: `net/http`, `openssl`, `digest`, `json`, `securerandom`, `zlib`, `stringio`).
|
|
14
14
|
|
|
@@ -23,7 +23,7 @@ run App
|
|
|
23
23
|
# Rails — nothing to add: the Railtie inserts Camada::Rack at the top of the middleware stack
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Env (printed
|
|
26
|
+
Env (the key is printed once when you create a project in the app):
|
|
27
27
|
|
|
28
28
|
```
|
|
29
29
|
CAMADA_KEY=<ingest_token>.<snap_token>
|
|
@@ -83,11 +83,11 @@ use Camada::Rack, engine
|
|
|
83
83
|
5. Serves the beacon: `GET /_cam/b.js` (the `@camada/browser` build, vendored) and `POST /_cam/fp`
|
|
84
84
|
(≤ 32 KB, relayed onto the event batch as a `sig: 1` row with the ip camada resolved). Both
|
|
85
85
|
fall through to your app when the tenant switched the beacon off.
|
|
86
|
-
6. Runs your app with `x-rid`
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
`route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
|
|
86
|
+
6. Runs your app with `x-rid` (the rid of the request's event row; never on a 101 handshake) and
|
|
87
|
+
the `_sfp` session cookie on its response, and when the server closes the response body ships
|
|
88
|
+
one redacted event: method, host, path, scrubbed query, status, latency, header
|
|
89
|
+
names/sizes/order, the auth scheme (never the credential), cookie count (never values), the
|
|
90
|
+
matched route pattern when the framework names it (`sinatra.route`, Rails' `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.
|
|
91
91
|
|
|
92
92
|
## Options
|
|
93
93
|
|
|
@@ -194,6 +194,6 @@ vendored beacon to `camada-browser/dist/auto.global.js` (`npm run build` there f
|
|
|
194
194
|
`ruby scripts/sync_beacon.rb` after a beacon release). Both fail by name when the checkout is
|
|
195
195
|
missing rather than skipping (`CAMADA_FIXTURES_DIR`, `CAMADA_BROWSER_DIST` override the paths).
|
|
196
196
|
|
|
197
|
-
[`camada-ruby-example`](
|
|
197
|
+
[`camada-ruby-example`](https://github.com/camada-app/camada-ruby-example) is the hand-test bench (Sinatra under Puma on
|
|
198
198
|
:3004), and `node scripts/e2e-sdk-ruby.mjs` in `camada/edge-analyst` drives it against a seeded
|
|
199
199
|
local analyst over real HTTP, cold first request included.
|
data/lib/camada/beacon_js.rb
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# dist/auto.global.js) vendored as a String literal: served at /_cam/b.js with no runtime file
|
|
5
5
|
# read. spec/beacon_js_spec.rb pins it to the sibling build byte for byte.
|
|
6
6
|
module Camada
|
|
7
|
-
BEACON_VERSION = "0.
|
|
8
|
-
BEACON_SHA256 = "
|
|
9
|
-
BEACON_JS = "\"use strict\";(()=>{var k=\"0.
|
|
7
|
+
BEACON_VERSION = "0.3.0"
|
|
8
|
+
BEACON_SHA256 = "0abf522f9d0004ae798ba22fec74e935f545f787847574c4800662d00d522e95"
|
|
9
|
+
BEACON_JS = "\"use strict\";(()=>{var k=\"0.3.0\";var _=\"@camada/browser\";var b=`${_}/${k}`;function n(t){try{return t()}catch{return null}}function x(t){let e=2166136261;for(let i=0;i<t.length;i++)e^=t.charCodeAt(i),e=Math.imul(e,16777619);return(e>>>0).toString(16)}function M(){let t=document.createElement(\"canvas\");t.width=240,t.height=60;let e=t.getContext(\"2d\");return e?(e.textBaseline=\"alphabetic\",e.fillStyle=\"#f60\",e.fillRect(120,5,60,20),e.fillStyle=\"#069\",e.font=\"15px Arial\",e.fillText(\"Sfp\\u2603 1.0\",2,15),e.fillStyle=\"rgba(102,204,0,0.7)\",e.font=\"17px Times\",e.fillText(\"Sfp\\u2603 1.0\",4,45),x(t.toDataURL())):null}function R(){let t=document.createElement(\"canvas\"),e=t.getContext(\"webgl\")||t.getContext(\"experimental-webgl\");if(!e)return null;let i=e.getExtension(\"WEBGL_debug_renderer_info\");return{v:i?e.getParameter(i.UNMASKED_VENDOR_WEBGL):e.getParameter(e.VENDOR),r:i?e.getParameter(i.UNMASKED_RENDERER_WEBGL):e.getParameter(e.RENDERER)}}function S(){return x([Math.tan(-1e300),Math.sinh(1),Math.expm1(1),Math.cbrt(100),Math.log1p(10),Math.atanh(.5)].join(\"|\"))}var N=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom/,L=/^\\$?cdc_|^__webdriver|^__selenium|^__nightmare|^__playwright|^callPhantom|^_phantom/;function j(){let t=document,e=navigator,i=window,o=0;for(let s in t)N.test(s)&&o++;for(let s in i)L.test(s)&&o++;return{wd:e.webdriver===!0?1:0,cdc:o,chrome:typeof i.chrome<\"u\"?1:0,plugins:e.plugins?e.plugins.length:-1,langs:e.languages?e.languages.length:-1,outer:window.outerWidth===0&&window.outerHeight===0?1:0,perm:n(()=>Notification.permission),pdf:typeof e.pdfViewerEnabled==\"boolean\"?e.pdfViewerEnabled?1:0:-1,err:n(()=>{try{null.x}catch(s){let c=s.stack;return c?c.split(`\n`).length:0}})}}function T({endpoint:t,rid:e}){let i=e||null,o=performance.now(),s=0,c=0,m=0,h=0,f=0,u=null;function l(){u===null&&(u=Math.round(performance.now()-o))}addEventListener(\"mousemove\",()=>{s++,l()},{passive:!0}),addEventListener(\"scroll\",()=>{c++,l()},{passive:!0}),addEventListener(\"keydown\",()=>{m++,l()},{passive:!0}),addEventListener(\"touchstart\",()=>{h++,l()},{passive:!0}),addEventListener(\"click\",()=>{f++,l()},{passive:!0});function w(){let r=navigator,a=n(()=>performance.getEntriesByType(\"navigation\")[0]),D=n(()=>{let p=performance.getEntriesByType(\"paint\");return p.length?Math.round(p[p.length-1].startTime):null}),d=n(()=>r.connection)||{};return{sdk:b,rid:i,ts:Date.now(),scr:n(()=>[screen.width,screen.height,screen.availWidth,screen.availHeight,screen.colorDepth]),win:n(()=>[window.innerWidth,window.innerHeight,window.outerWidth,window.outerHeight]),dpr:n(()=>window.devicePixelRatio),tz:n(()=>Intl.DateTimeFormat().resolvedOptions().timeZone),tzo:n(()=>new Date().getTimezoneOffset()),lang:n(()=>r.language),langs:n(()=>r.languages?Array.prototype.slice.call(r.languages,0,5):null),plat:n(()=>r.platform),cores:n(()=>r.hardwareConcurrency),mem:n(()=>r.deviceMemory),touch:n(()=>r.maxTouchPoints),ua:n(()=>r.userAgent),dnt:n(()=>r.doNotTrack),cookies:n(()=>r.cookieEnabled?1:0),conn:n(()=>[d.effectiveType,d.rtt,d.downlink,d.saveData?1:0]),gl:n(R),cv:n(M),mf:n(S),auto:n(j),vis:n(()=>document.visibilityState),focus:n(()=>document.hasFocus()?1:0),timing:a?[Math.round(a.domContentLoadedEventEnd),Math.round(a.loadEventEnd),Math.round(a.responseEnd-a.requestStart)]:null,paint:D,input:{moves:s,scrolls:c,keys:m,touches:h,clicks:f,first:u,at:Math.round(performance.now()-o)}}}function v(r){let a=JSON.stringify(r?Object.assign(w(),{hi:r}):w());navigator.sendBeacon&&navigator.sendBeacon(t,new Blob([a],{type:\"application/json\"}))||fetch(t,{method:\"POST\",body:a,keepalive:!0,headers:{\"content-type\":\"application/json\"}}).catch(()=>{})}let g=navigator.userAgentData,E=g&&g.getHighEntropyValues?g.getHighEntropyValues([\"architecture\",\"bitness\",\"model\",\"platformVersion\",\"fullVersionList\"]).catch(()=>null):Promise.resolve(null);setTimeout(()=>{E.then(v)},2e3),addEventListener(\"pagehide\",()=>{E.then(r=>{v(r)})},{once:!0})}function O(t){let e=new URL(t,\"http://_\"),i=e.pathname.split(\"/\");return i[i.length-1]=\"fp\",{endpoint:i.join(\"/\"),rid:e.searchParams.get(\"r\")}}(function(){try{let t=document.currentScript,e=t&&t.src;if(!e)return;let{endpoint:i,rid:o}=O(e);T({endpoint:i,rid:o})}catch{}})();})();\n"
|
|
10
10
|
end
|
data/lib/camada/env.rb
CHANGED
|
@@ -21,8 +21,7 @@ module Camada
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
class Env
|
|
24
|
-
#
|
|
25
|
-
# production ingest domain before any RubyGems publish.
|
|
24
|
+
# Production ingest; CAMADA_INGEST_URL overrides it.
|
|
26
25
|
DEFAULT_INGEST_URL = "https://in.camada.app"
|
|
27
26
|
|
|
28
27
|
# nil (SDK stays inert, one log line) rather than raising on bad config. `env` is anything
|
data/lib/camada/rack.rb
CHANGED
|
@@ -165,7 +165,7 @@ module Camada
|
|
|
165
165
|
passed.on_finish&.call(500)
|
|
166
166
|
raise
|
|
167
167
|
end
|
|
168
|
-
headers = stamp(headers, passed)
|
|
168
|
+
headers = stamp(status, headers, passed)
|
|
169
169
|
finish = passed.on_finish
|
|
170
170
|
return [status, headers, body] if finish.nil?
|
|
171
171
|
|
|
@@ -176,13 +176,14 @@ module Camada
|
|
|
176
176
|
[status, headers, proxy]
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
-
# x-rid and the session cookie on the app's response
|
|
179
|
+
# x-rid and the session cookie on the app's response; no x-rid on a 101 (a partial-hijack
|
|
180
|
+
# websocket handshake), whose rid rides the event alone. Rack 3 spells header names in lower
|
|
180
181
|
# case and carries several set-cookie values as an Array; Rack 2 joins them with "\n".
|
|
181
|
-
def stamp(headers, passed)
|
|
182
|
+
def stamp(status, headers, passed)
|
|
182
183
|
return headers if passed.rid.nil? && passed.set_cookie.nil?
|
|
183
184
|
|
|
184
185
|
headers = headers.to_h
|
|
185
|
-
headers["x-rid"] = passed.rid if passed.rid
|
|
186
|
+
headers["x-rid"] = passed.rid if passed.rid && status.to_i != 101
|
|
186
187
|
if passed.set_cookie
|
|
187
188
|
key = headers.keys.find { |k| k.to_s.downcase == "set-cookie" } || "set-cookie"
|
|
188
189
|
headers[key] = join_cookies(headers[key], passed.set_cookie)
|
|
@@ -15,7 +15,7 @@ module Camada
|
|
|
15
15
|
# 200 [u32 LE meta-length][meta JSON][BLK container] + etag + x-camada-config
|
|
16
16
|
# 304 nothing changed; config header repeated (config refreshes every poll for free)
|
|
17
17
|
# 204 authenticated, no snapshot published -> enforce nothing, fail open
|
|
18
|
-
# Semantics ported exactly: single-in-flight load; loaded_at stamped even on 204 (retry per
|
|
18
|
+
# Semantics ported exactly: single-in-flight load; loaded_at stamped last, even on 204 (retry per
|
|
19
19
|
# poll cadence, not per request); any error keeps the previous snapshot; cold = fail open.
|
|
20
20
|
# Timers are threads here: timer mode runs one thread per client sleeping on a condition
|
|
21
21
|
# variable; lazy mode kicks a one-shot thread from ensure_fresh so the request path never
|
|
@@ -153,7 +153,17 @@ module Camada
|
|
|
153
153
|
res = @transport.call(HttpRequest.new(method: "GET", url: @url, headers: headers, body: nil, timeout_s: @timeout_s))
|
|
154
154
|
return unless [200, 204, 304].include?(res.status) # 401/5xx/network: keep what we have
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
# loaded_at is stamped last (even when the body turns out corrupt): "not cold" is what Puma's
|
|
157
|
+
# request threads read as "rules in place", so it must not be visible before the matcher and
|
|
158
|
+
# config are. It is assigned after both, so a thread that sees it sees them.
|
|
159
|
+
begin
|
|
160
|
+
publish(res)
|
|
161
|
+
ensure
|
|
162
|
+
@loaded_at = monotonic
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def publish(res)
|
|
157
167
|
read_config(res.headers["x-camada-config"])
|
|
158
168
|
return if res.status == 304
|
|
159
169
|
|
|
@@ -43,23 +43,6 @@ module Camada
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
def self.clean_path(raw)
|
|
47
|
-
p = raw.nil? || raw.empty? ? "/" : raw
|
|
48
|
-
q = p.index("?")
|
|
49
|
-
q.nil? ? p : p[0, q]
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Walks every '/'-terminated ancestor of `path`, the way the block side does.
|
|
53
|
-
def self.prefix_hit?(prefixes, path)
|
|
54
|
-
i = path.index("/", 1)
|
|
55
|
-
until i.nil?
|
|
56
|
-
return true if prefixes.include?(path[0, i + 1])
|
|
57
|
-
|
|
58
|
-
i = path.index("/", i + 1)
|
|
59
|
-
end
|
|
60
|
-
false
|
|
61
|
-
end
|
|
62
|
-
|
|
63
46
|
# A rule decided this request (§D3): at most one of allowed / block / challenge / warn is
|
|
64
47
|
# true, `reason` is 'rule', and `rule` names the id the adapters stamp on the event.
|
|
65
48
|
def self.rule_result(rule, version)
|
|
@@ -89,20 +72,21 @@ module Camada
|
|
|
89
72
|
n4 = IpParse.parse_ip4(ip)
|
|
90
73
|
end
|
|
91
74
|
end
|
|
75
|
+
forms = Snapshot.path_forms(i.path) # [raw, lit, full]: contracts §D3 "Path matching"
|
|
92
76
|
unless s.rules.empty?
|
|
93
77
|
r = RuleRequest.new(n4: n4, ip6: w, asn: i.asn, country: i.country, tlsx: i.tlsx,
|
|
94
|
-
|
|
78
|
+
paths: forms, ua: i.ua, header: i.header)
|
|
95
79
|
s.rules.each do |rule| # the order IS the precedence (§A4): first match wins
|
|
96
80
|
return Snapshot.rule_result(rule, s.version) if rule.conds.all? { |cond| cond.call(r) }
|
|
97
81
|
end
|
|
98
82
|
end
|
|
99
|
-
reason = side(s.allow, i, n4, w)
|
|
83
|
+
reason = side(s.allow, i, n4, w, forms, false) # an exemption: every canonical spelling must agree
|
|
100
84
|
return MatchResult.new(allowed: true, reason: reason, version: s.version) if reason
|
|
101
85
|
|
|
102
|
-
reason = block_side(i, n4, w)
|
|
86
|
+
reason = block_side(i, n4, w, forms)
|
|
103
87
|
return MatchResult.new(block: true, reason: reason, version: s.version) if reason
|
|
104
88
|
|
|
105
|
-
reason = side(s.challenge, i, n4, w)
|
|
89
|
+
reason = side(s.challenge, i, n4, w, forms, true)
|
|
106
90
|
return MatchResult.new(challenge: true, reason: reason, version: s.version) if reason
|
|
107
91
|
|
|
108
92
|
MatchResult.new(version: s.version)
|
|
@@ -178,23 +162,16 @@ module Camada
|
|
|
178
162
|
false
|
|
179
163
|
end
|
|
180
164
|
|
|
181
|
-
def blocked_path?(path)
|
|
182
|
-
s = @snap
|
|
183
|
-
return true if s.paths_exact.include?(path)
|
|
184
|
-
return true if !s.paths_prefix.empty? && Snapshot.prefix_hit?(s.paths_prefix, path)
|
|
185
|
-
|
|
186
|
-
s.paths_regex.any? { |rx| Snapshot.regex_hit?(rx, path) }
|
|
187
|
-
end
|
|
188
|
-
|
|
189
165
|
# The block side: v3 sections plus the top-level meta.
|
|
190
|
-
def block_side(i, n4, w)
|
|
166
|
+
def block_side(i, n4, w, forms)
|
|
191
167
|
s = @snap
|
|
192
168
|
return "ip4" if n4 >= 0 && blocked4?(n4)
|
|
193
169
|
return "ip6" if !w.nil? && blocked6?(w)
|
|
194
170
|
return "asn" if !i.asn.nil? && blocked_asn?(i.asn)
|
|
195
171
|
return "country" if Camada.present(i.country) && !s.country.empty? && s.country.include?(i.country)
|
|
196
172
|
return "tls" if Camada.present(i.tlsx) && s.tls.include?(i.tlsx)
|
|
197
|
-
if (!s.paths_exact.empty? || !s.paths_prefix.empty? || !s.paths_regex.empty?) &&
|
|
173
|
+
if (!s.paths_exact.empty? || !s.paths_prefix.empty? || !s.paths_regex.empty?) &&
|
|
174
|
+
Snapshot.path_hit?(forms, true) { |p| Snapshot.path_in?(s, p, s.paths_regex) }
|
|
198
175
|
return "path"
|
|
199
176
|
end
|
|
200
177
|
|
|
@@ -202,18 +179,17 @@ module Camada
|
|
|
202
179
|
end
|
|
203
180
|
|
|
204
181
|
# A v4 side list (allow or challenge). No tls axis: §A3's side meta has no tls key.
|
|
205
|
-
def side(st, i, n4, w)
|
|
182
|
+
def side(st, i, n4, w, forms, deny)
|
|
206
183
|
return nil if st.empty # the common v3 snapshot
|
|
207
184
|
return "ip4" if n4 >= 0 && Snapshot.in_range4?(st.r4, n4)
|
|
208
185
|
return "ip6" if !w.nil? && Snapshot.in_range6?(st.r6, st.n6, w)
|
|
209
186
|
return "asn" if !i.asn.nil? && st.asn.include?(i.asn)
|
|
210
187
|
return "country" if Camada.present(i.country) && st.country.include?(i.country)
|
|
211
188
|
|
|
212
|
-
if !st.paths_exact.empty? || !st.paths_prefix.empty?
|
|
213
|
-
|
|
214
|
-
return "path" if st.paths_exact.include?(p)
|
|
215
|
-
return "path" if !st.paths_prefix.empty? && Snapshot.prefix_hit?(st.paths_prefix, p)
|
|
189
|
+
if (!st.paths_exact.empty? || !st.paths_prefix.empty?) && Snapshot.path_hit?(forms, deny) { |p| Snapshot.path_in?(st, p) }
|
|
190
|
+
return "path"
|
|
216
191
|
end
|
|
192
|
+
|
|
217
193
|
nil
|
|
218
194
|
end
|
|
219
195
|
end
|
|
@@ -67,7 +67,8 @@ module Camada
|
|
|
67
67
|
|
|
68
68
|
# The request a compiled condition reads. `ip6` is the parsed address words, or nil; `header`
|
|
69
69
|
# is called with an already lower-cased name, absent where the tap cannot read headers.
|
|
70
|
-
|
|
70
|
+
# `paths` is the request path's [raw, lit, full] forms (Snapshot.path_forms).
|
|
71
|
+
RuleRequest = Struct.new(:n4, :ip6, :asn, :country, :tlsx, :paths, :ua, :header, keyword_init: true)
|
|
71
72
|
|
|
72
73
|
CompiledRule = Struct.new(:id, :action, :conds)
|
|
73
74
|
|
|
@@ -82,8 +83,8 @@ module Camada
|
|
|
82
83
|
m ||= {}
|
|
83
84
|
asn = (m["asn"] || []).to_set(&:to_i)
|
|
84
85
|
country = (m["country"] || []).to_set
|
|
85
|
-
exact = (m["pathsExact"] || []).to_set
|
|
86
|
-
prefix = (m["pathsPrefix"] || []).to_set
|
|
86
|
+
exact = (m["pathsExact"] || []).to_set { |p| canon_path(p.to_s) }
|
|
87
|
+
prefix = (m["pathsPrefix"] || []).to_set { |p| dir_key(p.to_s) }
|
|
87
88
|
empty = r4.empty? && r6.empty? && asn.empty? && country.empty? && exact.empty? && prefix.empty?
|
|
88
89
|
RangeSet.new(r4: r4, r6: r6, n6: r6.length >> 3, asn: asn, country: country, paths_exact: exact, paths_prefix: prefix, empty: empty)
|
|
89
90
|
end
|
|
@@ -137,8 +138,8 @@ module Camada
|
|
|
137
138
|
# authored as JS regexes (the analyst validates them with `new RegExp`), so the JS spellings
|
|
138
139
|
# Onigmo reads differently are translated first — see js_to_onigmo; \d \w \b are ASCII in
|
|
139
140
|
# Ruby by default, as JS reads them.
|
|
140
|
-
def self.compile_regex(pattern)
|
|
141
|
-
Regexp.new(js_to_onigmo(pattern))
|
|
141
|
+
def self.compile_regex(pattern, options = 0)
|
|
142
|
+
Regexp.new(js_to_onigmo(pattern), options)
|
|
142
143
|
rescue RegexpError, TypeError, ArgumentError, EncodingError
|
|
143
144
|
nil
|
|
144
145
|
end
|
|
@@ -207,6 +208,122 @@ module Camada
|
|
|
207
208
|
false
|
|
208
209
|
end
|
|
209
210
|
|
|
211
|
+
# ---------- paths (contracts §D3 "Path matching") ----------
|
|
212
|
+
# A port of edge-analyst src/blocklist.js canonPath / pathForms / pathHit / pathPred. Both sides
|
|
213
|
+
# of a comparison are canonicalised so a rule catches every spelling a framework routes to the
|
|
214
|
+
# same handler: query cut at ? or #; per UTF-8 byte, %XX decoded when printable ASCII other
|
|
215
|
+
# than / and % (%2F stays %2f, one pass), every other byte written as lower-case %xx; ASCII
|
|
216
|
+
# lower-cased; each segment cut at its first ;; empty segments dropped; . and .. resolved
|
|
217
|
+
# (`full`; `lit` skips that step). A deny matches on raw, lit or full; an exemption (allow
|
|
218
|
+
# side, skip rule) needs lit AND full. Works on bytes, so a binary PATH_INFO never raises.
|
|
219
|
+
PATH_HEX = "0123456789abcdef"
|
|
220
|
+
CANON_PATH = %r{\A(?:/(?!\.\.?(?:/|\z))[a-z0-9\-._~!$&'()*+,=:@]+)+\z} # already canonical: skips the byte walk
|
|
221
|
+
|
|
222
|
+
def self.hexv(c)
|
|
223
|
+
if c.between?(48, 57) then c - 48
|
|
224
|
+
elsif c.between?(97, 102) then c - 87
|
|
225
|
+
elsif c.between?(65, 70) then c - 55
|
|
226
|
+
else -1
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def self.strip_query(raw)
|
|
231
|
+
p = raw.nil? || raw.empty? ? "/" : raw
|
|
232
|
+
q = p.b.index(/[?#]/)
|
|
233
|
+
q.nil? ? p : p.byteslice(0, q)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.canonical?(p) = p == "/" || (p.ascii_only? && CANON_PATH.match?(p))
|
|
237
|
+
|
|
238
|
+
def self.canon_path(raw, dots: true)
|
|
239
|
+
p = strip_query(raw)
|
|
240
|
+
return p if canonical?(p)
|
|
241
|
+
|
|
242
|
+
b = p.bytes
|
|
243
|
+
s = +""
|
|
244
|
+
i = 0
|
|
245
|
+
while i < b.length
|
|
246
|
+
c = b[i]
|
|
247
|
+
decoded = c == 37 && i + 2 < b.length && hexv(b[i + 1]) >= 0 && hexv(b[i + 2]) >= 0
|
|
248
|
+
if decoded
|
|
249
|
+
c = (hexv(b[i + 1]) * 16) + hexv(b[i + 2])
|
|
250
|
+
i += 2
|
|
251
|
+
end
|
|
252
|
+
if decoded && c == 47 then s << "%2f" # a decoded %2F is never a separator
|
|
253
|
+
elsif c < 0x21 || c > 0x7e || c == 37 then s << "%" << PATH_HEX[c >> 4] << PATH_HEX[c & 15]
|
|
254
|
+
else s << (c.between?(65, 90) ? c + 32 : c).chr
|
|
255
|
+
end
|
|
256
|
+
i += 1
|
|
257
|
+
end
|
|
258
|
+
out = []
|
|
259
|
+
s.split("/").each do |seg|
|
|
260
|
+
k = seg.index(";")
|
|
261
|
+
seg = seg[0, k] unless k.nil?
|
|
262
|
+
next if seg.empty? || (dots && seg == ".")
|
|
263
|
+
|
|
264
|
+
if dots && seg == ".."
|
|
265
|
+
out.pop
|
|
266
|
+
next
|
|
267
|
+
end
|
|
268
|
+
out << seg
|
|
269
|
+
end
|
|
270
|
+
"/#{out.join("/")}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# [raw (query cut), lit, full] for one request path.
|
|
274
|
+
def self.path_forms(raw)
|
|
275
|
+
p = strip_query(raw)
|
|
276
|
+
return [p, p, p] if canonical?(p)
|
|
277
|
+
|
|
278
|
+
[p, canon_path(p, dots: false), canon_path(p)]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.path_dir(p) = p.end_with?("/") ? p : "#{p}/"
|
|
282
|
+
|
|
283
|
+
# A prefix entry or a starts_with value ending in / -> its canonical directory key ('/' stays '/').
|
|
284
|
+
def self.dir_key(v) = path_dir(canon_path(v))
|
|
285
|
+
|
|
286
|
+
# Walks '/' boundaries: /a/b tries /, /a/, /a/b/.
|
|
287
|
+
def self.prefix_hit?(prefixes, path)
|
|
288
|
+
d = path_dir(path).b
|
|
289
|
+
i = 0
|
|
290
|
+
until i.nil?
|
|
291
|
+
return true if prefixes.include?(d.byteslice(0, i + 1))
|
|
292
|
+
|
|
293
|
+
i = d.index("/", i + 1)
|
|
294
|
+
end
|
|
295
|
+
false
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# One path form against a side's (or the block side's) exact, prefix and regex entries.
|
|
299
|
+
def self.path_in?(st, path, regexes = [])
|
|
300
|
+
return true if st.paths_exact.include?(path)
|
|
301
|
+
return true if !st.paths_prefix.empty? && prefix_hit?(st.paths_prefix, path)
|
|
302
|
+
|
|
303
|
+
regexes.any? { |rx| regex_hit?(rx, path) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Does the block hold for this request's path? deny = any spelling; an exemption = both canonical forms.
|
|
307
|
+
def self.path_hit?(forms, deny, &pred)
|
|
308
|
+
deny ? (pred.call(forms[0]) || pred.call(forms[1]) || pred.call(forms[2])) : (pred.call(forms[1]) && pred.call(forms[2]))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# One path condition -> a predicate over a single path form.
|
|
312
|
+
def self.path_pred(op, values)
|
|
313
|
+
case op
|
|
314
|
+
when "matches"
|
|
315
|
+
rx = compile_regex(values[0], Regexp::IGNORECASE)
|
|
316
|
+
->(p) { !rx.nil? && regex_hit?(rx, p) }
|
|
317
|
+
when "starts_with"
|
|
318
|
+
v = values[0]
|
|
319
|
+
key = v.end_with?("/") ? dir_key(v) : canon_path(v)
|
|
320
|
+
->(p) { text_hit? { path_dir(p).start_with?(key) } }
|
|
321
|
+
else
|
|
322
|
+
set = values.to_set { |v| canon_path(v) }
|
|
323
|
+
->(p) { set.include?(p) }
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
210
327
|
# ---------- custom rules (v5) ----------
|
|
211
328
|
|
|
212
329
|
# The string one condition reads, or nil when this request cannot answer the field.
|
|
@@ -216,7 +333,6 @@ module Camada
|
|
|
216
333
|
when "asn" then r.asn&.to_s
|
|
217
334
|
when "country" then Camada.present(r.country)
|
|
218
335
|
when "tlsx" then Camada.present(r.tlsx)
|
|
219
|
-
when "path" then r.path
|
|
220
336
|
when "ua" then Camada.present(r.ua)
|
|
221
337
|
end
|
|
222
338
|
# an entity-plane field (bot.verified, rule) answers nil: never true here
|
|
@@ -224,9 +340,14 @@ module Camada
|
|
|
224
340
|
|
|
225
341
|
# One condition -> a predicate. `sets` yields this rule's (v4, v6) section pair per ip
|
|
226
342
|
# condition, in condition order, so an ip condition consumes the next one.
|
|
227
|
-
def self.compile_cond(c, sets)
|
|
343
|
+
def self.compile_cond(c, sets, deny = true)
|
|
228
344
|
f = c["f"].to_s
|
|
229
345
|
op = c["op"].to_s
|
|
346
|
+
if f == "path" # every path op reads the canonical forms (see "paths" above)
|
|
347
|
+
raw = c["v"]
|
|
348
|
+
pred = path_pred(op, raw.is_a?(Array) ? raw.map(&:to_s) : [raw.to_s])
|
|
349
|
+
return ->(r) { path_hit?(r.paths, deny, &pred) }
|
|
350
|
+
end
|
|
230
351
|
negate = op == "is_not" || op == "not_in"
|
|
231
352
|
# A header condition reads the request through the caller's getter. The name is lower-cased
|
|
232
353
|
# once, here; a tap that cannot read headers (no getter) and a header the request does not
|
|
@@ -306,7 +427,7 @@ module Camada
|
|
|
306
427
|
[k < v4.length ? v4[k].slice(1, v4[k].length - 1) : EMPTY, k < v6.length ? v6[k].slice(1, v6[k].length - 1) : EMPTY]
|
|
307
428
|
end
|
|
308
429
|
conds = begin
|
|
309
|
-
(r["conds"] || []).map { |c| compile_cond(c, sets) }
|
|
430
|
+
(r["conds"] || []).map { |c| compile_cond(c, sets, action != "skip") }
|
|
310
431
|
rescue StandardError
|
|
311
432
|
next # a malformed rule is dropped, never enforced
|
|
312
433
|
end
|
|
@@ -351,9 +472,9 @@ module Camada
|
|
|
351
472
|
asn_bm: sec[8] || Zeros.new(131_072), asn_extra: sec.fetch(9, EMPTY),
|
|
352
473
|
country: (meta["country"] || []).to_set,
|
|
353
474
|
tls: (meta["tls"] || []).to_set,
|
|
354
|
-
paths_exact: (meta["pathsExact"] || []).to_set,
|
|
355
|
-
paths_prefix: (meta["pathsPrefix"] || []).to_set,
|
|
356
|
-
paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p) },
|
|
475
|
+
paths_exact: (meta["pathsExact"] || []).to_set { |p| canon_path(p.to_s) },
|
|
476
|
+
paths_prefix: (meta["pathsPrefix"] || []).to_set { |p| dir_key(p.to_s) },
|
|
477
|
+
paths_regex: (meta["pathsRegex"] || []).filter_map { |p| compile_regex(p, Regexp::IGNORECASE) },
|
|
357
478
|
allow: range_set(sec.fetch(10, EMPTY), sec.fetch(11, EMPTY), meta["allow"]),
|
|
358
479
|
challenge: range_set(sec.fetch(12, EMPTY), sec.fetch(13, EMPTY), meta["challenge"]),
|
|
359
480
|
rules: compile_rules(meta, rule4, rule6)
|
data/lib/camada/version.rb
CHANGED
|
@@ -5,6 +5,6 @@ module Camada
|
|
|
5
5
|
# source: camada.gemspec reads it at build, and the sibling drift guards (camada-backend,
|
|
6
6
|
# camada-web, camada-mkt) parse this file with /^\s*VERSION = "([^"]+)"/m the way they parse
|
|
7
7
|
# a package.json. Plain X.Y.Z only: the analyst's SDK_RE drops anything else.
|
|
8
|
-
VERSION = "0.1.
|
|
8
|
+
VERSION = "0.1.2"
|
|
9
9
|
SDK_ID = "@camada/ruby/#{VERSION}".freeze
|
|
10
10
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: camada
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- camada
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'camada SDK for Ruby: inline enforcement of your snapshot (ordered custom
|
|
14
14
|
rules, then allow, block, challenge), first-party beacon and proof-of-work challenge,
|