altd 0.0.4 → 0.0.5

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/dist/altd.js CHANGED
@@ -1,132 +1,207 @@
1
- import {spawn} from 'child_process';
2
- import Tail from 'nodejs-tail';
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import Tail from "nodejs-tail";
3
+
3
4
  /**
4
- ** main class of AccessLogTailDispatcher
5
+ * Safe, modern dispatcher:
6
+ * - command is mapped to an absolute executable path (not user-provided)
7
+ * - args are validated per-command
8
+ * - rate limited
9
+ * - process is sandboxed-ish (no shell, timeout, limited output)
5
10
  */
6
11
  export default class AccessLogTailDispatcher {
7
- /**
8
- * @constructor
9
- * @param {string} file access_log
10
- * @param {Array} whitelist ['command1','command2'..]
11
- */
12
- constructor(file, whitelist) {
13
- this.file = file;
14
- this.whitelist = whitelist;
15
- this.spawn = undefined;
16
- this.tail = undefined;
17
- }
18
- /**
19
- * Get the path from 'GET /path/to/dir HTTP'
20
- * @param {string} line access_log
21
- * @return {string} /path/to/dir
22
- */
23
- path(line) {
24
- if (!(typeof line === 'string')) {
25
- return '';
26
- }
27
- let match = line.match(
28
- /GET\s((\/[a-z0-9-._~%!$&'()*+,;=:@?]+)+\/?)\sHTTP/i);
29
- if (null !== match && match.length > 2) {
30
- return match[1];
31
- }
32
- return '';
33
- }
12
+ /**
13
+ * @param {string} file access_log
14
+ * @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
15
+ * @param {object} [opts]
16
+ */
17
+ constructor(file, commandRegistry, opts = {}) {
18
+ this.file = file;
19
+ this.registry = commandRegistry;
20
+
21
+ this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
22
+ this.tail = opts.tail
23
+ ?? new Tail(file, {
24
+ alwaysStat: true,
25
+ ignoreInitial: true,
26
+ persistent: true,
27
+ });
28
+
29
+ // Simple rate limit: max N executions per windowMs
30
+ this.windowMs = opts.windowMs ?? 1000;
31
+ this.maxPerWindow = opts.maxPerWindow ?? 5;
32
+ this._windowStart = Date.now();
33
+ this._countInWindow = 0;
34
+
35
+ // Process limits
36
+ this.timeoutMs = opts.timeoutMs ?? 10_000;
37
+ this.maxStdoutBytes = opts.maxStdoutBytes ?? 64 * 1024;
38
+ }
39
+
40
+ /**
41
+ * Extract request path from a typical access log line.
42
+ * More robust: parse "METHOD <url> HTTP/..."
43
+ * @param {string} line
44
+ * @returns {string} pathname like "/a/b"
45
+ */
46
+ extractPath(line) {
47
+ if (typeof line !== "string" || line.length > 10_000) return "";
34
48
 
35
- /**
36
- * Extract command and args
37
- * @param {string} path
38
- * @return {Array} [command,arg1,arg2...]
39
- */
40
- commandWithArgs(path) {
41
- if (!(typeof path === 'string')) {
42
- return [];
43
- }
44
- let commands = path.split(/\//).map(function(element, index, array) {
45
- let ret = "";
46
- try{
47
- ret = decodeURIComponent(element);
48
- }catch(e){
49
- console.error(e);
50
- }
51
- return ret;
52
- });
53
- commands.shift();
54
- return commands;
49
+ // Find something like: GET /foo/bar HTTP/1.1
50
+ const m = line.match(
51
+ /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
52
+ );
53
+ if (!m) return "";
54
+
55
+ const rawTarget = m[2];
56
+
57
+ // rawTarget can be absolute URL or origin-form. Normalize via URL.
58
+ // If it's origin-form "/x", give it a dummy base.
59
+ let url;
60
+ try {
61
+ url = rawTarget.startsWith("/")
62
+ ? new URL(rawTarget, "http://localhost")
63
+ : new URL(rawTarget);
64
+ } catch {
65
+ return "";
55
66
  }
56
67
 
57
- /**
58
- * Filter by whitelist
59
- * @param {Array} commandWithArgs [command,arg1,arg2...]
60
- * @param {Array} whitelist ['command1','command2'...]
61
- * @return {Array} filtered commandWithArgs
62
- */
63
- filterByWhitelist(commandWithArgs, whitelist) {
64
- if (!this.isArray(commandWithArgs) ||
65
- !this.isArray(whitelist) ||
66
- commandWithArgs.length == 0 ||
67
- whitelist.indexOf(commandWithArgs[0]) == -1
68
- ) {
69
- return [];
70
- }
71
- return commandWithArgs;
68
+ // Only use pathname; ignore query/hash to reduce attack surface.
69
+ return url.pathname || "";
70
+ }
71
+
72
+ /**
73
+ * "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
74
+ * @param {string} pathname
75
+ * @returns {string[]}
76
+ */
77
+ parseCommand(pathname) {
78
+ if (typeof pathname !== "string" || pathname === "" || pathname.length > 2048) {
79
+ return [];
72
80
  }
81
+ if (!pathname.startsWith("/")) return [];
73
82
 
74
- /**
75
- * Dispatch
76
- * @param {Array} commandWithArgs [command,arg1,arg2...]
77
- */
78
- dispatch(commandWithArgs) {
79
- if (commandWithArgs.length == 0) {
80
- return;
81
- }
82
- let command = commandWithArgs.shift();
83
- let proc = this.spawn(command, commandWithArgs);
84
- proc.on('error', (err) => {
85
- console.error(err);
86
- });
87
- proc.stdout.on('data', (data) => {
88
- process.stdout.write(data.toString());
89
- });
83
+ const parts = pathname.split("/").filter(Boolean);
84
+ if (parts.length === 0) return [];
85
+
86
+ // Safe decode each segment; if decode fails, reject the whole request.
87
+ const decoded = [];
88
+ for (const p of parts) {
89
+ if (p.length > 256) return [];
90
+ try {
91
+ decoded.push(decodeURIComponent(p));
92
+ } catch {
93
+ return [];
94
+ }
90
95
  }
96
+ return decoded;
97
+ }
91
98
 
92
- /**
93
- * isArray
94
- * @param {object} obj [command,arg1,arg2...]
95
- * @return {boolean}
96
- */
97
- isArray(obj) {
98
- return Object.prototype.toString.call(obj) === '[object Array]';
99
+ /**
100
+ * Basic rate limit to avoid log-triggered fork bombs.
101
+ * @returns {boolean} allowed
102
+ */
103
+ allowByRateLimit() {
104
+ const now = Date.now();
105
+ if (now - this._windowStart >= this.windowMs) {
106
+ this._windowStart = now;
107
+ this._countInWindow = 0;
99
108
  }
109
+ this._countInWindow += 1;
110
+ return this._countInWindow <= this.maxPerWindow;
111
+ }
112
+
113
+ /**
114
+ * Validate + build exec + args using registry
115
+ * @param {string[]} parsed ["cmd", ...rawArgs]
116
+ * @returns {{execPath:string,args:string[]}|null}
117
+ */
118
+ resolveExecution(parsed) {
119
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
120
+
121
+ const [cmd, ...rawArgs] = parsed;
100
122
 
101
- /**
102
- * run
103
- * @param {string} file
104
- * @param {Array} whitelist ['command1','command2'...]
105
- */
106
- run(file, whitelist) {
107
- if ( typeof file !== 'undefined') {
108
- this.file = file;
109
- }
110
- if ( typeof whitelist !== 'undefined') {
111
- this.whitelist = whitelist;
112
- }
113
- if ( typeof this.spawn === 'undefined') {
114
- this.spawn = spawn;
115
- }
116
- if ( typeof this.tail === 'undefined') {
117
- this.tail = new Tail(this.file,
118
- {alwaysStat: true, ignoreInitial: true, persistent: true});
119
- }
120
- this.tail.on('line', (line) => {
121
- this.dispatch(
122
- this.filterByWhitelist(
123
- this.commandWithArgs(
124
- this.path(line)),
125
- this.whitelist));
126
- });
127
- this.tail.on('close', () => {
128
- console.log('watching stopped');
129
- });
130
- this.tail.watch();
123
+ const entry = this.registry[cmd];
124
+ if (!entry) return null;
125
+
126
+ // Build args via per-command validator
127
+ let args;
128
+ try {
129
+ args = entry.buildArgs(rawArgs);
130
+ } catch {
131
+ return null;
131
132
  }
133
+ if (!Array.isArray(args)) return null;
134
+
135
+ return { execPath: entry.execPath, args };
136
+ }
137
+
138
+ /**
139
+ * Spawn with limits
140
+ * @param {string} execPath
141
+ * @param {string[]} args
142
+ */
143
+ spawnLimited(execPath, args) {
144
+ const proc = this.spawnImpl(execPath, args, {
145
+ shell: false,
146
+ windowsHide: true,
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ env: {
149
+ // Minimal env is often safer; adjust as needed
150
+ PATH: process.env.PATH ?? "",
151
+ },
152
+ });
153
+
154
+ // Timeout
155
+ const t = setTimeout(() => {
156
+ proc.kill("SIGKILL");
157
+ }, this.timeoutMs);
158
+ proc.on("exit", () => clearTimeout(t));
159
+
160
+ // Output limiting
161
+ let outBytes = 0;
162
+ proc.stdout.on("data", (buf) => {
163
+ outBytes += buf.length;
164
+ if (outBytes > this.maxStdoutBytes) {
165
+ proc.kill("SIGKILL");
166
+ return;
167
+ }
168
+ process.stdout.write(buf);
169
+ });
170
+
171
+ proc.stderr.on("data", (buf) => {
172
+ process.stderr.write(buf);
173
+ });
174
+
175
+ proc.on("error", (err) => {
176
+ console.error("[spawn error]", err);
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Start watching
182
+ */
183
+ run() {
184
+ this.tail.on("line", (line) => {
185
+ if (!this.allowByRateLimit()) return;
186
+
187
+ const pathname = this.extractPath(line);
188
+ const parsed = this.parseCommand(pathname);
189
+ const exec = this.resolveExecution(parsed);
190
+ if (!exec) return;
191
+
192
+ this.spawnLimited(exec.execPath, exec.args);
193
+ });
194
+
195
+ this.tail.on("close", () => {
196
+ console.log("watching stopped");
197
+ });
198
+
199
+ this.tail.watch();
200
+ }
201
+
202
+ stop() {
203
+ try {
204
+ this.tail.unwatch?.();
205
+ } catch {}
206
+ }
132
207
  }
package/dist/index.js CHANGED
@@ -1,10 +1,66 @@
1
1
  #!/usr/bin/env node
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
2
5
  import { Command } from 'commander';
3
- import pkg from '../package.json' assert { type: 'json' };
4
6
  import AccessLogTailDispatcher from './altd.js';
5
7
 
8
+ const require = createRequire(import.meta.url);
9
+ const pkg = require('../package.json');
10
+
6
11
  const program = new Command();
7
12
 
13
+ const resolveExecPath = (command) => {
14
+ if (!command || typeof command !== 'string') return null;
15
+ if (isAbsolute(command)) {
16
+ try {
17
+ accessSync(command, constants.X_OK);
18
+ return command;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ const searchPaths = (process.env.PATH ?? '').split(delimiter);
25
+ for (const dir of searchPaths) {
26
+ if (!dir) continue;
27
+ const candidate = join(dir, command);
28
+ try {
29
+ accessSync(candidate, constants.X_OK);
30
+ return candidate;
31
+ } catch {
32
+ // continue
33
+ }
34
+ }
35
+ return null;
36
+ };
37
+
38
+ const buildRegistry = (whitelist) => {
39
+ const registry = {};
40
+ for (const command of whitelist) {
41
+ const execPath = resolveExecPath(command);
42
+ if (!execPath) {
43
+ console.warn(`[altd] skip command: ${command}`);
44
+ continue;
45
+ }
46
+ registry[command] = {
47
+ execPath,
48
+ buildArgs: (rawArgs) => {
49
+ if (!Array.isArray(rawArgs) || rawArgs.length > 20) {
50
+ throw new Error('invalid args');
51
+ }
52
+ for (const arg of rawArgs) {
53
+ if (typeof arg !== 'string' || arg.length > 256) {
54
+ throw new Error('invalid args');
55
+ }
56
+ }
57
+ return rawArgs;
58
+ },
59
+ };
60
+ }
61
+ return registry;
62
+ };
63
+
8
64
  program
9
65
  .name('altd')
10
66
  .version(pkg.version)
@@ -13,17 +69,23 @@ program
13
69
  .option(
14
70
  '-w, --whitelist <commands>',
15
71
  'Add commands to whitelist',
16
- (commands) => commands.split(',')
72
+ (commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
17
73
  )
18
74
  .parse(process.argv);
19
75
 
20
76
  const fileValue = program.args[0];
21
77
  const { whitelist } = program.opts();
22
78
 
23
- if (!fileValue || !whitelist) {
79
+ if (!fileValue || !whitelist || whitelist.length === 0) {
24
80
  console.log('altd <file> -w <commands...>');
25
81
  process.exit(1);
26
82
  }
27
83
 
28
- const altd = new AccessLogTailDispatcher(fileValue, whitelist);
84
+ const registry = buildRegistry(whitelist);
85
+ if (Object.keys(registry).length === 0) {
86
+ console.error('[altd] no valid commands to run');
87
+ process.exit(1);
88
+ }
89
+
90
+ const altd = new AccessLogTailDispatcher(fileValue, registry);
29
91
  altd.run();
package/index.js CHANGED
@@ -1,10 +1,66 @@
1
1
  #!/usr/bin/env node
2
+ import { accessSync, constants } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
2
5
  import { Command } from 'commander';
3
- import pkg from './package.json' assert { type: 'json' };
4
6
  import AccessLogTailDispatcher from './src/altd.js';
5
7
 
8
+ const require = createRequire(import.meta.url);
9
+ const pkg = require('./package.json');
10
+
6
11
  const program = new Command();
7
12
 
13
+ const resolveExecPath = (command) => {
14
+ if (!command || typeof command !== 'string') return null;
15
+ if (isAbsolute(command)) {
16
+ try {
17
+ accessSync(command, constants.X_OK);
18
+ return command;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ const searchPaths = (process.env.PATH ?? '').split(delimiter);
25
+ for (const dir of searchPaths) {
26
+ if (!dir) continue;
27
+ const candidate = join(dir, command);
28
+ try {
29
+ accessSync(candidate, constants.X_OK);
30
+ return candidate;
31
+ } catch {
32
+ // continue
33
+ }
34
+ }
35
+ return null;
36
+ };
37
+
38
+ const buildRegistry = (whitelist) => {
39
+ const registry = {};
40
+ for (const command of whitelist) {
41
+ const execPath = resolveExecPath(command);
42
+ if (!execPath) {
43
+ console.warn(`[altd] skip command: ${command}`);
44
+ continue;
45
+ }
46
+ registry[command] = {
47
+ execPath,
48
+ buildArgs: (rawArgs) => {
49
+ if (!Array.isArray(rawArgs) || rawArgs.length > 20) {
50
+ throw new Error('invalid args');
51
+ }
52
+ for (const arg of rawArgs) {
53
+ if (typeof arg !== 'string' || arg.length > 256) {
54
+ throw new Error('invalid args');
55
+ }
56
+ }
57
+ return rawArgs;
58
+ },
59
+ };
60
+ }
61
+ return registry;
62
+ };
63
+
8
64
  program
9
65
  .name('altd')
10
66
  .version(pkg.version)
@@ -13,17 +69,23 @@ program
13
69
  .option(
14
70
  '-w, --whitelist <commands>',
15
71
  'Add commands to whitelist',
16
- (commands) => commands.split(',')
72
+ (commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
17
73
  )
18
74
  .parse(process.argv);
19
75
 
20
76
  const fileValue = program.args[0];
21
77
  const { whitelist } = program.opts();
22
78
 
23
- if (!fileValue || !whitelist) {
79
+ if (!fileValue || !whitelist || whitelist.length === 0) {
24
80
  console.log('altd <file> -w <commands...>');
25
81
  process.exit(1);
26
82
  }
27
83
 
28
- const altd = new AccessLogTailDispatcher(fileValue, whitelist);
84
+ const registry = buildRegistry(whitelist);
85
+ if (Object.keys(registry).length === 0) {
86
+ console.error('[altd] no valid commands to run');
87
+ process.exit(1);
88
+ }
89
+
90
+ const altd = new AccessLogTailDispatcher(fileValue, registry);
29
91
  altd.run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altd",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Access log tail dispatcher",
5
5
  "type": "module",
6
6
  "bin": {
package/src/altd.js CHANGED
@@ -1,132 +1,207 @@
1
- import {spawn} from 'child_process';
2
- import Tail from 'nodejs-tail';
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import Tail from "nodejs-tail";
3
+
3
4
  /**
4
- ** main class of AccessLogTailDispatcher
5
+ * Safe, modern dispatcher:
6
+ * - command is mapped to an absolute executable path (not user-provided)
7
+ * - args are validated per-command
8
+ * - rate limited
9
+ * - process is sandboxed-ish (no shell, timeout, limited output)
5
10
  */
6
11
  export default class AccessLogTailDispatcher {
7
- /**
8
- * @constructor
9
- * @param {string} file access_log
10
- * @param {Array} whitelist ['command1','command2'..]
11
- */
12
- constructor(file, whitelist) {
13
- this.file = file;
14
- this.whitelist = whitelist;
15
- this.spawn = undefined;
16
- this.tail = undefined;
17
- }
18
- /**
19
- * Get the path from 'GET /path/to/dir HTTP'
20
- * @param {string} line access_log
21
- * @return {string} /path/to/dir
22
- */
23
- path(line) {
24
- if (!(typeof line === 'string')) {
25
- return '';
26
- }
27
- let match = line.match(
28
- /GET\s((\/[a-z0-9-._~%!$&'()*+,;=:@?]+)+\/?)\sHTTP/i);
29
- if (null !== match && match.length > 2) {
30
- return match[1];
31
- }
32
- return '';
33
- }
12
+ /**
13
+ * @param {string} file access_log
14
+ * @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
15
+ * @param {object} [opts]
16
+ */
17
+ constructor(file, commandRegistry, opts = {}) {
18
+ this.file = file;
19
+ this.registry = commandRegistry;
20
+
21
+ this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
22
+ this.tail = opts.tail
23
+ ?? new Tail(file, {
24
+ alwaysStat: true,
25
+ ignoreInitial: true,
26
+ persistent: true,
27
+ });
28
+
29
+ // Simple rate limit: max N executions per windowMs
30
+ this.windowMs = opts.windowMs ?? 1000;
31
+ this.maxPerWindow = opts.maxPerWindow ?? 5;
32
+ this._windowStart = Date.now();
33
+ this._countInWindow = 0;
34
+
35
+ // Process limits
36
+ this.timeoutMs = opts.timeoutMs ?? 10_000;
37
+ this.maxStdoutBytes = opts.maxStdoutBytes ?? 64 * 1024;
38
+ }
39
+
40
+ /**
41
+ * Extract request path from a typical access log line.
42
+ * More robust: parse "METHOD <url> HTTP/..."
43
+ * @param {string} line
44
+ * @returns {string} pathname like "/a/b"
45
+ */
46
+ extractPath(line) {
47
+ if (typeof line !== "string" || line.length > 10_000) return "";
34
48
 
35
- /**
36
- * Extract command and args
37
- * @param {string} path
38
- * @return {Array} [command,arg1,arg2...]
39
- */
40
- commandWithArgs(path) {
41
- if (!(typeof path === 'string')) {
42
- return [];
43
- }
44
- let commands = path.split(/\//).map(function(element, index, array) {
45
- let ret = "";
46
- try{
47
- ret = decodeURIComponent(element);
48
- }catch(e){
49
- console.error(e);
50
- }
51
- return ret;
52
- });
53
- commands.shift();
54
- return commands;
49
+ // Find something like: GET /foo/bar HTTP/1.1
50
+ const m = line.match(
51
+ /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
52
+ );
53
+ if (!m) return "";
54
+
55
+ const rawTarget = m[2];
56
+
57
+ // rawTarget can be absolute URL or origin-form. Normalize via URL.
58
+ // If it's origin-form "/x", give it a dummy base.
59
+ let url;
60
+ try {
61
+ url = rawTarget.startsWith("/")
62
+ ? new URL(rawTarget, "http://localhost")
63
+ : new URL(rawTarget);
64
+ } catch {
65
+ return "";
55
66
  }
56
67
 
57
- /**
58
- * Filter by whitelist
59
- * @param {Array} commandWithArgs [command,arg1,arg2...]
60
- * @param {Array} whitelist ['command1','command2'...]
61
- * @return {Array} filtered commandWithArgs
62
- */
63
- filterByWhitelist(commandWithArgs, whitelist) {
64
- if (!this.isArray(commandWithArgs) ||
65
- !this.isArray(whitelist) ||
66
- commandWithArgs.length == 0 ||
67
- whitelist.indexOf(commandWithArgs[0]) == -1
68
- ) {
69
- return [];
70
- }
71
- return commandWithArgs;
68
+ // Only use pathname; ignore query/hash to reduce attack surface.
69
+ return url.pathname || "";
70
+ }
71
+
72
+ /**
73
+ * "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
74
+ * @param {string} pathname
75
+ * @returns {string[]}
76
+ */
77
+ parseCommand(pathname) {
78
+ if (typeof pathname !== "string" || pathname === "" || pathname.length > 2048) {
79
+ return [];
72
80
  }
81
+ if (!pathname.startsWith("/")) return [];
73
82
 
74
- /**
75
- * Dispatch
76
- * @param {Array} commandWithArgs [command,arg1,arg2...]
77
- */
78
- dispatch(commandWithArgs) {
79
- if (commandWithArgs.length == 0) {
80
- return;
81
- }
82
- let command = commandWithArgs.shift();
83
- let proc = this.spawn(command, commandWithArgs);
84
- proc.on('error', (err) => {
85
- console.error(err);
86
- });
87
- proc.stdout.on('data', (data) => {
88
- process.stdout.write(data.toString());
89
- });
83
+ const parts = pathname.split("/").filter(Boolean);
84
+ if (parts.length === 0) return [];
85
+
86
+ // Safe decode each segment; if decode fails, reject the whole request.
87
+ const decoded = [];
88
+ for (const p of parts) {
89
+ if (p.length > 256) return [];
90
+ try {
91
+ decoded.push(decodeURIComponent(p));
92
+ } catch {
93
+ return [];
94
+ }
90
95
  }
96
+ return decoded;
97
+ }
91
98
 
92
- /**
93
- * isArray
94
- * @param {object} obj [command,arg1,arg2...]
95
- * @return {boolean}
96
- */
97
- isArray(obj) {
98
- return Object.prototype.toString.call(obj) === '[object Array]';
99
+ /**
100
+ * Basic rate limit to avoid log-triggered fork bombs.
101
+ * @returns {boolean} allowed
102
+ */
103
+ allowByRateLimit() {
104
+ const now = Date.now();
105
+ if (now - this._windowStart >= this.windowMs) {
106
+ this._windowStart = now;
107
+ this._countInWindow = 0;
99
108
  }
109
+ this._countInWindow += 1;
110
+ return this._countInWindow <= this.maxPerWindow;
111
+ }
112
+
113
+ /**
114
+ * Validate + build exec + args using registry
115
+ * @param {string[]} parsed ["cmd", ...rawArgs]
116
+ * @returns {{execPath:string,args:string[]}|null}
117
+ */
118
+ resolveExecution(parsed) {
119
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
120
+
121
+ const [cmd, ...rawArgs] = parsed;
100
122
 
101
- /**
102
- * run
103
- * @param {string} file
104
- * @param {Array} whitelist ['command1','command2'...]
105
- */
106
- run(file, whitelist) {
107
- if ( typeof file !== 'undefined') {
108
- this.file = file;
109
- }
110
- if ( typeof whitelist !== 'undefined') {
111
- this.whitelist = whitelist;
112
- }
113
- if ( typeof this.spawn === 'undefined') {
114
- this.spawn = spawn;
115
- }
116
- if ( typeof this.tail === 'undefined') {
117
- this.tail = new Tail(this.file,
118
- {alwaysStat: true, ignoreInitial: true, persistent: true});
119
- }
120
- this.tail.on('line', (line) => {
121
- this.dispatch(
122
- this.filterByWhitelist(
123
- this.commandWithArgs(
124
- this.path(line)),
125
- this.whitelist));
126
- });
127
- this.tail.on('close', () => {
128
- console.log('watching stopped');
129
- });
130
- this.tail.watch();
123
+ const entry = this.registry[cmd];
124
+ if (!entry) return null;
125
+
126
+ // Build args via per-command validator
127
+ let args;
128
+ try {
129
+ args = entry.buildArgs(rawArgs);
130
+ } catch {
131
+ return null;
131
132
  }
133
+ if (!Array.isArray(args)) return null;
134
+
135
+ return { execPath: entry.execPath, args };
136
+ }
137
+
138
+ /**
139
+ * Spawn with limits
140
+ * @param {string} execPath
141
+ * @param {string[]} args
142
+ */
143
+ spawnLimited(execPath, args) {
144
+ const proc = this.spawnImpl(execPath, args, {
145
+ shell: false,
146
+ windowsHide: true,
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ env: {
149
+ // Minimal env is often safer; adjust as needed
150
+ PATH: process.env.PATH ?? "",
151
+ },
152
+ });
153
+
154
+ // Timeout
155
+ const t = setTimeout(() => {
156
+ proc.kill("SIGKILL");
157
+ }, this.timeoutMs);
158
+ proc.on("exit", () => clearTimeout(t));
159
+
160
+ // Output limiting
161
+ let outBytes = 0;
162
+ proc.stdout.on("data", (buf) => {
163
+ outBytes += buf.length;
164
+ if (outBytes > this.maxStdoutBytes) {
165
+ proc.kill("SIGKILL");
166
+ return;
167
+ }
168
+ process.stdout.write(buf);
169
+ });
170
+
171
+ proc.stderr.on("data", (buf) => {
172
+ process.stderr.write(buf);
173
+ });
174
+
175
+ proc.on("error", (err) => {
176
+ console.error("[spawn error]", err);
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Start watching
182
+ */
183
+ run() {
184
+ this.tail.on("line", (line) => {
185
+ if (!this.allowByRateLimit()) return;
186
+
187
+ const pathname = this.extractPath(line);
188
+ const parsed = this.parseCommand(pathname);
189
+ const exec = this.resolveExecution(parsed);
190
+ if (!exec) return;
191
+
192
+ this.spawnLimited(exec.execPath, exec.args);
193
+ });
194
+
195
+ this.tail.on("close", () => {
196
+ console.log("watching stopped");
197
+ });
198
+
199
+ this.tail.watch();
200
+ }
201
+
202
+ stop() {
203
+ try {
204
+ this.tail.unwatch?.();
205
+ } catch {}
206
+ }
132
207
  }