@decentrys/sentinel-sdk 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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/browser/decentrys-sentinel.js +377 -0
- package/dist/browser/decentrys-sentinel.mjs +352 -0
- package/dist/client.d.ts +118 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +218 -0
- package/dist/client.js.map +1 -0
- package/dist/from-audit.d.ts +70 -0
- package/dist/from-audit.d.ts.map +1 -0
- package/dist/from-audit.js +207 -0
- package/dist/from-audit.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +77 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +27 -0
- package/dist/model.js.map +1 -0
- package/package.json +62 -0
- package/src/client.ts +289 -0
- package/src/from-audit.test.ts +131 -0
- package/src/from-audit.ts +253 -0
- package/src/index.ts +3 -0
- package/src/model.ts +85 -0
package/dist/model.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The Sentinel model.
|
|
4
|
+
*
|
|
5
|
+
* Sentinel answers a different question from Protect. Protect asks "should I
|
|
6
|
+
* sign this?" about something a user is about to do; Sentinel asks "did
|
|
7
|
+
* something happen to what I own?" about systems already deployed. That
|
|
8
|
+
* changes what matters:
|
|
9
|
+
*
|
|
10
|
+
* - **The subject is yours.** You register your own contracts, wallets and
|
|
11
|
+
* treasuries. Nothing here classifies a third party, so none of Protect's
|
|
12
|
+
* machinery for avoiding accusations applies — and neither does its
|
|
13
|
+
* restraint about severity.
|
|
14
|
+
* - **A rule is a statement about facts, not a score.** A rule fires or it
|
|
15
|
+
* does not, and the alert says which fact made it fire. There is no
|
|
16
|
+
* threshold model to argue with.
|
|
17
|
+
* - **An alert must be actionable.** An alert nobody can act on trains
|
|
18
|
+
* people to close alerts, which is worse than having none.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.TARGET_TYPES = exports.ALERT_SEVERITIES = void 0;
|
|
22
|
+
exports.ALERT_SEVERITIES = ['INFO', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
|
23
|
+
/** What a monitored thing is. Determines which rules can apply to it. */
|
|
24
|
+
exports.TARGET_TYPES = [
|
|
25
|
+
'CONTRACT', 'WALLET', 'TREASURY', 'MULTISIG', 'LP_POOL', 'ORACLE', 'BRIDGE', 'GOVERNANCE',
|
|
26
|
+
];
|
|
27
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;GAiBG;;;AAEU,QAAA,gBAAgB,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,CAAU,CAAC;AAGvF,yEAAyE;AAC5D,QAAA,YAAY,GAAG;IAC1B,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY;CACjF,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@decentrys/sentinel-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Monitoring for deployed contracts, wallets and treasuries: targets, detection rules, alerts, and audit-to-monitoring handover.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Decentrys Labs",
|
|
7
|
+
"homepage": "https://decentrys.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/teamdecentrys-byte/Decentrys.git",
|
|
11
|
+
"directory": "sdk/sentinel"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/teamdecentrys-byte/Decentrys/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"decentrys",
|
|
18
|
+
"blockchain",
|
|
19
|
+
"web3",
|
|
20
|
+
"security",
|
|
21
|
+
"crypto",
|
|
22
|
+
"monitoring",
|
|
23
|
+
"smart-contract",
|
|
24
|
+
"alerting",
|
|
25
|
+
"detection",
|
|
26
|
+
"defi"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"main": "dist/index.js",
|
|
36
|
+
"types": "dist/index.d.ts",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"import": "./dist/browser/decentrys-sentinel.mjs",
|
|
41
|
+
"default": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./browser": "./dist/browser/decentrys-sentinel.mjs"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"!dist/.tsbuildinfo*",
|
|
48
|
+
"src",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"README.md"
|
|
51
|
+
],
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"typescript": "^5.7.2",
|
|
54
|
+
"vitest": "^3.2.7"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc -p tsconfig.json && npm run build:browser",
|
|
58
|
+
"build:browser": "../../node_modules/.bin/esbuild src/index.ts --bundle --format=esm --target=es2022 --outfile=dist/browser/decentrys-sentinel.mjs && ../../node_modules/.bin/esbuild src/index.ts --bundle --format=iife --global-name=DecentrysSentinel --target=es2022 --outfile=dist/browser/decentrys-sentinel.js",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.spec.json",
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sentinel client.
|
|
3
|
+
*
|
|
4
|
+
* The contract is the opposite of Protect's, and deliberately.
|
|
5
|
+
*
|
|
6
|
+
* Protect sits between a user and a signing screen, so it never throws: a
|
|
7
|
+
* security service having a bad minute must not cost someone their
|
|
8
|
+
* transaction. Sentinel is management and reporting — you are registering a
|
|
9
|
+
* contract, writing a rule, acknowledging an alert. Swallowing a failure
|
|
10
|
+
* there would leave an operator believing they are monitored when they are
|
|
11
|
+
* not, which is the more dangerous silence of the two.
|
|
12
|
+
*
|
|
13
|
+
* So Sentinel throws. Loudly, with the API's own message.
|
|
14
|
+
*
|
|
15
|
+
* The one exception is `reportEvent`, which runs on a hot path in a
|
|
16
|
+
* customer's own system and must never be able to break it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
Alert, AlertSeverity, MonitoringRule, MonitoringTarget, RuleAction, RuleTestResult, TargetType,
|
|
21
|
+
} from './model';
|
|
22
|
+
|
|
23
|
+
export const SDK_VERSION = '0.1.0';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_BASE_URL = 'https://api.decentrys.com';
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export type FetchLike = (
|
|
29
|
+
url: string,
|
|
30
|
+
init: { method: string; headers: Record<string, string>; body?: string; signal?: AbortSignal },
|
|
31
|
+
) => Promise<{ ok: boolean; status: number; text: () => Promise<string> }>;
|
|
32
|
+
|
|
33
|
+
export class SentinelError extends Error {
|
|
34
|
+
constructor(readonly status: number, message: string, readonly code?: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'SentinelError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SentinelConfig {
|
|
41
|
+
apiKey: string;
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
fetch?: FetchLike;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RegisterTargetInput {
|
|
48
|
+
projectId: string;
|
|
49
|
+
chain: string;
|
|
50
|
+
address: string;
|
|
51
|
+
label?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CreateRuleInput {
|
|
55
|
+
projectId: string;
|
|
56
|
+
name: string;
|
|
57
|
+
description?: string;
|
|
58
|
+
severity: AlertSeverity;
|
|
59
|
+
triggerType: string;
|
|
60
|
+
conditions: Record<string, unknown>;
|
|
61
|
+
actions: RuleAction[];
|
|
62
|
+
cooldownSeconds?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ListAlertsInput {
|
|
66
|
+
status?: string;
|
|
67
|
+
severity?: AlertSeverity;
|
|
68
|
+
limit?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class Sentinel {
|
|
72
|
+
private readonly baseUrl: string;
|
|
73
|
+
private readonly apiKey: string;
|
|
74
|
+
private readonly timeoutMs: number;
|
|
75
|
+
private readonly fetchImpl: FetchLike;
|
|
76
|
+
|
|
77
|
+
constructor(config: SentinelConfig) {
|
|
78
|
+
if (!config.apiKey?.trim()) {
|
|
79
|
+
throw new Error('Sentinel: an apiKey is required. Create one at https://decentrys.com/developers.');
|
|
80
|
+
}
|
|
81
|
+
// A publishable key is readable by anyone who downloads the app carrying
|
|
82
|
+
// it. Registering monitoring targets and writing detection rules is not
|
|
83
|
+
// something that credential may do, and saying so here is clearer than a
|
|
84
|
+
// 403 from the server later.
|
|
85
|
+
if (config.apiKey.startsWith('dk_pub_')) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'Sentinel: that is a publishable key. Monitoring is managed server-side with a secret key — a '
|
|
88
|
+
+ 'publishable key ships inside clients and cannot be trusted to configure detection.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.apiKey = config.apiKey;
|
|
93
|
+
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
94
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
95
|
+
this.fetchImpl = config.fetch ?? resolveFetch();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Targets ------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Watch a deployed contract. */
|
|
101
|
+
registerContract(input: RegisterTargetInput): Promise<MonitoringTarget> {
|
|
102
|
+
return this.addTarget(input, 'CONTRACT');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
registerWallet(input: RegisterTargetInput): Promise<MonitoringTarget> {
|
|
106
|
+
return this.addTarget(input, 'WALLET');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
registerTreasury(input: RegisterTargetInput): Promise<MonitoringTarget> {
|
|
110
|
+
return this.addTarget(input, 'TREASURY');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
registerTarget(input: RegisterTargetInput, targetType: TargetType): Promise<MonitoringTarget> {
|
|
114
|
+
return this.addTarget(input, targetType);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
listTargets(projectId?: string): Promise<MonitoringTarget[]> {
|
|
118
|
+
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
119
|
+
return this.request<MonitoringTarget[]>('GET', `/v1/monitoring/targets${query}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async setTargetEnabled(targetId: string, enabled: boolean): Promise<MonitoringTarget> {
|
|
123
|
+
return this.request<MonitoringTarget>('PATCH', `/v1/monitoring/targets/${targetId}`, { enabled });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async removeTarget(targetId: string): Promise<void> {
|
|
127
|
+
await this.request<unknown>('DELETE', `/v1/monitoring/targets/${targetId}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Rules --------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
createRule(input: CreateRuleInput): Promise<MonitoringRule> {
|
|
133
|
+
return this.request<MonitoringRule>('POST', '/v1/monitoring/rules', input);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
updateRule(ruleId: string, changes: Partial<CreateRuleInput> & { enabled?: boolean }): Promise<MonitoringRule> {
|
|
137
|
+
return this.request<MonitoringRule>('PATCH', `/v1/monitoring/rules/${ruleId}`, changes);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async deleteRule(ruleId: string): Promise<void> {
|
|
141
|
+
await this.request<unknown>('DELETE', `/v1/monitoring/rules/${ruleId}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
listRules(projectId?: string): Promise<{ data: MonitoringRule[] }> {
|
|
145
|
+
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
146
|
+
return this.request<{ data: MonitoringRule[] }>('GET', `/v1/monitoring/rules${query}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Evaluate a rule against facts without arming it.
|
|
151
|
+
*
|
|
152
|
+
* A rule that has never been tested against a fact set is a rule nobody
|
|
153
|
+
* knows the behaviour of, and finding out during an incident is the worst
|
|
154
|
+
* possible time.
|
|
155
|
+
*/
|
|
156
|
+
testRule(ruleId: string, facts: Record<string, unknown>): Promise<RuleTestResult> {
|
|
157
|
+
return this.request<RuleTestResult>('POST', `/v1/monitoring/rules/${ruleId}/test`, { facts });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The vocabulary rules are built from: fields, operators, actions, templates. */
|
|
161
|
+
catalog(): Promise<Record<string, unknown>> {
|
|
162
|
+
return this.request<Record<string, unknown>>('GET', '/v1/monitoring/catalog');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Alerts -------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
listAlerts(input: ListAlertsInput = {}): Promise<{ data: Alert[] }> {
|
|
168
|
+
const params = new URLSearchParams();
|
|
169
|
+
if (input.status) params.set('status', input.status);
|
|
170
|
+
if (input.severity) params.set('severity', input.severity);
|
|
171
|
+
if (input.limit) params.set('limit', String(input.limit));
|
|
172
|
+
const query = params.toString();
|
|
173
|
+
return this.request<{ data: Alert[] }>('GET', `/v1/alerts${query ? `?${query}` : ''}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getAlert(alertId: string): Promise<Alert> {
|
|
177
|
+
return this.request<Alert>('GET', `/v1/alerts/${alertId}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
acknowledgeAlert(alertId: string, note?: string): Promise<Alert> {
|
|
181
|
+
return this.request<Alert>('PATCH', `/v1/alerts/${alertId}`, {
|
|
182
|
+
status: 'ACKNOWLEDGED',
|
|
183
|
+
...(note ? { note } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Reporting ----------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Report an event from your own system.
|
|
191
|
+
*
|
|
192
|
+
* The one method here that never throws. It is called from a customer's hot
|
|
193
|
+
* path — a deploy script, a treasury movement, a governance execution — and
|
|
194
|
+
* a monitoring call must not be able to fail the thing it is monitoring.
|
|
195
|
+
* The boolean says whether it was recorded, so a caller who cares can check.
|
|
196
|
+
*/
|
|
197
|
+
async reportEvent(event: {
|
|
198
|
+
projectId: string;
|
|
199
|
+
eventName: string;
|
|
200
|
+
chain?: string;
|
|
201
|
+
address?: string;
|
|
202
|
+
txHash?: string;
|
|
203
|
+
facts?: Record<string, unknown>;
|
|
204
|
+
}): Promise<boolean> {
|
|
205
|
+
try {
|
|
206
|
+
await this.request<unknown>('POST', '/v1/monitoring/events', event);
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- Internals ----------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
private addTarget(input: RegisterTargetInput, targetType: TargetType): Promise<MonitoringTarget> {
|
|
216
|
+
return this.request<MonitoringTarget>('POST', '/v1/monitoring/targets', {
|
|
217
|
+
projectId: input.projectId,
|
|
218
|
+
chainKey: input.chain,
|
|
219
|
+
address: input.address,
|
|
220
|
+
targetType,
|
|
221
|
+
...(input.label ? { label: input.label } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
'content-type': 'application/json',
|
|
234
|
+
'x-api-key': this.apiKey,
|
|
235
|
+
'user-agent': `decentrys-sentinel/${SDK_VERSION}`,
|
|
236
|
+
},
|
|
237
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
238
|
+
signal: controller.signal,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const text = await response.text();
|
|
242
|
+
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
const parsed = safeParse(text);
|
|
245
|
+
throw new SentinelError(
|
|
246
|
+
response.status,
|
|
247
|
+
typeof parsed?.message === 'string' ? parsed.message : `Decentrys returned HTTP ${response.status}.`,
|
|
248
|
+
typeof parsed?.code === 'string' ? parsed.code : undefined,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!text) return undefined as T;
|
|
253
|
+
|
|
254
|
+
const parsed = safeParse(text);
|
|
255
|
+
if (parsed === null) throw new SentinelError(response.status, 'The response was not valid JSON.');
|
|
256
|
+
|
|
257
|
+
// The platform wraps successful bodies as `{ data: ... }`, except for
|
|
258
|
+
// paginated responses which carry `data` alongside pagination fields.
|
|
259
|
+
return (('data' in parsed && !('total' in parsed) && !('page' in parsed))
|
|
260
|
+
? parsed.data
|
|
261
|
+
: parsed) as T;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (error instanceof SentinelError) throw error;
|
|
264
|
+
if (controller.signal.aborted) {
|
|
265
|
+
throw new SentinelError(0, `No response within ${this.timeoutMs}ms.`);
|
|
266
|
+
}
|
|
267
|
+
throw new SentinelError(0, error instanceof Error ? error.message : 'Request failed.');
|
|
268
|
+
} finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function safeParse(text: string): Record<string, unknown> | null {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(text) as unknown;
|
|
277
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveFetch(): FetchLike {
|
|
284
|
+
const candidate = (globalThis as { fetch?: unknown }).fetch;
|
|
285
|
+
if (typeof candidate !== 'function') {
|
|
286
|
+
throw new Error('Sentinel: no global fetch was found. Pass one via `new Sentinel({ fetch })`.');
|
|
287
|
+
}
|
|
288
|
+
return candidate.bind(globalThis) as FetchLike;
|
|
289
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { rulesFromAudit, unmappedCapabilities } from './from-audit';
|
|
3
|
+
|
|
4
|
+
const PROJECT = 'proj-1';
|
|
5
|
+
|
|
6
|
+
describe('audit handover', () => {
|
|
7
|
+
it('turns an upgrade capability into a rule that watches the upgrade, not the capability', () => {
|
|
8
|
+
const [generated] = rulesFromAudit({
|
|
9
|
+
projectId: PROJECT,
|
|
10
|
+
capabilities: [{ type: 'UPGRADEABLE', grantedBy: 'EIP1967' }],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(generated!.rule.triggerType).toBe('contract.upgraded');
|
|
14
|
+
expect(generated!.rule.severity).toBe('CRITICAL');
|
|
15
|
+
expect(generated!.rule.description).toContain('EIP1967');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The distinction the whole product rests on. Being upgradeable is a design
|
|
20
|
+
* choice most serious protocols make; the rule must describe the event, not
|
|
21
|
+
* accuse the contract of having the capability.
|
|
22
|
+
*/
|
|
23
|
+
it('never describes a capability as a fault', () => {
|
|
24
|
+
const generated = rulesFromAudit({
|
|
25
|
+
projectId: PROJECT,
|
|
26
|
+
capabilities: [
|
|
27
|
+
{ type: 'UPGRADEABLE' }, { type: 'MINT_AUTHORITY' }, { type: 'PAUSABLE' },
|
|
28
|
+
{ type: 'BLACKLIST' }, { type: 'GOVERNANCE' },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
for (const { rule } of generated) {
|
|
33
|
+
const text = `${rule.name} ${rule.description}`;
|
|
34
|
+
expect(text, rule.name).not.toMatch(/scam|malicious|fraud|dangerous|vulnerab/i);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A proxy and an upgrade authority are one capability described two ways.
|
|
40
|
+
* Two identical rules means two pages for one event, which is how a team
|
|
41
|
+
* learns to mute the channel.
|
|
42
|
+
*/
|
|
43
|
+
it('deduplicates capabilities that mean the same thing', () => {
|
|
44
|
+
const generated = rulesFromAudit({
|
|
45
|
+
projectId: PROJECT,
|
|
46
|
+
capabilities: [{ type: 'PROXY' }, { type: 'UPGRADEABLE' }, { type: 'UPGRADE_AUTHORITY' }],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(generated).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The alias table must be written against the canonical names the Protect
|
|
54
|
+
* layer actually emits, not against chain vocabulary. It was not, and this
|
|
55
|
+
* test is why: `TreasuryCap` never reaches here — Sui's capability is
|
|
56
|
+
* already normalised to MINT_AUTHORITY upstream.
|
|
57
|
+
*/
|
|
58
|
+
it.each([
|
|
59
|
+
'UPGRADEABLE', 'DELEGATED_EXECUTION', 'MINT_AUTHORITY', 'PAUSABLE',
|
|
60
|
+
'ACCOUNT_FREEZE', 'FORCED_BALANCE_CHANGE',
|
|
61
|
+
])('produces a rule for the canonical capability %s', (type) => {
|
|
62
|
+
expect(rulesFromAudit({ projectId: PROJECT, capabilities: [{ type }] })).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts a reviewer\'s prose wording as well as the canonical name', () => {
|
|
66
|
+
const prose = rulesFromAudit({ projectId: PROJECT, capabilities: [{ type: 'mint' }] });
|
|
67
|
+
const canonical = rulesFromAudit({ projectId: PROJECT, capabilities: [{ type: 'MINT_AUTHORITY' }] });
|
|
68
|
+
|
|
69
|
+
expect(prose[0]!.rule.triggerType).toBe(canonical[0]!.rule.triggerType);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A threshold guessed on a customer's behalf is either so low it pages
|
|
74
|
+
* constantly or so high it never fires. Both end with the alert ignored.
|
|
75
|
+
*/
|
|
76
|
+
it('generates no treasury rule without an agreed threshold', () => {
|
|
77
|
+
const without = rulesFromAudit({ projectId: PROJECT, capabilities: [] });
|
|
78
|
+
expect(without).toHaveLength(0);
|
|
79
|
+
|
|
80
|
+
const withThreshold = rulesFromAudit({
|
|
81
|
+
projectId: PROJECT, capabilities: [], treasuryThresholdUsd: 50_000,
|
|
82
|
+
});
|
|
83
|
+
expect(withThreshold).toHaveLength(1);
|
|
84
|
+
expect(JSON.stringify(withThreshold[0]!.rule.conditions)).toContain('50000');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('gives every rule a cooldown, so one looping event cannot page repeatedly', () => {
|
|
88
|
+
const generated = rulesFromAudit({
|
|
89
|
+
projectId: PROJECT,
|
|
90
|
+
capabilities: [{ type: 'UPGRADEABLE' }, { type: 'MINT_AUTHORITY' }],
|
|
91
|
+
treasuryThresholdUsd: 10_000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
for (const { rule } of generated) {
|
|
95
|
+
expect(rule.cooldownSeconds, rule.name).toBeGreaterThan(0);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('defaults to raising an in-platform alert rather than nothing', () => {
|
|
100
|
+
const [generated] = rulesFromAudit({ projectId: PROJECT, capabilities: [{ type: 'OWNERSHIP' }] });
|
|
101
|
+
expect(generated!.rule.actions).toEqual([{ type: 'ALERT', config: {} }]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('honours the caller\'s own routing', () => {
|
|
105
|
+
const actions = [{ type: 'PAGERDUTY', config: { routingKey: 'k' } }];
|
|
106
|
+
const [generated] = rulesFromAudit({
|
|
107
|
+
projectId: PROJECT, capabilities: [{ type: 'UPGRADEABLE' }], actions,
|
|
108
|
+
});
|
|
109
|
+
expect(generated!.rule.actions).toEqual(actions);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('unmappedCapabilities', () => {
|
|
114
|
+
/**
|
|
115
|
+
* A customer handed six rules for nine capabilities must be told which
|
|
116
|
+
* three are unwatched. Silence there becomes an assumption of coverage.
|
|
117
|
+
*/
|
|
118
|
+
it('reports what produced no rule instead of dropping it', () => {
|
|
119
|
+
const capabilities = [{ type: 'UPGRADEABLE' }, { type: 'ORACLE_SETTER' }, { type: 'FEE_CONTROL' }];
|
|
120
|
+
// FEE_CONTROL is real and deliberately unmapped: a fee change has no
|
|
121
|
+
// single response, and a rule nobody can act on trains people to ignore
|
|
122
|
+
// the channel that matters.
|
|
123
|
+
|
|
124
|
+
expect(rulesFromAudit({ projectId: PROJECT, capabilities })).toHaveLength(1);
|
|
125
|
+
expect(unmappedCapabilities(capabilities)).toEqual(['ORACLE_SETTER', 'FEE_CONTROL']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is empty when everything mapped', () => {
|
|
129
|
+
expect(unmappedCapabilities([{ type: 'UPGRADEABLE' }, { type: 'PAUSABLE' }])).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|