spooder 3.0.8 → 3.2.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.
@@ -0,0 +1,223 @@
1
+ import { App } from '@octokit/app';
2
+ import { get_config } from './config';
3
+ import { warn, log } from './utils';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ async function load_local_env(): Promise<Map<string, string>> {
9
+ const env = new Map<string, string>();
10
+
11
+ const env_file = Bun.file(path.join(process.cwd(), '.env.local'));
12
+
13
+ if (env_file.size > 0) {
14
+ const env_text = await env_file.text();
15
+ const env_lines = env_text.split(/\r?\n/);
16
+
17
+ for (const line of env_lines) {
18
+ // Empty lines / comments
19
+ if (line.length === 0 || line.startsWith('#'))
20
+ continue;
21
+
22
+ const separator_index = line.indexOf('=');
23
+ if (separator_index === -1)
24
+ continue;
25
+
26
+ const key = line.slice(0, separator_index).trim();
27
+ let value = line.slice(separator_index + 1).trim();
28
+
29
+ // Strip quotes.
30
+ if (value.startsWith('"') && value.endsWith('"'))
31
+ value = value.slice(1, -1);
32
+ else if (value.startsWith("'") && value.endsWith("'"))
33
+ value = value.slice(1, -1);
34
+
35
+ env.set(key, value);
36
+ }
37
+ }
38
+
39
+ return env;
40
+ }
41
+
42
+ async function save_cache_table(table: Map<bigint, number>, cache_file_path: string): Promise<void> {
43
+ const data = Buffer.alloc(4 + (table.size * 12));
44
+
45
+ let offset = 4;
46
+ data.writeUInt32LE(table.size, 0);
47
+
48
+ for (const [key, value] of table.entries()) {
49
+ data.writeBigUint64LE(key, offset);
50
+ offset += 8;
51
+
52
+ data.writeUInt32LE(value, offset);
53
+ offset += 4;
54
+ }
55
+
56
+ await new Promise(resolve => fs.mkdir(path.dirname(cache_file_path), { recursive: true }, resolve));
57
+ await Bun.write(cache_file_path, data);
58
+ }
59
+
60
+ async function check_cache_table(key: string, repository: string, expiry: number): Promise<boolean> {
61
+ if (expiry === 0)
62
+ return false;
63
+
64
+ const [owner, repo] = repository.split('/');
65
+ const cache_file_path = path.join(os.tmpdir(), 'spooder_canary', owner, repo, 'cache.bin');
66
+
67
+ const cache_table = new Map<bigint, number>();
68
+ const key_hash = BigInt(Bun.hash.wyhash(key));
69
+
70
+ const time_now = Math.floor(Date.now() / 1000);
71
+ const expiry_threshold = time_now - expiry;
72
+
73
+ let changed = false;
74
+ try {
75
+ const cache_file = Bun.file(cache_file_path);
76
+
77
+ if (cache_file.size > 0) {
78
+ const data = Buffer.from(await cache_file.arrayBuffer());
79
+ const entry_count = data.readUInt32LE(0);
80
+
81
+ let offset = 4;
82
+ for (let i = 0; i < entry_count; i++) {
83
+ const hash = data.readBigUInt64LE(offset);
84
+ offset += 8;
85
+
86
+ const expiry = data.readUInt32LE(offset);
87
+ offset += 4;
88
+
89
+ if (expiry >= expiry_threshold)
90
+ cache_table.set(hash, expiry);
91
+ else
92
+ changed = true;
93
+ }
94
+ }
95
+ } catch (e) {
96
+ warn('Failed to read canary cache file ' + cache_file_path);
97
+ warn('Error: ' + (e as Error).message);
98
+ warn('You should resolve this issue to prevent spamming GitHub with canary reports.');
99
+ }
100
+
101
+ if (cache_table.has(key_hash)) {
102
+ if (changed)
103
+ await save_cache_table(cache_table, cache_file_path);
104
+
105
+ return true;
106
+ }
107
+
108
+ cache_table.set(key_hash, time_now);
109
+ await save_cache_table(cache_table, cache_file_path);
110
+
111
+ return false;
112
+ }
113
+
114
+ function sanitize_string(input: string, local_env?: Map<string, string>): string {
115
+ // Strip all potential e-mail addresses.
116
+ input = input.replaceAll(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/g, '[e-mail address]');
117
+
118
+ // Strip IPv4 addresses.
119
+ input = input.replaceAll(/([0-9]{1,3}\.){3}[0-9]{1,3}/g, '[IPv4 address]');
120
+
121
+ // Strip IPv6 addresses.
122
+ input = input.replaceAll(/([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}/g, '[IPv6 address]');
123
+
124
+ // Strip local environment variables.
125
+ if (local_env !== undefined) {
126
+ // Do not expose the name of the key redacted, as this may inadvertently expose the key/value
127
+ // if the value coincidentally appears in some other context.
128
+ for (const value of local_env.values())
129
+ input = input.replaceAll(value, '[redacted]');
130
+ }
131
+
132
+ return input;
133
+ }
134
+
135
+ function generate_diagnostics(): object {
136
+ return {
137
+ 'loadavg': os.loadavg(),
138
+ 'memory': {
139
+ 'free': os.freemem(),
140
+ 'total': os.totalmem(),
141
+ },
142
+ 'platform': os.platform(),
143
+ 'uptime': os.uptime(),
144
+ 'versions': process.versions,
145
+ }
146
+ }
147
+
148
+ export async function dispatch_report(report_title: string, report_body: Array<unknown>): Promise<void> {
149
+ try {
150
+ const config = await get_config();
151
+
152
+ const canary_account = config.canary.account.toLowerCase();
153
+ const canary_repostiory = config.canary.repository.toLowerCase();
154
+ const canary_labels = config.canary.labels;
155
+
156
+ if (canary_account.length === 0|| canary_repostiory.length === 0)
157
+ return;
158
+
159
+ const is_cached = await check_cache_table(report_title, canary_repostiory, config.canary.throttle);
160
+ if (is_cached) {
161
+ warn('Throttled canary report: ' + report_title);
162
+ return;
163
+ }
164
+
165
+ const canary_app_id = process.env.SPOODER_CANARY_APP_ID as string;
166
+ const canary_app_key = process.env.SPOODER_CANARY_KEY as string;
167
+
168
+ if (canary_app_id === undefined)
169
+ throw new Error('dispatch_report() called without SPOODER_CANARY_APP_ID environment variable set');
170
+
171
+ if (canary_app_key === undefined)
172
+ throw new Error('dispatch_report() called without SPOODER_CANARY_KEY environment variable set');
173
+
174
+ const key_file = Bun.file(canary_app_key);
175
+ if (key_file.size === 0)
176
+ throw new Error('dispatch_report() failed to read canary private key file');
177
+
178
+ const app_id = parseInt(canary_app_id, 10);
179
+ if (isNaN(app_id))
180
+ throw new Error('dispatch_report() failed to parse SPOODER_CANARY_APP_ID environment variable as integer');
181
+
182
+ const app = new App({
183
+ appId: app_id,
184
+ privateKey: await key_file.text(),
185
+ });
186
+
187
+ await app.octokit.request('GET /app');
188
+
189
+ const post_object = {
190
+ title: report_title,
191
+ body: '',
192
+ labels: canary_labels
193
+ };
194
+
195
+ report_body.push(generate_diagnostics());
196
+
197
+ if (config.canary.sanitize) {
198
+ const local_env = await load_local_env();
199
+ post_object.body = sanitize_string(JSON.stringify(report_body, null, 4), local_env);
200
+ post_object.title = sanitize_string(report_title, local_env);
201
+ } else {
202
+ post_object.body = JSON.stringify(report_body, null, 4);
203
+ }
204
+
205
+ post_object.body = '```json\n' + post_object.body + '\n```\n\nℹ️ *This issue has been created automatically in response to a server panic, caution or crash.*';
206
+
207
+ for await (const { installation } of app.eachInstallation.iterator()) {
208
+ const login = (installation?.account as { login: string })?.login;
209
+ if (login?.toLowerCase() !== canary_account)
210
+ continue;
211
+
212
+ for await (const { octokit, repository } of app.eachRepository.iterator({ installationId: installation.id })) {
213
+ if (repository.full_name.toLowerCase() !== canary_repostiory)
214
+ continue;
215
+
216
+ await octokit.request('POST /repos/' + canary_repostiory + '/issues', post_object);
217
+ log('Dispatched canary report to %s: %s', canary_repostiory, report_title);
218
+ }
219
+ }
220
+ } catch (e) {
221
+ warn('Failed to dispatch canary report: ' + (e as Error)?.message ?? 'unspecified error');
222
+ }
223
+ }
package/src/utils.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Logs a message to stdout with the prefix `[spooder] ` */
2
+ export declare function log(message: string, ...args: unknown[]): void;
3
+ /** Logs a message to stderr with the prefix `[spooder] ` */
4
+ export declare function warn(message: string, ...args: unknown[]): void;
5
+ /** Strips ANSI color codes from a string */
6
+ export declare function strip_color_codes(str: string): string;
7
+ /** Converts a command line string into an array of arguments */
8
+ export declare function parse_command_line(command: string): string[];
package/src/utils.ts ADDED
@@ -0,0 +1,55 @@
1
+ /** Logs a message to stdout with the prefix `[spooder] ` */
2
+ export function log(message: string, ...args: unknown[]): void {
3
+ console.log('[spooder] ' + message, ...args);
4
+ }
5
+
6
+ /** Logs a message to stderr with the prefix `[spooder] ` */
7
+ export function warn(message: string, ...args: unknown[]): void {
8
+ console.error('[spooder] ' + message, ...args);
9
+ }
10
+
11
+ /** Strips ANSI color codes from a string */
12
+ export function strip_color_codes(str: string): string {
13
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
14
+ }
15
+
16
+ /** Converts a command line string into an array of arguments */
17
+ export function parse_command_line(command: string): string[] {
18
+ const args = [];
19
+ let current_arg = '';
20
+ let in_quotes = false;
21
+ let in_escape = false;
22
+
23
+ for (let i = 0; i < command.length; i++) {
24
+ const char = command[i];
25
+
26
+ if (in_escape) {
27
+ current_arg += char;
28
+ in_escape = false;
29
+ continue;
30
+ }
31
+
32
+ if (char === '\\') {
33
+ in_escape = true;
34
+ continue;
35
+ }
36
+
37
+ if (char === '"') {
38
+ in_quotes = !in_quotes;
39
+ continue;
40
+ }
41
+
42
+ if (char === ' ' && !in_quotes) {
43
+ args.push(current_arg);
44
+ current_arg = '';
45
+ continue;
46
+ }
47
+
48
+ current_arg += char;
49
+ }
50
+
51
+ if (current_arg.length > 0)
52
+ args.push(current_arg);
53
+
54
+ return args;
55
+ }