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/src/risk.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type RiskReason =
|
|
2
|
+
| "velocity" // too many attempts / requests in the window
|
|
3
|
+
| "spike" // sudden burst (API: > N req in 10 s)
|
|
4
|
+
| "fast_action" // submitted unrealistically fast after page load
|
|
5
|
+
| "retry" // same form submitted more than once
|
|
6
|
+
| "enumeration" // sequential numeric IDs in URL path
|
|
7
|
+
| "endpoint_concentration" // one endpoint hit repeatedly (scraping / extraction)
|
|
8
|
+
| "probing" // high 4xx rate from this IP (directory / param probing)
|
|
9
|
+
| "flow_anomaly"; // write request with no prior read (bot / agent shortcut)
|
|
10
|
+
|
|
11
|
+
export type RiskLevel = "low" | "medium" | "high";
|
|
12
|
+
|
|
13
|
+
export type RiskResult = {
|
|
14
|
+
riskScore: number;
|
|
15
|
+
riskLevel: RiskLevel;
|
|
16
|
+
reasons: RiskReason[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Weights per spec: velocity +30, retry +25, fast_action +20
|
|
20
|
+
const REASON_SCORES: Record<RiskReason, number> = {
|
|
21
|
+
velocity: 30,
|
|
22
|
+
spike: 40,
|
|
23
|
+
fast_action: 20,
|
|
24
|
+
retry: 25,
|
|
25
|
+
enumeration: 50,
|
|
26
|
+
endpoint_concentration: 35,
|
|
27
|
+
probing: 40,
|
|
28
|
+
flow_anomaly: 30
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function buildRiskResult(reasons: RiskReason[]): RiskResult {
|
|
32
|
+
const unique = [...new Set(reasons)];
|
|
33
|
+
const riskScore = Math.min(100, unique.reduce((sum, r) => sum + REASON_SCORES[r], 0));
|
|
34
|
+
const riskLevel: RiskLevel = riskScore >= 70 ? "high" : riskScore >= 40 ? "medium" : "low";
|
|
35
|
+
return { riskScore, riskLevel, reasons: unique };
|
|
36
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createEngine, type EngineOptions } from "./engine.js";
|
|
2
|
+
import { buildRiskResult } from "./risk.js";
|
|
3
|
+
|
|
4
|
+
export type { RiskReason, RiskLevel, RiskResult } from "./risk.js";
|
|
5
|
+
|
|
6
|
+
type IncomingHeaders = Record<string, string | string[] | undefined>;
|
|
7
|
+
|
|
8
|
+
/** Request shape — compatible with Express, Next.js, and plain Node IncomingMessage. */
|
|
9
|
+
export type SignupGuardRequest = {
|
|
10
|
+
method?: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
ip?: string;
|
|
13
|
+
headers?: IncomingHeaders;
|
|
14
|
+
signupGuardRisk?: import("./risk.js").RiskResult;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type WatchAccessOptions = EngineOptions & {
|
|
19
|
+
/** Max requests per velocityWindowMs before "velocity" fires. Default: 60. */
|
|
20
|
+
velocityThreshold?: number;
|
|
21
|
+
/** Max requests per 10 s before "spike" fires. Default: 20. */
|
|
22
|
+
spikeThreshold?: number;
|
|
23
|
+
/** Max requests to same normalised endpoint before "endpoint_concentration" fires. Default: 20. */
|
|
24
|
+
concThreshold?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WatchAccessMiddleware = (
|
|
28
|
+
req: SignupGuardRequest,
|
|
29
|
+
res: unknown,
|
|
30
|
+
next: () => void
|
|
31
|
+
) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Express / Node middleware.
|
|
35
|
+
* Attaches req.signupGuardRisk = { riskScore, riskLevel, reasons } to every request.
|
|
36
|
+
*
|
|
37
|
+
* Detected signals (all O(1), in-memory, zero external calls):
|
|
38
|
+
* velocity — too many requests from this IP in the window
|
|
39
|
+
* spike — burst of requests in 10 s
|
|
40
|
+
* enumeration — sequential numeric IDs in URL path
|
|
41
|
+
* endpoint_concentration — same endpoint hit repeatedly (scraping)
|
|
42
|
+
* probing — high 4xx rate (directory / param probing)
|
|
43
|
+
* flow_anomaly — write request with no prior read (bot shortcut)
|
|
44
|
+
*
|
|
45
|
+
* Mount it narrowly: app.use("/api/checkout", watchAccess()) rather than globally.
|
|
46
|
+
*/
|
|
47
|
+
export function watchAccess(options: WatchAccessOptions = {}): WatchAccessMiddleware {
|
|
48
|
+
const engine = createEngine(options);
|
|
49
|
+
|
|
50
|
+
return function watchAccessMiddleware(req, res, next) {
|
|
51
|
+
const ip = resolveIp(req);
|
|
52
|
+
const url = req.url ?? "";
|
|
53
|
+
const method = req.method ?? "GET";
|
|
54
|
+
|
|
55
|
+
const reasons = engine.track(ip, { url, method });
|
|
56
|
+
|
|
57
|
+
// Capture response status asynchronously for probing detection.
|
|
58
|
+
// Safe cast: a no-op when res has no 'on' method (plain object, tests).
|
|
59
|
+
const resObj = res as { on?: (event: string, cb: () => void) => void; statusCode?: unknown };
|
|
60
|
+
resObj.on?.("finish", () => {
|
|
61
|
+
if (typeof resObj.statusCode === "number" && resObj.statusCode >= 400) {
|
|
62
|
+
engine.reportError(ip);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
req.signupGuardRisk = buildRiskResult(reasons);
|
|
67
|
+
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolveIp(req: SignupGuardRequest): string {
|
|
73
|
+
const forwarded = req.headers?.["x-forwarded-for"];
|
|
74
|
+
|
|
75
|
+
if (forwarded) {
|
|
76
|
+
return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0].trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return typeof req.ip === "string" ? req.ip : "unknown";
|
|
80
|
+
}
|