@deployowl/guard 2.2.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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @deployowl/guard
2
+
3
+ Enterprise-grade, platform-agnostic bot detection and security middleware.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ npm install @deployowl/guard
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```js
14
+ import { OwlGuard } from "@deployowl/guard";
15
+
16
+ const owlguard = new OwlGuard({
17
+ apiKey: "owl_live_xxx", // Optional: enables enrichment signals
18
+ blockScore: 80, // Bot score threshold for 403 block
19
+ challengeScore: 50, // Bot score threshold for JS challenge
20
+ });
21
+
22
+ const result = await owlguard.decision(request);
23
+
24
+ if (result.blocked) {
25
+ return new Response(result.reason, { status: result.status });
26
+ }
27
+ // Continue to your handler
28
+ ```
29
+
30
+ ## Configuration Options
31
+
32
+ | Option | Type | Default | Description |
33
+ |---|---|---|---|
34
+ | `apiKey` | `string` | — | OwlGuard install key. Enables enrichment signals. |
35
+ | `blockScore` | `number` | `80` | Bot score threshold for hard 403 block. |
36
+ | `challengeScore` | `number` | `50` | Bot score threshold for JS proof-of-work challenge. |
37
+ | `rateLimit` | `object` | `{windowMs: 60000, max: 120}` | Per-IP rate limiting. |
38
+ | `bypassPaths` | `string[]` | `[]` | Path prefixes that skip all checks. |
39
+ | `allowlist` | `object` | — | IP/apiKey allowlist. |
40
+ | `denylist` | `object` | — | IP/ASN denylist. |
41
+ | `geo` | `object` | — | Country-level geo fencing. |
42
+ | `validation` | `object` | — | Body size, methods, content-types. |
43
+ | `challenge` | `object` | `{difficulty: "000"}` | SHA-256 proof-of-work config. |
44
+ | `log` | `string` | `"warn"` | Verbosity: `none`/`block`/`warn`/`info`. |
45
+ | `reporting` | `boolean` | `false` | **Opt-in**: send blocked-decision events to reporting worker. |
46
+ | `reportingSecret` | `string` | — | **Required when `reporting: true`**. Obtain from dashboard. |
47
+
48
+ ## What Data Is Sent
49
+
50
+ ### Enrichment — to `enrich.deployowl.com/enrich` (when `apiKey` is set)
51
+
52
+ - End-user IP address (for threat scoring)
53
+ - Request headers (when running inside an edge worker)
54
+ - **Authentication:** apiKey only
55
+ - **Purpose:** Obtain threatScore, country, ASN, Tor/VPN flags
56
+
57
+ ### Decision Reporting — to `security.deployowl.com` (only when `reporting: true`)
58
+
59
+ - IP, country, pathname, method, block reason, score
60
+ - **Authentication:** `reportingSecret` (config-provided)
61
+ - **Purpose:** Analytics, billing, dashboard visibility
62
+
63
+ ### Remote Config — to `api.deployowl.com/api/v1/config` (when `apiKey` is set)
64
+
65
+ - API key sent via `X-API-Key` header only (not URL)
66
+ - **Purpose:** Fetch WAF rules, rate limits, custom routes
67
+
68
+ ## Privacy & Security
69
+
70
+ - **No environment variables collected.**
71
+ - **No dependency manifests collected.**
72
+ - Reporting is **opt-in** and requires a user-provided secret.
73
+ - API keys are sent via headers, never URL query strings.
74
+
75
+ ## License
76
+
77
+ Proprietary © DeployOwl. All rights reserved.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * owlguard — adapters/cloudflare-kv.js
3
+ * Pre-built storage adapter for Cloudflare KV namespaces.
4
+ *
5
+ * Usage (Cloudflare Worker):
6
+ * import { CloudflareKVAdapter } from "owlguard/adapters/cloudflare-kv";
7
+ *
8
+ * // In your wrangler.toml, bind a KV namespace:
9
+ * // [[kv_namespaces]]
10
+ * // binding = "OWL_RATE_LIMIT"
11
+ * // id = "your-kv-namespace-id"
12
+ *
13
+ * const owl = new OwlGuard({
14
+ * storage: new CloudflareKVAdapter(env.OWL_RATE_LIMIT),
15
+ * });
16
+ *
17
+ * This gives you globally-consistent rate limiting across all Cloudflare
18
+ * Worker isolates — every edge location shares the same KV counter.
19
+ */
20
+
21
+ export class CloudflareKVAdapter {
22
+ /**
23
+ * @param {object} namespace — A Cloudflare KV namespace binding.
24
+ */
25
+ constructor(namespace) {
26
+ this._kv = namespace;
27
+ }
28
+
29
+ /**
30
+ * @param {string} key
31
+ * @returns {Promise<{ ts: number, count: number } | null>}
32
+ */
33
+ async get(key) {
34
+ try {
35
+ const value = await this._kv.get(key, { type: "json" });
36
+ return value ?? null;
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {string} key
44
+ * @param {{ ts: number, count: number }} value
45
+ * @param {number} ttlSeconds
46
+ * @returns {Promise<void>}
47
+ */
48
+ async put(key, value, ttlSeconds) {
49
+ try {
50
+ await this._kv.put(key, JSON.stringify(value), {
51
+ expirationTtl: Math.ceil(ttlSeconds),
52
+ });
53
+ } catch (_) {
54
+ // Write failure — degrade gracefully
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * owlguard — adapters/redis.js
3
+ * Pre-built storage adapter for Redis.
4
+ *
5
+ * Compatible with both:
6
+ * - node-redis (v4+): client.setEx(key, ttl, value)
7
+ * - ioredis: client.setex(key, ttl, value) [lowercase]
8
+ *
9
+ * Usage:
10
+ * import { createClient } from "redis";
11
+ * import { RedisAdapter } from "owlguard/adapters/redis";
12
+ *
13
+ * const redis = createClient({ url: process.env.REDIS_URL });
14
+ * await redis.connect();
15
+ *
16
+ * const owl = new OwlGuard({
17
+ * storage: new RedisAdapter(redis),
18
+ * });
19
+ */
20
+
21
+ export class RedisAdapter {
22
+ /**
23
+ * @param {object} client — A redis or ioredis client instance.
24
+ */
25
+ constructor(client) {
26
+ this._client = client;
27
+ }
28
+
29
+ /**
30
+ * @param {string} key
31
+ * @returns {Promise<{ ts: number, count: number } | null>}
32
+ */
33
+ async get(key) {
34
+ try {
35
+ const raw = await this._client.get(key);
36
+ return raw ? JSON.parse(raw) : null;
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {string} key
44
+ * @param {{ ts: number, count: number }} value
45
+ * @param {number} ttlSeconds
46
+ * @returns {Promise<void>}
47
+ */
48
+ async put(key, value, ttlSeconds) {
49
+ try {
50
+ const serialized = JSON.stringify(value);
51
+ // Support both node-redis (setEx) and ioredis (setex)
52
+ if (typeof this._client.setEx === "function") {
53
+ await this._client.setEx(key, ttlSeconds, serialized);
54
+ } else {
55
+ await this._client.setex(key, ttlSeconds, serialized);
56
+ }
57
+ } catch (_) {
58
+ // Write failure — degrade gracefully, rate limiting continues in-memory
59
+ }
60
+ }
61
+ }
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
1
+ var Z=Object.defineProperty;var q=(e,t)=>()=>(e&&(t=e(e=0)),t);var ee=(e,t)=>{for(var n in t)Z(e,n,{get:t[n],enumerable:!0})};var X={};ee(X,{computeSignature:()=>N,verifyHandshake:()=>ue});async function N(e,t){let n=new TextEncoder,o=n.encode(t),a=n.encode(e),s=await crypto.subtle.importKey("raw",o,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),r=await crypto.subtle.sign("HMAC",s,a);return Array.from(new Uint8Array(r)).map(l=>l.toString(16).padStart(2,"0")).join("")}async function ue(e,t){let n=e["x-owlguard-signature"],o=e["x-owlguard-timestamp"];if(!n||!o)return!1;let a=parseInt(o,10);if(isNaN(a)||Math.abs(Date.now()-a)>5*60*1e3)return!1;try{let r=await N(o,t);return n===r}catch{return!1}}var U=q(()=>{});function M(e){if(e&&typeof e.ip=="string"&&typeof e.method=="string")return{ip:e.ip??"unknown",country:e.country??null,asn:e.asn??null,userAgent:e.userAgent??"",method:e.method??"GET",pathname:e.pathname??"/",headers:A(e.headers??{}),bodySize:e.bodySize??0,cookie:e.cookie??""};if(e&&typeof e.cf<"u"){let t=new URL(e.url),n=Object.fromEntries(e.headers);return{ip:e.headers.get("CF-Connecting-IP")??"unknown",country:e.cf?.country??null,asn:e.cf?.asn??null,userAgent:e.headers.get("user-agent")??"",method:e.method,pathname:t.pathname,headers:A(n),bodySize:Number(e.headers.get("content-length")??0),cookie:e.headers.get("cookie")??"",cfRaw:{"cf-ipcountry":e.cf?.country??null,"cf-threat-score":e.cf?.threatScore!=null?String(e.cf.threatScore):null,"cf-asn":e.cf?.asn!=null?String(e.cf.asn):null,"cf-bot-score":e.cf?.botManagement?.score!=null?String(e.cf.botManagement.score):null}}}if(e&&typeof e.headers?.get=="function"){let t=new URL(e.url),n=Object.fromEntries(e.headers);return{ip:e.headers.get("x-forwarded-for")?.split(",")[0]?.trim()??e.headers.get("x-real-ip")??"unknown",country:e.headers.get("cf-ipcountry")??null,asn:null,userAgent:e.headers.get("user-agent")??"",method:e.method,pathname:t.pathname,headers:A(n),bodySize:Number(e.headers.get("content-length")??0),cookie:e.headers.get("cookie")??"",cfRaw:null}}if(e&&e.socket!==void 0){let t=e.headers.host??"localhost",n=new URL(e.url??"/",`http://${t}`);return{ip:e.headers["x-forwarded-for"]?.split(",")[0]?.trim()??e.socket?.remoteAddress??"unknown",country:e.headers["cf-ipcountry"]??null,asn:null,userAgent:e.headers["user-agent"]??"",method:e.method??"GET",pathname:n.pathname,headers:A(e.headers),bodySize:Number(e.headers["content-length"]??0),cookie:e.headers.cookie??"",cfRaw:null}}return console.warn("[OwlGuard] Could not detect request platform. Pass a Cloudflare Request, Node IncomingMessage, or a normalized context object."),{ip:"unknown",country:null,asn:null,userAgent:"",method:"GET",pathname:"/",headers:{},bodySize:0,cookie:"",cfRaw:null}}function A(e){let t={};for(let[n,o]of Object.entries(e))t[n.toLowerCase()]=o;return t}var v=class{constructor(){this._store=new Map}async get(t){return this._store.get(t)??null}async put(t,n,o){this._store.set(t,n),this._prune(o*1e3)}_prune(t){let n=Date.now()-t;for(let[o,a]of this._store.entries())a.ts<n&&this._store.delete(o)}};var k={none:0,block:1,warn:2,info:3},C={block:"\u{1F6AB} [OwlGuard] BLOCKED",challenge:"\u{1F989} [OwlGuard] CHALLENGED",allow:"\u2705 [OwlGuard] ALLOWED",skip:"\u23ED\uFE0F [OwlGuard] SKIPPED"},T=class{constructor(t="warn"){this.level=k[t]??k.warn}block(t,n,o){this.level<k.block||console.warn(`${C.block} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}challenge(t,n,o){this.level<k.warn||console.warn(`${C.challenge} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}allow(t,n,o){this.level<k.info||console.info(`${C.allow} | reason=${t} ip=${n.ip} method=${n.method} path=${n.pathname} score=${o}`)}skip(t,n){this.level<k.info||console.info(`${C.skip} | reason=${t} ip=${n.ip} path=${n.pathname}`)}};async function j(e,t,n={}){let o=n.windowMs??6e4,a=n.max??120,s=`owlguard:rl:${e}`,r=Date.now(),i=Math.ceil(o/1e3),l=null;try{l=await t.get(s)}catch{return!1}let d=1,c=r;l&&r-l.ts<o&&(d=l.count+1,c=l.ts);try{await t.put(s,{ts:c,count:d},i)}catch{return!1}return d>a}function F(e){return e.split(".").reduce((t,n)=>(t<<8)+parseInt(n,10),0)>>>0}function te(e,t){if(!t.includes("/"))return e===t;let[n,o]=t.split("/"),a=parseInt(o,10);if(isNaN(a)||a<0||a>32)return!1;let s=a===0?0:-1<<32-a>>>0,r=F(e),i=F(n);return(r&s)===(i&s)}function x(e){if(e=e.split("%")[0],e.includes(".")){let r=e.lastIndexOf(":"),i=e.slice(r+1),l=e.slice(0,r),c=F(i).toString(16).padStart(8,"0");e=l+":"+c.slice(0,4)+":"+c.slice(4)}let t=e.split("::");if(t.length>2)return null;let n=t[0]?t[0].split(":"):[],o=t[1]?t[1].split(":"):[],a=8-n.length-o.length;if(a<0)return null;let s=[...n,...Array(a).fill("0"),...o];return s.length!==8?null:s.map(r=>r.padStart(4,"0")).join("")}function ne(e,t){if(!t.includes("/")){let c=x(e),u=x(t);return c!==null&&u!==null&&c===u}let[n,o]=t.split("/"),a=parseInt(o,10);if(isNaN(a)||a<0||a>128)return!1;let s=x(e),r=x(n);if(!s||!r)return!1;let i=BigInt("0x"+s),l=BigInt("0x"+r),d=a===0?0n:~0n<<BigInt(128-a)&0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFn;return(i&d)===(l&d)}function oe(e,t){return!e||!t?!1:e.includes(":")?ne(e,t):te(e,t)}function E(e,t){return!e||!Array.isArray(t)||t.length===0?!1:t.some(n=>oe(e,n))}function B(e,t={}){if(!t||Object.keys(t).length===0)return{allowed:!1,reason:""};if(t.ips?.length&&E(e.ip,t.ips))return{allowed:!0,reason:"allowlist_ip"};let n=t.apiKeys;if(n?.header&&n?.values?.length){let o=e.headers[n.header.toLowerCase()];if(o&&n.values.includes(o))return{allowed:!0,reason:"allowlist_apikey"}}return{allowed:!1,reason:""}}function R(e,t={}){return!t||Object.keys(t).length===0?{denied:!1,reason:""}:t.ips?.length&&E(e.ip,t.ips)?{denied:!0,reason:"denylist_ip"}:t.asns?.length&&e.asn&&t.asns.includes(Number(e.asn))?{denied:!0,reason:"denylist_asn"}:{denied:!1,reason:""}}function I(e,t={}){return!t||Object.keys(t).length===0?{blocked:!1,reason:""}:t.deny?.length&&e&&t.deny.includes(e)?{blocked:!0,reason:"geo_deny"}:t.allow?.length&&(!e||!t.allow.includes(e))?{blocked:!0,reason:"geo_allow"}:{blocked:!1,reason:""}}var ae=["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS"],re=["POST","PUT","PATCH"];function G(e,t={}){if(!t||Object.keys(t).length===0)return{valid:!0,status:200,reason:""};if(!(t.allowedMethods??ae).includes(e.method?.toUpperCase()))return{valid:!1,status:405,reason:"method_not_allowed"};if(t.maxBodySize!=null&&e.bodySize>t.maxBodySize)return{valid:!1,status:413,reason:"body_too_large"};if(t.allowedContentTypes?.length&&re.includes(e.method?.toUpperCase())){let o=(e.headers["content-type"]??"").split(";")[0].trim().toLowerCase();if(o&&!t.allowedContentTypes.map(s=>s.toLowerCase().trim()).some(s=>o.startsWith(s)))return{valid:!1,status:415,reason:"content_type_not_allowed"}}return{valid:!0,status:200,reason:""}}var se=[/googlebot/i,/bingbot/i,/slurp/i,/duckduckbot/i,/facebookexternalhit/i,/twitterbot/i,/linkedinbot/i,/whatsapp/i,/applebot/i],ie=[/bot/i,/crawl/i,/spider/i,/scrape/i,/curl/i,/python-requests/i,/axios/i,/wget/i,/go-http-client/i,/headlesschrome/i,/phantomjs/i,/selenium/i,/puppeteer/i,/playwright/i,/java\//i,/ruby/i,/libwww/i],le=[/\/\.env/i,/\/wp-admin/i,/\/wp-login/i,/\/phpmyadmin/i,/\/xmlrpc\.php/i,/\/config\./i,/\/\.git/i,/\/admin/i,/\/shell/i,/\/passwd/i];function K(e,t={},n=null){let o=e.userAgent??"",a=e.headers.accept??"",s=e.headers["accept-language"]??"",r=e.pathname??"",i=t.goodBots??se,l=t.badUaPatterns??ie,d=t.suspiciousPaths??le;if(i.some(u=>u.test(o)))return-1;let c=0;return o?l.some(u=>u.test(o))&&(c+=60):c+=40,s||(c+=15),(!a||a==="*/*")&&(c+=10),d.some(u=>u.test(r))&&(c+=50),n&&(n.threatScore!=null&&(c+=Math.round(n.threatScore/100*60)),n.botScore!=null&&(c+=Math.round((1-n.botScore/99)*60)),n.isTor&&(c+=40),n.isVpn&&(c+=20)),Math.min(c,100)}var ce="https://enrich.deployowl.com/enrich";async function z(e,t,n=50){let{ip:o,cfRaw:a}=e;if(!t||!o||o==="unknown")return null;let s=new AbortController,r=setTimeout(()=>s.abort(),n);try{let i=await fetch(ce,{method:"POST",signal:s.signal,headers:{"content-type":"application/json","x-owl-api-key":t},body:JSON.stringify({ip:o,cfHeaders:a??{}})});if(!i.ok)return null;let l=await i.json();return{threatScore:l.threatScore??null,country:l.country??null,asn:l.asn??null,isTor:l.isTor??!1,isVpn:l.isVpn??!1,botScore:l.botScore??null}}catch{return null}finally{clearTimeout(r)}}async function H(e,t){let n=new TextEncoder,o=await crypto.subtle.importKey("raw",n.encode(t),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await crypto.subtle.sign("HMAC",o,n.encode(e));return Array.prototype.map.call(new Uint8Array(a),s=>("00"+s.toString(16)).slice(-2)).join("")}async function V(e={}){let t=e.difficulty??"000",n=e.title??"Checking your browser...",o=e.message??"Please wait while we verify your request.",a=e.signingSecret??"default_waf_secret",s=e.ip??"",r=crypto.randomUUID?crypto.randomUUID():Math.random().toString(36).slice(2)+Date.now().toString(36),i=Date.now(),l=`${r}:${i}:${s}`,d=await H(l,a),c=`${r}.${i}.${d}`;return`<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>${O(n)}</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11
+ display: flex;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ justify-content: center;
15
+ min-height: 100vh;
16
+ background: #f9fafb;
17
+ color: #374151;
18
+ }
19
+ .card {
20
+ background: #fff;
21
+ border: 1px solid #e5e7eb;
22
+ border-radius: 12px;
23
+ padding: 2.5rem 3rem;
24
+ text-align: center;
25
+ max-width: 420px;
26
+ width: 90%;
27
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
28
+ }
29
+ .owl { font-size: 3rem; margin-bottom: 1rem; }
30
+ h1 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; }
31
+ p { font-size: 0.9rem; color: #6b7280; margin-bottom: 1.5rem; }
32
+ .progress {
33
+ height: 4px;
34
+ background: #e5e7eb;
35
+ border-radius: 2px;
36
+ overflow: hidden;
37
+ }
38
+ .progress-bar {
39
+ height: 100%;
40
+ width: 0%;
41
+ background: #6366f1;
42
+ border-radius: 2px;
43
+ transition: width 0.3s ease;
44
+ animation: pulse 1.5s ease-in-out infinite;
45
+ }
46
+ @keyframes pulse {
47
+ 0%, 100% { opacity: 1; }
48
+ 50% { opacity: 0.6; }
49
+ }
50
+ </style>
51
+ </head>
52
+ <body>
53
+ <div class="card">
54
+ <div class="owl">\u{1F989}</div>
55
+ <h1>${O(n)}</h1>
56
+ <p>${O(o)}</p>
57
+ <div class="progress"><div class="progress-bar" id="bar"></div></div>
58
+ </div>
59
+
60
+ <script>
61
+ (async () => {
62
+ const challenge = ${JSON.stringify(c)};
63
+ const difficulty = ${JSON.stringify(t)};
64
+ const bar = document.getElementById("bar");
65
+ const maxAttempts = 2_000_000;
66
+
67
+ for (let n = 0; n < maxAttempts; n++) {
68
+ // Yield to browser every 500 iterations to keep UI responsive
69
+ if (n % 500 === 0) {
70
+ bar.style.width = Math.min((n / 50000) * 100, 95) + "%";
71
+ await new Promise(r => setTimeout(r, 0));
72
+ }
73
+
74
+ const buf = await crypto.subtle.digest(
75
+ "SHA-256",
76
+ new TextEncoder().encode(challenge + n)
77
+ );
78
+ const hex = Array.from(new Uint8Array(buf))
79
+ .map(b => b.toString(16).padStart(2, "0"))
80
+ .join("");
81
+
82
+ if (hex.startsWith(difficulty)) {
83
+ bar.style.width = "100%";
84
+ // SameSite=Lax: safe for top-level nav, blocked on cross-site subrequests
85
+ document.cookie =
86
+ "_owl_cleared=" + btoa(challenge + ":" + n + ":" + difficulty) +
87
+ "; path=/; SameSite=Lax";
88
+ // Small delay so progress bar visually completes
89
+ await new Promise(r => setTimeout(r, 300));
90
+ location.reload();
91
+ return;
92
+ }
93
+ }
94
+
95
+ // Exhausted attempts without solving \u2014 reload anyway (rare edge case)
96
+ location.reload();
97
+ })();
98
+ </script>
99
+ </body>
100
+ </html>`}async function W(e,t="000",n="default_waf_secret"){let a=(e.cookie??"").match(/(?:^|;)\s*_owl_cleared=([^;]+)/);if(!a)return!1;let s;try{s=atob(a[1])}catch{return!1}let r=s.split(":");if(r.length<3)return!1;let i=r[r.length-1],l=r[r.length-2],d=r.slice(0,r.length-2).join(":");if(i!==t)return!1;let c=d.split(".");if(c.length!==3)return!1;let[u,b,w]=c,g=parseInt(b);if(isNaN(g))return!1;let h=Date.now();if(h-g>3e5||h-g<-6e4)return!1;let f=e.ip??"",P=`${u}:${g}:${f}`,D=await H(P,n);if(w!==D){let p=`${u}:${g}:`,m=await H(p,n);if(w!==m)return!1}try{let p=new TextEncoder,m=await crypto.subtle.digest("SHA-256",p.encode(d+l));return Array.prototype.map.call(new Uint8Array(m),S=>("00"+S.toString(16)).slice(-2)).join("").startsWith(t)}catch{return!1}}function O(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")}async function Y(e,t,n,o){if((t.bypassPaths??[]).some(p=>e.pathname.startsWith(p)))return o.skip("bypass",e),$("bypass");if(await j(e.ip,n,t.rateLimit))return o.block("rate_limited",e,0),y(429,"rate_limited",0);let{allowed:r,reason:i}=B(e,t.allowlist);if(r)return o.allow(i,e,0),$(i);let{denied:l,reason:d}=R(e,t.denylist);if(l)return o.block(d,e,0),y(403,d,0);let{blocked:c,reason:u}=I(e.country,t.geo);if(c)return o.block(u,e,0),y(403,u,0);let{valid:b,status:w,reason:g}=G(e,t.validation);if(!b)return o.block(g,e,0),y(w,g,0);let h=null;if(t.apiKey){let p=t.enrichmentTimeout??50;if(h=await z(e,t.apiKey,p),h?.country&&(e.country=h.country),h?.asn&&t.denylist?.asns?.length){let m={...e,asn:h.asn},{denied:_,reason:S}=R(m,t.denylist);if(_)return o.block(S,e,0),y(403,S,0)}if(h?.country&&h.country!==e.country){let{blocked:m,reason:_}=I(h.country,t.geo);if(m)return o.block(_,e,0),y(403,_,0)}}let f=K(e,t.scorer,h);if(f===-1)return o.allow("good_bot",e,-1),$("good_bot",-1);let P=t.blockScore??80;if(f>=P)return o.block("bot_score_block",e,f),y(403,"bot_score_block",f);let D=t.challengeScore??50;if(f>=D){let p=t.challenge?.difficulty??"000",m=t.challenge?.signingSecret||t.apiKey||"default_waf_secret";if(!await W(e,p,m))return o.challenge("bot_score_challenge",e,f),await de(f,t.challenge,e.ip,m)}return o.allow("allow",e,f),$("allow",f)}function $(e,t=0){return{blocked:!1,status:200,reason:e,score:t,html:null}}function y(e,t,n){return{blocked:!0,status:e,reason:t,score:n,html:null}}async function de(e,t={},n="",o=""){return{blocked:!0,status:200,reason:"challenge",score:e,html:await V({...t,ip:n,signingSecret:o})}}var L="https://security.deployowl.com";async function J(e,t,n,o){if(!n||!o||!L||L.startsWith("__"))return;let a=new AbortController,s=setTimeout(()=>a.abort(),2e3);try{await fetch(L,{method:"POST",signal:a.signal,headers:{"content-type":"application/json","x-owl-secret":o,"x-owl-api-key":n},body:JSON.stringify({ts:Date.now(),apiKey:n,ip:t.ip,country:t.country,pathname:t.pathname,method:t.method,blocked:e.blocked,reason:e.reason,score:e.score})})}catch{}finally{clearTimeout(s)}}U();var Q=class{constructor(t={}){this._config=t,this._store=t.storage??new v,this._logger=new T(t.log??"warn"),this._callbacks=[],this._remoteConfig=null,this._remoteConfigAt=0,this._remoteConfigTtl=15*60*1e3,this._quotaExceeded=!1,t.apiKey||(t.log??"warn")!=="none"&&console.info("[OwlGuard] No apiKey provided. Enrichment signals (threatScore, Tor, VPN, ASN) will be skipped. Get a key at https://deployowl.com")}async _fetchRemoteConfig(){if(this._config.apiKey)try{let n=await fetch("https://api.deployowl.com/api/v1/config",{method:"GET",headers:{"X-API-Key":this._config.apiKey}});if(n.status===402){this._quotaExceeded=!0,this._remoteConfigAt=Date.now();return}if(n.ok){let o=await n.json();this._remoteConfig=o,this._quotaExceeded=!!o.quotaExceeded,this._remoteConfigAt=Date.now()}}catch(t){this._logger.info(`[OwlGuard] Failed to fetch remote config: ${t.message}`)}}onDecision(t){return typeof t=="function"&&this._callbacks.push(t),this}async decision(t,n=null){let o=M(t),a=n,s=Date.now();!this._quotaExceeded&&this._config.remoteConfig===!0&&s-this._remoteConfigAt>=this._remoteConfigTtl&&await this._fetchRemoteConfig();let r={...this._config,...this._remoteConfig||{}};if(this._quotaExceeded&&(r.apiKey=null),r.apiKey)try{let{verifyHandshake:l}=await Promise.resolve().then(()=>(U(),X));if(await l(o.headers,r.apiKey)){let c=o.headers["x-owlguard-decision"]||"allow",u=o.headers["x-owlguard-reason"]||"allowed",b=parseInt(o.headers["x-owlguard-score"]||"0",10),w={blocked:c==="block",status:c==="block"?403:200,reason:u,score:isNaN(b)?0:b,html:null};return this._logger.info(`[HANDSHAKE BYPASS] Trusted Edge verdict: ${c} (score: ${w.score})`,o),w}}catch(l){this._logger.info(`[HANDSHAKE BYPASS] Handshake verification skipped: ${l.message}`)}let i=await Y(o,r,this._store,this._logger);return r.dryRun&&i.blocked&&(this._logger.info(`[DRY RUN] Would have blocked: ${i.reason} (score: ${i.score})`,o),i.blocked=!1,i.simulated=!0),this._fireCallbacks(i,o,a,r.apiKey),i}_fireCallbacks(t,n,o,a){let s=this._callbacks.map(l=>Promise.resolve().then(()=>l(t,n)).catch(()=>{})),r=a&&t.blocked&&this._config.reporting&&this._config.reportingSecret?J(t,n,a,this._config.reportingSecret).catch(()=>{}):Promise.resolve(),i=Promise.allSettled([...s,r]);o&&typeof o.waitUntil=="function"&&o.waitUntil(i)}};export{Q as OwlGuard,N as computeSignature};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@deployowl/guard",
3
+ "version": "2.2.2",
4
+ "description": "Enterprise-grade, platform-agnostic bot detection and security middleware",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./types/index.d.ts"
12
+ },
13
+ "./adapters/redis": {
14
+ "import": "./adapters/redis.js",
15
+ "types": "./types/adapters.d.ts"
16
+ },
17
+ "./adapters/cloudflare-kv": {
18
+ "import": "./adapters/cloudflare-kv.js",
19
+ "types": "./types/adapters.d.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist/",
24
+ "adapters/",
25
+ "types/"
26
+ ],
27
+ "scripts": {
28
+ "build": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.js --minify --platform=neutral",
29
+ "dev": "esbuild src/index.js --bundle --format=esm --outfile=dist/index.js --platform=neutral --watch"
30
+ },
31
+ "keywords": [
32
+ "security",
33
+ "bot-detection",
34
+ "middleware",
35
+ "rate-limiting",
36
+ "geo-blocking",
37
+ "waf"
38
+ ],
39
+ "author": "OwlGuard",
40
+ "license": "Proprietary",
41
+ "engines": {
42
+ "node": ">=18.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "esbuild": "^0.21.5"
46
+ }
47
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * owlguard — TypeScript type declarations for storage adapters.
3
+ */
4
+
5
+ import type { StorageAdapter } from "./index.js";
6
+
7
+ // ── Redis Adapter ─────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Minimal interface for a Redis client.
11
+ * Compatible with both `ioredis` and `node-redis` (v4+).
12
+ */
13
+ export interface RedisLike {
14
+ get(key: string): Promise<string | null>;
15
+ setEx(key: string, ttlSeconds: number, value: string): Promise<unknown>;
16
+ }
17
+
18
+ /**
19
+ * Pre-built storage adapter for Redis.
20
+ * Works with ioredis and node-redis (v4+).
21
+ *
22
+ * @example
23
+ * import { createClient } from "redis";
24
+ * import { RedisAdapter } from "owlguard/adapters/redis";
25
+ *
26
+ * const redis = createClient({ url: process.env.REDIS_URL });
27
+ * await redis.connect();
28
+ *
29
+ * const owl = new OwlGuard({
30
+ * storage: new RedisAdapter(redis),
31
+ * });
32
+ */
33
+ export declare class RedisAdapter implements StorageAdapter {
34
+ constructor(client: RedisLike);
35
+ get(key: string): Promise<{ ts: number; count: number } | null>;
36
+ put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
37
+ }
38
+
39
+ // ── Cloudflare KV Adapter ─────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Minimal interface for a Cloudflare KV namespace.
43
+ */
44
+ export interface CloudflareKVNamespace {
45
+ get(key: string, options: { type: "json" }): Promise<unknown>;
46
+ put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
47
+ }
48
+
49
+ /**
50
+ * Pre-built storage adapter for Cloudflare KV.
51
+ *
52
+ * @example
53
+ * // In your Cloudflare Worker:
54
+ * import { CloudflareKVAdapter } from "owlguard/adapters/cloudflare-kv";
55
+ *
56
+ * const owl = new OwlGuard({
57
+ * storage: new CloudflareKVAdapter(env.OWL_RATE_LIMIT),
58
+ * });
59
+ */
60
+ export declare class CloudflareKVAdapter implements StorageAdapter {
61
+ constructor(namespace: CloudflareKVNamespace);
62
+ get(key: string): Promise<{ ts: number; count: number } | null>;
63
+ put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
64
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * owlguard — TypeScript type declarations
3
+ * Full types for the OwlGuard npm package public API.
4
+ */
5
+
6
+ // ── Storage Adapter ───────────────────────────────────────────────────────────
7
+
8
+ export interface StorageAdapter {
9
+ /** Retrieve a stored value by key. Returns null if not found. */
10
+ get(key: string): Promise<{ ts: number; count: number } | null>;
11
+ /** Store a value with a TTL in seconds. */
12
+ put(key: string, value: { ts: number; count: number }, ttlSeconds: number): Promise<void>;
13
+ }
14
+
15
+ // ── Configuration ─────────────────────────────────────────────────────────────
16
+
17
+ export interface AllowlistApiKeysConfig {
18
+ /** The header name to check (e.g. "x-api-key"). */
19
+ header: string;
20
+ /** Values that are unconditionally trusted. */
21
+ values: string[];
22
+ }
23
+
24
+ export interface AllowlistConfig {
25
+ /** IPv4, IPv6, or CIDR ranges to unconditionally allow (e.g. "10.0.0.0/8"). */
26
+ ips?: string[];
27
+ /** API key header config for unconditional trust. */
28
+ apiKeys?: AllowlistApiKeysConfig;
29
+ }
30
+
31
+ export interface DenylistConfig {
32
+ /** IPv4, IPv6, or CIDR ranges to unconditionally block. */
33
+ ips?: string[];
34
+ /** ASN numbers to block entire ISPs or datacenters. */
35
+ asns?: number[];
36
+ }
37
+
38
+ export interface GeoConfig {
39
+ /**
40
+ * If set, ONLY these ISO 3166-1 alpha-2 country codes are allowed.
41
+ * Checked after geo.deny.
42
+ */
43
+ allow?: string[];
44
+ /** ISO 3166-1 alpha-2 country codes to always block. Checked first. */
45
+ deny?: string[];
46
+ }
47
+
48
+ export interface ScorerConfig {
49
+ /** UA patterns for verified good bots — always allowed (score = -1). */
50
+ goodBots?: RegExp[];
51
+ /** UA patterns that raise the bot suspicion score. */
52
+ badUaPatterns?: RegExp[];
53
+ /** Path patterns that raise the bot suspicion score. */
54
+ suspiciousPaths?: RegExp[];
55
+ }
56
+
57
+ export interface RateLimitConfig {
58
+ /** Sliding window duration in milliseconds. Default: 60000 (1 minute). */
59
+ windowMs?: number;
60
+ /** Maximum requests per window per IP. Default: 120. */
61
+ max?: number;
62
+ }
63
+
64
+ export interface ValidationConfig {
65
+ /** Maximum allowed body size in bytes. */
66
+ maxBodySize?: number;
67
+ /** Allowed HTTP methods. Default: GET, POST, PUT, DELETE, PATCH. */
68
+ allowedMethods?: string[];
69
+ /** Allowed Content-Type values (checked on POST, PUT, PATCH). */
70
+ allowedContentTypes?: string[];
71
+ }
72
+
73
+ export interface ChallengeConfig {
74
+ /**
75
+ * SHA-256 leading-zero difficulty.
76
+ * "00" ≈ 10ms, "000" ≈ 200ms (default), "0000" ≈ 3s.
77
+ */
78
+ difficulty?: string;
79
+ /** Title text shown on the challenge page. */
80
+ title?: string;
81
+ /** Body message shown on the challenge page. */
82
+ message?: string;
83
+ }
84
+
85
+ export interface OwlGuardConfig {
86
+ /**
87
+ * Your OwlGuard install key (owl_live_xxx).
88
+ * Optional — enrichment signals are skipped without it.
89
+ */
90
+ apiKey?: string;
91
+
92
+ /**
93
+ * External storage adapter for globally-consistent rate limiting.
94
+ * If omitted, OwlGuard uses an in-memory store (per-instance).
95
+ * For multi-instance deployments, provide a Redis or Cloudflare KV adapter.
96
+ */
97
+ storage?: StorageAdapter;
98
+
99
+ /**
100
+ * Path prefixes that skip ALL security checks.
101
+ * Use for webhooks, health probes, etc.
102
+ * e.g. ["/api/webhook", "/healthz"]
103
+ */
104
+ bypassPaths?: string[];
105
+
106
+ /** Requests matching any allowlist entry are unconditionally allowed. */
107
+ allowlist?: AllowlistConfig;
108
+
109
+ /** Requests matching any denylist entry are hard-blocked (403). */
110
+ denylist?: DenylistConfig;
111
+
112
+ /** Country-level geo fencing using ISO 3166-1 alpha-2 codes. */
113
+ geo?: GeoConfig;
114
+
115
+ /**
116
+ * Bot score threshold for a hard 403 block.
117
+ * Range: 0–100. Default: 80.
118
+ */
119
+ blockScore?: number;
120
+
121
+ /**
122
+ * Bot score threshold for a JS proof-of-work challenge.
123
+ * Range: 0–100. Default: 50.
124
+ */
125
+ challengeScore?: number;
126
+
127
+ /** Override the default bot scoring patterns. */
128
+ scorer?: ScorerConfig;
129
+
130
+ /** Rate limiting configuration. */
131
+ rateLimit?: RateLimitConfig;
132
+
133
+ /** Request shape validation. */
134
+ validation?: ValidationConfig;
135
+
136
+ /** JS proof-of-work challenge page configuration. */
137
+ challenge?: ChallengeConfig;
138
+
139
+ /**
140
+ * Console log verbosity.
141
+ * - "none" — silent
142
+ * - "block" — hard blocks only
143
+ * - "warn" — blocks + challenges (default)
144
+ * - "info" — everything
145
+ */
146
+ log?: "none" | "block" | "warn" | "info";
147
+ }
148
+
149
+ // ── Decision Result ───────────────────────────────────────────────────────────
150
+
151
+ /** All possible machine-readable reason keys returned in DecisionResult. */
152
+ export type DecisionReason =
153
+ | "bypass"
154
+ | "allowlist_ip"
155
+ | "allowlist_apikey"
156
+ | "denylist_ip"
157
+ | "denylist_asn"
158
+ | "geo_deny"
159
+ | "geo_allow"
160
+ | "method_not_allowed"
161
+ | "body_too_large"
162
+ | "content_type_not_allowed"
163
+ | "rate_limited"
164
+ | "good_bot"
165
+ | "bot_score_block"
166
+ | "challenge"
167
+ | "allow";
168
+
169
+ export interface DecisionResult {
170
+ /** True if the request should be stopped. */
171
+ blocked: boolean;
172
+ /** HTTP status to return (200, 403, 429, 405, 413, 415). */
173
+ status: number;
174
+ /** Machine-readable reason key. */
175
+ reason: DecisionReason;
176
+ /**
177
+ * Bot suspicion score at time of decision.
178
+ * Range: 0–100. -1 means verified good bot.
179
+ */
180
+ score: number;
181
+ /**
182
+ * Challenge page HTML. Only present when reason === "challenge".
183
+ * Return this as an HTML response with status 200.
184
+ */
185
+ html: string | null;
186
+ }
187
+
188
+ // ── Normalized Request Context (internal, exposed for onDecision) ─────────────
189
+
190
+ export interface OwlContext {
191
+ ip: string;
192
+ country: string | null;
193
+ asn: number | null;
194
+ userAgent: string;
195
+ method: string;
196
+ pathname: string;
197
+ headers: Record<string, string>;
198
+ bodySize: number;
199
+ cookie: string;
200
+ cfRaw: Record<string, string | null> | null;
201
+ }
202
+
203
+ // ── OwlGuard Class ─────────────────────────────────────────────────────────────
204
+
205
+ /** Callback invoked after every security decision. Never blocks the response. */
206
+ export type OnDecisionCallback = (result: DecisionResult, ctx: OwlContext) => void | Promise<void>;
207
+
208
+ export declare class OwlGuard {
209
+ constructor(config?: OwlGuardConfig);
210
+
211
+ /**
212
+ * Register a callback invoked after every security decision.
213
+ * Fires asynchronously — never blocks or delays the decision result.
214
+ * Use for logging, analytics, or billing integrations.
215
+ *
216
+ * @example
217
+ * owl.onDecision((result, ctx) => {
218
+ * if (result.blocked) console.log(`Blocked ${ctx.ip}: ${result.reason}`);
219
+ * });
220
+ */
221
+ onDecision(fn: OnDecisionCallback): this;
222
+
223
+ /**
224
+ * Evaluates a request and returns a security decision.
225
+ *
226
+ * Accepts any of:
227
+ * - Cloudflare Worker Request
228
+ * - Node.js http.IncomingMessage
229
+ * - Generic fetch-API Request
230
+ * - Pre-normalized OwlContext object
231
+ */
232
+ decision(request: Request | import("http").IncomingMessage | OwlContext | object): Promise<DecisionResult>;
233
+ }