apira-guard 0.1.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.
- package/README.md +296 -0
- package/demo/index.html +168 -0
- package/dist/engine.d.ts +47 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +125 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +348 -0
- package/dist/risk.d.ts +9 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +17 -0
- package/dist/server.d.ts +37 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +42 -0
- package/package.json +45 -0
- package/src/engine.ts +193 -0
- package/src/index.ts +507 -0
- package/src/risk.ts +36 -0
- package/src/server.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Apira
|
|
2
|
+
|
|
3
|
+
Catches bots, abuse, and broken flows after Cloudflare, before PostHog.
|
|
4
|
+
|
|
5
|
+
Detect fake signups, risky actions, API abuse, and silent backend failures — locally, with no API key.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install apira-guard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Apira is a local, no-network layer. **`watchActions()`** and **`watchAccess()`** attach `riskScore`, `riskLevel`, and `reasons` to matched form submits and HTTP requests. **`watchSignups()`** / **`watchForms()`** listen for conversion submits and fire `signupguard:submit` with intent metadata (no risk payload). **`protectForms()`** blocks obvious bad email and phone values before submit.
|
|
12
|
+
|
|
13
|
+
No account. No dashboard. No outbound calls.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Common use cases
|
|
18
|
+
|
|
19
|
+
Use Apira to:
|
|
20
|
+
|
|
21
|
+
* block or flag fake or junk signups (`protectForms`, then optional `watchActions` risk)
|
|
22
|
+
* detect disposable emails and fake-looking phones at submit time (`protectForms`)
|
|
23
|
+
* score checkout, password reset, account update, invite, waitlist, demo request, and contact sales forms (`watchActions` — match by form copy or `data-signup-guard-form`)
|
|
24
|
+
* detect forms submitted unrealistically fast (`watchActions`)
|
|
25
|
+
* detect repeated form submissions (`watchActions`)
|
|
26
|
+
* add request-risk scoring to Express, Next.js, or Node APIs
|
|
27
|
+
* detect request velocity
|
|
28
|
+
* detect sudden traffic spikes
|
|
29
|
+
* detect simple API enumeration like `/api/users/1`, `/api/users/2`, `/api/users/3`
|
|
30
|
+
* send risk events to your own logs, database, Sentry, PostHog, or analytics stack
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Add one line
|
|
35
|
+
|
|
36
|
+
### Signup forms
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { watchSignups } from "apira-guard";
|
|
40
|
+
|
|
41
|
+
watchSignups();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Auto-detects signup forms with email or phone fields and fires `signupguard:submit` on submit (detail: `intent`, `timestamp`, `metadata`). It does **not** inject `signupGuardRisk` — use `watchActions({ action: "signup" })` (plus matching form text or `data-signup-guard-form="signup"`) when you want the same risk JSON on signup flows.
|
|
45
|
+
|
|
46
|
+
Use `protectForms()` if you want to block obvious bad submissions (invalid / disposable email, bad phone shapes).
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
### Important actions
|
|
51
|
+
|
|
52
|
+
Protect conversion-critical forms like checkout, password reset, account update, invite, waitlist, demo request, and contact sales.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { watchActions } from "apira-guard";
|
|
56
|
+
|
|
57
|
+
watchActions({ action: "checkout" });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Apira watches the matching form, computes risk on submit, injects `signupGuardRisk` into the form body, and fires `signupguard:action`.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
watchActions({
|
|
64
|
+
action: "checkout",
|
|
65
|
+
onAction(event) {
|
|
66
|
+
console.log(event.action, event.risk);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Target a form explicitly:
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<form data-signup-guard-form="checkout">
|
|
75
|
+
...
|
|
76
|
+
</form>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### API routes
|
|
82
|
+
|
|
83
|
+
Watch server-side requests for lightweight API abuse signals.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { watchAccess } from "apira-guard/server";
|
|
87
|
+
|
|
88
|
+
app.use(watchAccess());
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Apira attaches risk directly to the request:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
app.get("/api/users/:id", (req, res) => {
|
|
95
|
+
if (req.signupGuardRisk?.riskLevel === "high") {
|
|
96
|
+
return res.status(429).json({ error: "rate limited" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
res.json({ ok: true });
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Configure velocity and spike detection:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
watchAccess({
|
|
107
|
+
velocityThreshold: 60,
|
|
108
|
+
spikeThreshold: 20,
|
|
109
|
+
velocityWindowMs: 60_000
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Risk output
|
|
116
|
+
|
|
117
|
+
**`watchActions()`** and **`watchAccess()`** both use the same payload shape:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
{
|
|
121
|
+
riskScore: 85,
|
|
122
|
+
riskLevel: "high",
|
|
123
|
+
reasons: ["velocity", "fast_action", "enumeration"]
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Reason | Where | What it means |
|
|
128
|
+
| ------------------------ | -------------- | ------------- |
|
|
129
|
+
| `velocity` | forms (`watchActions`), APIs | Too many submits or requests in the sliding window (default 60 s on the server; client `watchActions` uses a stricter per-form threshold) |
|
|
130
|
+
| `fast_action` | forms (`watchActions`) | Submitted faster than the fast-action threshold after the watcher attached |
|
|
131
|
+
| `retry` | forms (`watchActions`) | Same form submitted again |
|
|
132
|
+
| `enumeration` | APIs | Sequential numeric IDs in the URL path (run ≥ 3) |
|
|
133
|
+
| `spike` | APIs | Burst of requests in a 10 s window |
|
|
134
|
+
| `endpoint_concentration` | APIs | Same normalised endpoint hit repeatedly in the velocity window |
|
|
135
|
+
| `probing` | APIs | High 4xx rate from this client (after enough samples) |
|
|
136
|
+
| `flow_anomaly` | APIs | Write request with no prior read on the same key (bot-style shortcut) |
|
|
137
|
+
|
|
138
|
+
Score thresholds: **low** < 40 · **medium** 40–69 · **high** ≥ 70.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Can Apira block risk?
|
|
143
|
+
|
|
144
|
+
Yes. Apira can run as a signal layer or a blocking layer.
|
|
145
|
+
|
|
146
|
+
Signal mode: **`watchActions()`** injects a hidden `signupGuardRisk` JSON field on submit; **`watchAccess()`** sets `req.signupGuardRisk` on each request.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const risk = JSON.parse(req.body.signupGuardRisk ?? "{}");
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Block mode stops obvious bad activity before it continues.
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
import { protectForms } from "apira-guard";
|
|
156
|
+
|
|
157
|
+
protectForms();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
For APIs, block high-risk requests in middleware:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
app.use(watchAccess());
|
|
164
|
+
|
|
165
|
+
app.use((req, res, next) => {
|
|
166
|
+
if (req.signupGuardRisk?.riskLevel === "high") {
|
|
167
|
+
return res.status(429).json({ error: "Too many suspicious requests" });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
next();
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Apira can reduce obvious abuse. It does not guarantee fraud prevention or stop sophisticated attackers.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Reading risk on the server
|
|
179
|
+
|
|
180
|
+
**`watchActions()`** injects a hidden field on matched forms:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
app.post("/checkout", (req, res) => {
|
|
184
|
+
const risk = JSON.parse(req.body.signupGuardRisk ?? "{}");
|
|
185
|
+
|
|
186
|
+
if (risk.riskLevel === "high") {
|
|
187
|
+
return res.status(422).json({ error: "Suspicious request" });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
res.json({ ok: true });
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
API routes get risk directly:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
req.signupGuardRisk;
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
For TypeScript Express apps:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
declare global {
|
|
204
|
+
namespace Express {
|
|
205
|
+
interface Request {
|
|
206
|
+
signupGuardRisk?: import("apira-guard/server").RiskResult;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Signup-first, conversion-ready
|
|
215
|
+
|
|
216
|
+
`watchSignups()` is shorthand for:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
watchForms({ intent: "signup" });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
For any conversion-critical form, use `watchForms()`:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { watchForms } from "apira-guard";
|
|
226
|
+
|
|
227
|
+
watchForms({
|
|
228
|
+
intent: "demo_request",
|
|
229
|
+
metadata: { page: "pricing" },
|
|
230
|
+
onSubmit(event) {
|
|
231
|
+
console.log(event.intent, event.timestamp, event.metadata);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Opt a form in or out:
|
|
237
|
+
|
|
238
|
+
```html
|
|
239
|
+
<form data-signup-guard-form="checkout">...</form>
|
|
240
|
+
<form data-signup-guard-ignore>...</form>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## What protectForms() blocks
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { protectForms } from "apira-guard";
|
|
249
|
+
|
|
250
|
+
protectForms();
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Blocks obvious garbage at submit time using browser validation messages:
|
|
254
|
+
|
|
255
|
+
* invalid email formats
|
|
256
|
+
* disposable email domains
|
|
257
|
+
* invalid phone formats
|
|
258
|
+
* fake-looking phones like `0000000000`, `1234567890`, and `555-01xx`
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## What ships in npm
|
|
263
|
+
|
|
264
|
+
* `watchSignups()`
|
|
265
|
+
* `watchForms()`
|
|
266
|
+
* `watchActions()`
|
|
267
|
+
* `protectForms()`
|
|
268
|
+
* `watchAccess()`
|
|
269
|
+
* shared risk payloads
|
|
270
|
+
* local scoring rules
|
|
271
|
+
* reason codes
|
|
272
|
+
* TypeScript types
|
|
273
|
+
|
|
274
|
+
Everything runs inside your app. Apira does not call external services.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## What is not included
|
|
279
|
+
|
|
280
|
+
Not included today:
|
|
281
|
+
|
|
282
|
+
* hosted dashboard
|
|
283
|
+
* shared reputation network
|
|
284
|
+
* external IP reputation checks
|
|
285
|
+
* external email verification APIs
|
|
286
|
+
* ML fraud models
|
|
287
|
+
|
|
288
|
+
These may become optional later. The npm package works without them.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## What Apira is not
|
|
293
|
+
|
|
294
|
+
Apira is not a CAPTCHA, identity verification system, hosted fraud platform, or replacement for server-side validation.
|
|
295
|
+
|
|
296
|
+
It is a local first line of defense for forms, actions, and API routes — the layer between edge protection and your analytics/logging stack.
|
package/demo/index.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<!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>Apira Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
9
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; }
|
|
10
|
+
h2 { margin-top: 2.5rem; }
|
|
11
|
+
label { display: block; margin-block: 0.75rem 0.2rem; font-size: 0.9rem; color: #555; }
|
|
12
|
+
input { padding: 0.5rem 0.6rem; width: 100%; border: 1px solid #ccc; border-radius: 4px; font: inherit; }
|
|
13
|
+
button { margin-top: 0.9rem; padding: 0.55rem 1rem; font: inherit; cursor: pointer; border: 1px solid #ccc; border-radius: 4px; background: #fff; }
|
|
14
|
+
button:hover { background: #f3f4f6; }
|
|
15
|
+
pre {
|
|
16
|
+
background: #111827; color: #f9fafb; border-radius: 6px;
|
|
17
|
+
overflow: auto; padding: 1rem; font-size: 0.85rem; min-height: 3rem;
|
|
18
|
+
}
|
|
19
|
+
.badge {
|
|
20
|
+
display: inline-block; padding: 0.2rem 0.6rem; border-radius: 999px;
|
|
21
|
+
font-size: 0.75rem; font-weight: 600; text-transform: uppercase; margin-left: 0.5rem;
|
|
22
|
+
vertical-align: middle;
|
|
23
|
+
}
|
|
24
|
+
.low { background: #dcfce7; color: #166534; }
|
|
25
|
+
.medium { background: #fef9c3; color: #713f12; }
|
|
26
|
+
.high { background: #fee2e2; color: #991b1b; }
|
|
27
|
+
hr { margin-block: 2.5rem; border: none; border-top: 1px solid #e5e7eb; }
|
|
28
|
+
.buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<h1>Apira Demo</h1>
|
|
33
|
+
<p>Local behaviour engine — no network calls, no API keys.</p>
|
|
34
|
+
|
|
35
|
+
<!-- ── A) protectForms ──────────────────────────────────────────── -->
|
|
36
|
+
<h2>A) <code>protectForms()</code></h2>
|
|
37
|
+
<p>Blocks fake inputs at the browser level. Try submitting as-is.</p>
|
|
38
|
+
<form id="signup-form">
|
|
39
|
+
<label for="email">Email</label>
|
|
40
|
+
<input id="email" name="email" value="test@mailinator.com" />
|
|
41
|
+
<label for="phone">Phone</label>
|
|
42
|
+
<input id="phone" name="phone" value="1111111111" />
|
|
43
|
+
<button type="submit">Create account</button>
|
|
44
|
+
</form>
|
|
45
|
+
<pre id="protect-result">→ submit to see what gets blocked</pre>
|
|
46
|
+
|
|
47
|
+
<hr />
|
|
48
|
+
|
|
49
|
+
<!-- ── B) watchActions ──────────────────────────────────────────── -->
|
|
50
|
+
<h2>
|
|
51
|
+
B) <code>watchActions({ action: "checkout" })</code>
|
|
52
|
+
<span id="risk-badge" class="badge" style="display:none"></span>
|
|
53
|
+
</h2>
|
|
54
|
+
<p>
|
|
55
|
+
Click <strong>Checkout</strong> several times in a row.
|
|
56
|
+
Watch the risk score climb: <em>fast_action</em> → <em>retry</em> → <em>velocity</em>.
|
|
57
|
+
</p>
|
|
58
|
+
<form id="checkout-form" data-signup-guard-form="checkout">
|
|
59
|
+
<label for="card">Card number</label>
|
|
60
|
+
<input id="card" name="card" placeholder="4242 4242 4242 4242" />
|
|
61
|
+
<button type="submit">Checkout</button>
|
|
62
|
+
</form>
|
|
63
|
+
<pre id="actions-result">→ submit the form to see risk signals</pre>
|
|
64
|
+
|
|
65
|
+
<hr />
|
|
66
|
+
|
|
67
|
+
<!-- ── C) watchAccess simulation ───────────────────────────────── -->
|
|
68
|
+
<h2>C) <code>watchAccess()</code> — API abuse patterns</h2>
|
|
69
|
+
<p>
|
|
70
|
+
In production this runs server-side as <code>app.use("/api", watchAccess())</code>.
|
|
71
|
+
Here we run the same engine in-browser to show the signals.
|
|
72
|
+
</p>
|
|
73
|
+
<div class="buttons">
|
|
74
|
+
<button id="btn-normal">Normal user</button>
|
|
75
|
+
<button id="btn-enum">Enumerator</button>
|
|
76
|
+
<button id="btn-scraper">Scraper</button>
|
|
77
|
+
<button id="btn-bot">Bot (no browse)</button>
|
|
78
|
+
<button id="btn-brute">Brute force</button>
|
|
79
|
+
</div>
|
|
80
|
+
<pre id="access-result">→ pick a pattern above</pre>
|
|
81
|
+
|
|
82
|
+
<!-- ── scripts ──────────────────────────────────────────────────── -->
|
|
83
|
+
<script type="module">
|
|
84
|
+
import { protectForms, watchActions } from "../dist/index.js";
|
|
85
|
+
|
|
86
|
+
// A) protect forms
|
|
87
|
+
const signupForm = document.querySelector("#signup-form");
|
|
88
|
+
const protectOut = document.querySelector("#protect-result");
|
|
89
|
+
|
|
90
|
+
protectForms();
|
|
91
|
+
signupForm.addEventListener("signupguard:block", (e) => {
|
|
92
|
+
protectOut.textContent = JSON.stringify(e.detail, null, 2);
|
|
93
|
+
});
|
|
94
|
+
signupForm.addEventListener("submit", (e) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
protectOut.textContent = "✓ valid — submission would continue";
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// B) watch actions
|
|
100
|
+
const actionsOut = document.querySelector("#actions-result");
|
|
101
|
+
const badge = document.querySelector("#risk-badge");
|
|
102
|
+
|
|
103
|
+
watchActions({
|
|
104
|
+
action: "checkout",
|
|
105
|
+
onAction({ risk }) {
|
|
106
|
+
actionsOut.textContent = JSON.stringify(risk, null, 2);
|
|
107
|
+
badge.textContent = risk.riskLevel;
|
|
108
|
+
badge.className = `badge ${risk.riskLevel}`;
|
|
109
|
+
badge.style.display = "inline-block";
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
document.querySelector("#checkout-form").addEventListener("submit", (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
});
|
|
116
|
+
</script>
|
|
117
|
+
|
|
118
|
+
<script type="module">
|
|
119
|
+
// C) watchAccess simulation — same engine the server uses
|
|
120
|
+
import { createEngine } from "../dist/engine.js";
|
|
121
|
+
import { buildRiskResult } from "../dist/risk.js";
|
|
122
|
+
|
|
123
|
+
const accessOut = document.querySelector("#access-result");
|
|
124
|
+
|
|
125
|
+
function simulate(label, requests) {
|
|
126
|
+
// One engine per simulation (mirrors one watchAccess() mount per route)
|
|
127
|
+
const engine = createEngine({ velocityThreshold: 5, spikeThreshold: 5, concThreshold: 5 });
|
|
128
|
+
const log = [];
|
|
129
|
+
|
|
130
|
+
for (const [ip, method, url] of requests) {
|
|
131
|
+
const reasons = engine.track(ip, { url, method });
|
|
132
|
+
log.push({ url, method, ...buildRiskResult(reasons) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const last = log.at(-1);
|
|
136
|
+
const signals = [...new Set(log.flatMap(r => r.reasons))];
|
|
137
|
+
|
|
138
|
+
accessOut.textContent =
|
|
139
|
+
`Pattern : ${label}\n` +
|
|
140
|
+
`Requests: ${requests.length}\n\n` +
|
|
141
|
+
`Last request:\n${JSON.stringify(last, null, 2)}\n\n` +
|
|
142
|
+
`All signals fired: ${signals.length ? signals.join(", ") : "none (clean traffic)"}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ip = "demo-ip";
|
|
146
|
+
|
|
147
|
+
document.querySelector("#btn-normal").onclick = () =>
|
|
148
|
+
simulate("Normal user", [
|
|
149
|
+
[ip, "GET", "/api/products"],
|
|
150
|
+
[ip, "GET", "/api/product/42"],
|
|
151
|
+
[ip, "GET", "/api/checkout"],
|
|
152
|
+
[ip, "POST", "/api/checkout"],
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
document.querySelector("#btn-enum").onclick = () =>
|
|
156
|
+
simulate("Enumerator", [1,2,3,4,5,6,7].map(n => [ip, "GET", `/api/user/${n}`]));
|
|
157
|
+
|
|
158
|
+
document.querySelector("#btn-scraper").onclick = () =>
|
|
159
|
+
simulate("Scraper", Array.from({ length: 8 }, () => [ip, "GET", "/api/products"]));
|
|
160
|
+
|
|
161
|
+
document.querySelector("#btn-bot").onclick = () =>
|
|
162
|
+
simulate("Bot (no browse)", Array.from({ length: 4 }, () => [ip, "POST", "/api/checkout"]));
|
|
163
|
+
|
|
164
|
+
document.querySelector("#btn-brute").onclick = () =>
|
|
165
|
+
simulate("Brute force", Array.from({ length: 8 }, () => [ip, "POST", "/api/login"]));
|
|
166
|
+
</script>
|
|
167
|
+
</body>
|
|
168
|
+
</html>
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O(1) behaviour detection engine — shared by all surfaces.
|
|
3
|
+
*
|
|
4
|
+
* All counters use fixed-window arithmetic so every operation is constant time:
|
|
5
|
+
* no timestamp arrays, no filter/scan, no history queries.
|
|
6
|
+
*/
|
|
7
|
+
import type { RiskReason } from "./risk.js";
|
|
8
|
+
export type TrackEvent = {
|
|
9
|
+
now?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Milliseconds since the watcher was attached (submit surface).
|
|
12
|
+
* Presence of this field marks the event as a submit-surface event:
|
|
13
|
+
* - enables fast_action and retry signals
|
|
14
|
+
* - disables enumeration / endpoint_concentration / flow_anomaly
|
|
15
|
+
*/
|
|
16
|
+
elapsed?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Request URL (access surface).
|
|
19
|
+
* Presence of this field marks the event as an access-surface event:
|
|
20
|
+
* - enables enumeration, endpoint_concentration, flow_anomaly
|
|
21
|
+
* - disables retry
|
|
22
|
+
*/
|
|
23
|
+
url?: string;
|
|
24
|
+
/** HTTP method — required for flow_anomaly (access surface only). */
|
|
25
|
+
method?: string;
|
|
26
|
+
};
|
|
27
|
+
export type EngineOptions = {
|
|
28
|
+
/** Requests per velocityWindowMs before "velocity" fires. Default: 60. */
|
|
29
|
+
velocityThreshold?: number;
|
|
30
|
+
/** Sliding window for velocity. Default: 60 000 ms. */
|
|
31
|
+
velocityWindowMs?: number;
|
|
32
|
+
/** Requests per 10 s before "spike" fires. Default: 20. */
|
|
33
|
+
spikeThreshold?: number;
|
|
34
|
+
/** Requests to the same normalised endpoint before "endpoint_concentration" fires. Default: 20. */
|
|
35
|
+
concThreshold?: number;
|
|
36
|
+
/** Minimum error rate (0–1) before "probing" fires (needs ≥ 5 requests). Default: 0.5. */
|
|
37
|
+
errorRateThreshold?: number;
|
|
38
|
+
/** Elapsed ms threshold for "fast_action". Default: 3 000. */
|
|
39
|
+
fastActionMs?: number;
|
|
40
|
+
};
|
|
41
|
+
export type Engine = {
|
|
42
|
+
track(key: string, event: TrackEvent): RiskReason[];
|
|
43
|
+
/** Call after a 4xx / 5xx response to feed the probing detector. */
|
|
44
|
+
reportError(key: string): void;
|
|
45
|
+
};
|
|
46
|
+
export declare function createEngine(opts?: EngineOptions): Engine;
|
|
47
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAI5C,MAAM,MAAM,UAAU,GAAG;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2DAA2D;IAC3D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mGAAmG;IACnG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0FAA0F;IAC1F,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG;IACnB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,UAAU,EAAE,CAAC;IACpD,oEAAoE;IACpE,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC,CAAC;AA0CF,wBAAgB,YAAY,CAAC,IAAI,GAAE,aAAkB,GAAG,MAAM,CA6F7D"}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O(1) behaviour detection engine — shared by all surfaces.
|
|
3
|
+
*
|
|
4
|
+
* All counters use fixed-window arithmetic so every operation is constant time:
|
|
5
|
+
* no timestamp arrays, no filter/scan, no history queries.
|
|
6
|
+
*/
|
|
7
|
+
const NUMERIC_ID_RE = /\/(\d+)(?:\/|$|\?)/u;
|
|
8
|
+
function makeState(now) {
|
|
9
|
+
return {
|
|
10
|
+
winStart: now, winCount: 0,
|
|
11
|
+
spikeStart: now, spikeCount: 0,
|
|
12
|
+
totalCount: 0,
|
|
13
|
+
lastPathNum: null, seqRun: 0,
|
|
14
|
+
epWinStart: now, epCounts: new Map(),
|
|
15
|
+
reqCount: 0, errCount: 0,
|
|
16
|
+
hasGet: false
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// ─── factory ─────────────────────────────────────────────────────────────────
|
|
20
|
+
export function createEngine(opts = {}) {
|
|
21
|
+
const velocityThreshold = opts.velocityThreshold ?? 60;
|
|
22
|
+
const velocityWindowMs = opts.velocityWindowMs ?? 60_000;
|
|
23
|
+
const spikeThreshold = opts.spikeThreshold ?? 20;
|
|
24
|
+
const concThreshold = opts.concThreshold ?? 20;
|
|
25
|
+
const errorRateThreshold = opts.errorRateThreshold ?? 0.5;
|
|
26
|
+
const fastActionMs = opts.fastActionMs ?? 3_000;
|
|
27
|
+
const store = new Map();
|
|
28
|
+
function state(key, now) {
|
|
29
|
+
let s = store.get(key);
|
|
30
|
+
if (!s) {
|
|
31
|
+
s = makeState(now);
|
|
32
|
+
store.set(key, s);
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
track(key, event) {
|
|
38
|
+
const now = event.now ?? Date.now();
|
|
39
|
+
const s = state(key, now);
|
|
40
|
+
const reasons = [];
|
|
41
|
+
// ── Velocity (O(1) fixed window) ───────────────────────────────────────
|
|
42
|
+
if (now - s.winStart >= velocityWindowMs) {
|
|
43
|
+
s.winStart = now;
|
|
44
|
+
s.winCount = 0;
|
|
45
|
+
}
|
|
46
|
+
s.winCount++;
|
|
47
|
+
if (s.winCount > velocityThreshold)
|
|
48
|
+
reasons.push("velocity");
|
|
49
|
+
// ── Spike (O(1) fixed 10 s window) ────────────────────────────────────
|
|
50
|
+
if (now - s.spikeStart >= 10_000) {
|
|
51
|
+
s.spikeStart = now;
|
|
52
|
+
s.spikeCount = 0;
|
|
53
|
+
}
|
|
54
|
+
s.spikeCount++;
|
|
55
|
+
if (s.spikeCount > spikeThreshold)
|
|
56
|
+
reasons.push("spike");
|
|
57
|
+
s.totalCount++;
|
|
58
|
+
// ── Fast action (submit surface) ───────────────────────────────────────
|
|
59
|
+
if (event.elapsed !== undefined && event.elapsed < fastActionMs) {
|
|
60
|
+
reasons.push("fast_action");
|
|
61
|
+
}
|
|
62
|
+
if (event.url !== undefined) {
|
|
63
|
+
// ── Access-surface signals ─────────────────────────────────────────
|
|
64
|
+
// Enumeration (O(1) sequential-run counter)
|
|
65
|
+
const m = NUMERIC_ID_RE.exec(event.url);
|
|
66
|
+
if (m) {
|
|
67
|
+
const num = parseInt(m[1], 10);
|
|
68
|
+
s.seqRun = (s.lastPathNum !== null && Math.abs(num - s.lastPathNum) <= 5)
|
|
69
|
+
? s.seqRun + 1
|
|
70
|
+
: 1;
|
|
71
|
+
s.lastPathNum = num;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
s.lastPathNum = null;
|
|
75
|
+
s.seqRun = 0;
|
|
76
|
+
}
|
|
77
|
+
if (s.seqRun >= 3)
|
|
78
|
+
reasons.push("enumeration");
|
|
79
|
+
// Endpoint concentration (O(1) per-endpoint window counter)
|
|
80
|
+
if (now - s.epWinStart >= velocityWindowMs) {
|
|
81
|
+
s.epCounts.clear();
|
|
82
|
+
s.epWinStart = now;
|
|
83
|
+
}
|
|
84
|
+
const ep = normalizeEndpoint(event.url);
|
|
85
|
+
const epCount = (s.epCounts.get(ep) ?? 0) + 1;
|
|
86
|
+
s.epCounts.set(ep, epCount);
|
|
87
|
+
if (epCount > concThreshold)
|
|
88
|
+
reasons.push("endpoint_concentration");
|
|
89
|
+
// Flow anomaly (O(1) method tracker — POST/PUT/DELETE with no prior GET)
|
|
90
|
+
if (event.method) {
|
|
91
|
+
const method = event.method.toUpperCase();
|
|
92
|
+
if (method === "GET") {
|
|
93
|
+
s.hasGet = true;
|
|
94
|
+
}
|
|
95
|
+
else if (!s.hasGet && s.totalCount > 2) {
|
|
96
|
+
reasons.push("flow_anomaly");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Probing (O(1) error rate — evaluated on next request after reportError calls)
|
|
100
|
+
s.reqCount++;
|
|
101
|
+
if (s.reqCount >= 5 && s.errCount / s.reqCount > errorRateThreshold) {
|
|
102
|
+
reasons.push("probing");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// ── Submit-surface signals ─────────────────────────────────────────
|
|
107
|
+
// Retry (second or later submit to the same form)
|
|
108
|
+
if (s.totalCount > 1)
|
|
109
|
+
reasons.push("retry");
|
|
110
|
+
}
|
|
111
|
+
return reasons;
|
|
112
|
+
},
|
|
113
|
+
reportError(key) {
|
|
114
|
+
const s = store.get(key);
|
|
115
|
+
if (s) {
|
|
116
|
+
s.errCount++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
122
|
+
function normalizeEndpoint(url) {
|
|
123
|
+
// Strip query string, collapse numeric path segments to :id
|
|
124
|
+
return url.split("?")[0].replace(/\/\d+/gu, "/:id");
|
|
125
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type RiskResult } from "./risk.js";
|
|
2
|
+
export type { RiskReason, RiskLevel, RiskResult } from "./risk.js";
|
|
3
|
+
export type ConversionFormIntent = "signup" | "checkout" | "demo_request" | "waitlist" | "lead_capture" | "contact_sales" | "invite";
|
|
4
|
+
export type WatchFormsEvent = {
|
|
5
|
+
intent: ConversionFormIntent;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
metadata: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
export type WatchFormsOptions = {
|
|
10
|
+
/** Root node to search. Defaults to document. */
|
|
11
|
+
root?: ParentNode;
|
|
12
|
+
/** Revenue-critical flow being watched. Defaults to signup. */
|
|
13
|
+
intent?: ConversionFormIntent;
|
|
14
|
+
/** Optional metadata copied into the submit event. */
|
|
15
|
+
metadata?: Record<string, unknown>;
|
|
16
|
+
/** Called when a watched form submits. */
|
|
17
|
+
onSubmit?: (event: WatchFormsEvent) => void;
|
|
18
|
+
};
|
|
19
|
+
export type WatchFormsController = {
|
|
20
|
+
/** Number of forms watched by this call. */
|
|
21
|
+
watchedForms: number;
|
|
22
|
+
/** Remove submit listeners added by this call. */
|
|
23
|
+
destroy: () => void;
|
|
24
|
+
};
|
|
25
|
+
export type ProtectFormsController = {
|
|
26
|
+
/** Number of forms protected by this call. */
|
|
27
|
+
protectedForms: number;
|
|
28
|
+
/** Remove submit listeners added by this call. */
|
|
29
|
+
destroy: () => void;
|
|
30
|
+
};
|
|
31
|
+
export type WatchActionsOptions = {
|
|
32
|
+
/** Action being watched — e.g. "checkout", "reset", "update". */
|
|
33
|
+
action: string;
|
|
34
|
+
/** Root node to search. Defaults to document. */
|
|
35
|
+
root?: ParentNode;
|
|
36
|
+
/** Called on each action with the risk result. */
|
|
37
|
+
onAction?: (event: WatchActionsEvent) => void;
|
|
38
|
+
};
|
|
39
|
+
export type WatchActionsEvent = {
|
|
40
|
+
action: string;
|
|
41
|
+
risk: RiskResult;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
};
|
|
44
|
+
export type WatchActionsController = {
|
|
45
|
+
/** Number of forms watched by this call. */
|
|
46
|
+
watchedForms: number;
|
|
47
|
+
/** Remove submit listeners added by this call. */
|
|
48
|
+
destroy: () => void;
|
|
49
|
+
};
|
|
50
|
+
export declare function watchSignups(options?: Omit<WatchFormsOptions, "intent">): WatchFormsController;
|
|
51
|
+
export declare function watchForms(options?: WatchFormsOptions): WatchFormsController;
|
|
52
|
+
export declare function watchActions(options: WatchActionsOptions): WatchActionsController;
|
|
53
|
+
export declare function protectForms(root?: ParentNode): ProtectFormsController;
|
|
54
|
+
//# sourceMappingURL=index.d.ts.map
|