oops-cli 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.
- checksums.yaml +7 -0
- data/CHANGELOG.ja.md +14 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.ja.md +236 -0
- data/README.md +236 -0
- data/exe/oops +5 -0
- data/lib/oops/ansi.rb +52 -0
- data/lib/oops/cli.rb +186 -0
- data/lib/oops/clipboard.rb +218 -0
- data/lib/oops/config.rb +72 -0
- data/lib/oops/doctor.rb +133 -0
- data/lib/oops/formatter.rb +115 -0
- data/lib/oops/marker.rb +88 -0
- data/lib/oops/paths.rb +28 -0
- data/lib/oops/prompt_sp.rb +74 -0
- data/lib/oops/record.rb +41 -0
- data/lib/oops/recorder.rb +416 -0
- data/lib/oops/secrets.rb +85 -0
- data/lib/oops/session_log.rb +213 -0
- data/lib/oops/shell/oops.bash +336 -0
- data/lib/oops/shell/oops.zsh +102 -0
- data/lib/oops/shell_init.rb +26 -0
- data/lib/oops/stream_parser.rb +347 -0
- data/lib/oops/tty.rb +19 -0
- data/lib/oops/version.rb +3 -0
- data/lib/oops.rb +17 -0
- metadata +71 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
require "pty"
|
|
2
|
+
require "io/console"
|
|
3
|
+
|
|
4
|
+
module Oops
|
|
5
|
+
# The pty relay behind `oops record`: runs the shell on a new pty, relays
|
|
6
|
+
# input and output between it and the user's terminal, strips the hook
|
|
7
|
+
# markers from the output and writes finished commands to the session log.
|
|
8
|
+
#
|
|
9
|
+
# Rule one is never to break the user's shell. Failing to set up the log
|
|
10
|
+
# only disables recording; any failure after the shell is running switches
|
|
11
|
+
# logging off and the relay goes on.
|
|
12
|
+
class Recorder
|
|
13
|
+
READ_SIZE = 65_536
|
|
14
|
+
# Input waiting for the shell beyond this stays in the kernel's tty
|
|
15
|
+
# buffer (it is not read) until the shell catches up.
|
|
16
|
+
MAX_PENDING_INPUT = 1_048_576
|
|
17
|
+
# Backstop for a missed SIGCHLD; normally the self-pipe wakes the loop.
|
|
18
|
+
POLL_INTERVAL = 1.0
|
|
19
|
+
# Pause after an unexpected error in the relay loop, so an error that
|
|
20
|
+
# repeats cannot spin the CPU.
|
|
21
|
+
ERROR_BACKOFF = 0.05
|
|
22
|
+
|
|
23
|
+
# The shell could not be started on the new pty.
|
|
24
|
+
class ChildStartError < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Feeds child output through the StreamParser and appends records to the
|
|
27
|
+
# log. Failures never lose output: a failing log is dropped, and if the
|
|
28
|
+
# parser itself fails, bytes are forwarded unparsed from then on.
|
|
29
|
+
#
|
|
30
|
+
# A pause marker counts only while the log's pause flag file exists (and
|
|
31
|
+
# a resume marker only while it does not): `oops pause` creates the file
|
|
32
|
+
# before it prints the marker, while program output that merely contains
|
|
33
|
+
# the marker cannot.
|
|
34
|
+
class OutputFilter
|
|
35
|
+
def initialize(log:, on_error:, max_bytes: 1_048_576)
|
|
36
|
+
@log = log
|
|
37
|
+
@on_error = on_error
|
|
38
|
+
@parser = StreamParser.new(max_bytes: max_bytes, pause_gate: pause_gate(log)) { |record| store(record) }
|
|
39
|
+
@broken = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the bytes to show on the terminal.
|
|
43
|
+
def feed(bytes)
|
|
44
|
+
return bytes.b if @broken
|
|
45
|
+
|
|
46
|
+
@parser.feed(bytes)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
parser_failed(e)
|
|
49
|
+
bytes.b
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns held-back bytes; an unfinished command is logged with exit nil.
|
|
53
|
+
def finish
|
|
54
|
+
return "".b if @broken
|
|
55
|
+
|
|
56
|
+
@parser.finish
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
parser_failed(e)
|
|
59
|
+
"".b
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def pause_gate(log)
|
|
65
|
+
return nil unless log
|
|
66
|
+
|
|
67
|
+
lambda do |kind|
|
|
68
|
+
paused = begin
|
|
69
|
+
log.paused?
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
kind == :pause ? paused : !paused
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def store(record)
|
|
78
|
+
return unless @log
|
|
79
|
+
|
|
80
|
+
@log.append(record)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
@log = nil
|
|
83
|
+
@on_error.call(e.message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parser_failed(error)
|
|
87
|
+
@broken = true
|
|
88
|
+
@log = nil
|
|
89
|
+
@on_error.call("stream parser failed: #{error.class}: #{error.message}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
94
|
+
@argv = argv.empty? ? [default_shell(env)] : argv
|
|
95
|
+
@env = env
|
|
96
|
+
@stdin = stdin
|
|
97
|
+
@stdout = stdout
|
|
98
|
+
@stderr = stderr
|
|
99
|
+
@raw = false
|
|
100
|
+
@log_failed = false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def run
|
|
104
|
+
tty = Tty.name(@stdin)
|
|
105
|
+
return exec_shell({}) if tty && @env["OOPS_TTY"] == tty
|
|
106
|
+
|
|
107
|
+
install_traps
|
|
108
|
+
log = open_session
|
|
109
|
+
master, pid = start_child
|
|
110
|
+
unless pid
|
|
111
|
+
discard_session
|
|
112
|
+
return exec_shell({ "OOPS_DISABLE" => "1" })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@master = master
|
|
116
|
+
@pid = pid
|
|
117
|
+
@filter = OutputFilter.new(log: log, on_error: method(:log_failed))
|
|
118
|
+
with_raw_mode { relay }
|
|
119
|
+
# Waited for with the terminal already restored: a shell that closed
|
|
120
|
+
# the pty but keeps running must not leave the terminal raw.
|
|
121
|
+
@status ||= Process.wait2(@pid)[1]
|
|
122
|
+
exit_code(@status)
|
|
123
|
+
ensure
|
|
124
|
+
restore_traps
|
|
125
|
+
@master.close if @master && !@master.closed?
|
|
126
|
+
@wake_r&.close
|
|
127
|
+
@wake_w&.close
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def default_shell(env)
|
|
133
|
+
shell = env["SHELL"].to_s
|
|
134
|
+
shell.empty? ? "/bin/sh" : shell
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Replaces this process with the shell. Returns only when exec fails.
|
|
138
|
+
def exec_shell(extra_env)
|
|
139
|
+
Kernel.exec(extra_env, [@argv[0], @argv[0]], *@argv[1..])
|
|
140
|
+
rescue SystemCallError => e
|
|
141
|
+
@stderr.puts "oops: cannot run #{@argv[0]}: #{e.message}"
|
|
142
|
+
127
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- session log ---
|
|
146
|
+
|
|
147
|
+
def open_session
|
|
148
|
+
dir = Paths.sessions_dir(env: @env)
|
|
149
|
+
@session_path = SessionLog.new_path(dir)
|
|
150
|
+
Paths.ensure_sessions_dir!(env: @env)
|
|
151
|
+
rotate_logs(dir)
|
|
152
|
+
File.open(@session_path, File::WRONLY | File::CREAT | File::APPEND, 0o600, &:itself)
|
|
153
|
+
write_status("ok")
|
|
154
|
+
SessionLog.new(@session_path)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
@log_failed = true
|
|
157
|
+
warn_line "oops: warning: session log unavailable (#{e.message}); recording disabled"
|
|
158
|
+
write_status("log-error: #{e.message}")
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The shell runs unrecorded, so an empty log would only mislead.
|
|
163
|
+
def discard_session
|
|
164
|
+
return unless @session_path
|
|
165
|
+
|
|
166
|
+
[@session_path, "#{@session_path}.status"].each do |f|
|
|
167
|
+
File.delete(f)
|
|
168
|
+
rescue SystemCallError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Rotation only frees space; failing to rotate must not stop recording.
|
|
174
|
+
def rotate_logs(dir)
|
|
175
|
+
SessionLog.rotate!(dir)
|
|
176
|
+
rescue SystemCallError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def write_status(text)
|
|
181
|
+
return unless @session_path && File.directory?(File.dirname(@session_path))
|
|
182
|
+
|
|
183
|
+
File.open("#{@session_path}.status", File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
184
|
+
f.write(text)
|
|
185
|
+
end
|
|
186
|
+
rescue SystemCallError, IOError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Called at most once: the first log failure switches logging off.
|
|
191
|
+
def log_failed(message)
|
|
192
|
+
return if @log_failed
|
|
193
|
+
|
|
194
|
+
@log_failed = true
|
|
195
|
+
warn_line "oops: warning: session log write failed (#{message}); recording disabled"
|
|
196
|
+
write_status("log-error: #{message}")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def warn_line(message)
|
|
200
|
+
@stderr.write(message, @raw ? "\r\n" : "\n")
|
|
201
|
+
@stderr.flush
|
|
202
|
+
rescue SystemCallError, IOError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# --- child process ---
|
|
207
|
+
|
|
208
|
+
# Returns [master, pid], or nil after printing why the pty or the shell
|
|
209
|
+
# could not be started.
|
|
210
|
+
def start_child
|
|
211
|
+
master, slave = PTY.open
|
|
212
|
+
copy_termios(slave)
|
|
213
|
+
copy_winsize(master)
|
|
214
|
+
[master, fork_child(slave.path)]
|
|
215
|
+
rescue SystemCallError, IOError, NotImplementedError, ChildStartError => e
|
|
216
|
+
master&.close
|
|
217
|
+
@stderr.puts "oops: recording disabled: #{e.message}"
|
|
218
|
+
nil
|
|
219
|
+
ensure
|
|
220
|
+
# Kept open until the child has opened the slave itself, so the pty is
|
|
221
|
+
# never without a slave in between (that would read as a hangup).
|
|
222
|
+
slave&.close
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Forks the shell as a session leader with the pty slave as its
|
|
226
|
+
# controlling terminal. A close-on-exec pipe reports whether exec
|
|
227
|
+
# succeeded: EOF means it did, otherwise it carries the error.
|
|
228
|
+
def fork_child(slave_path)
|
|
229
|
+
report_r, report_w = IO.pipe
|
|
230
|
+
child_env = { "OOPS_SESSION" => @session_path, "OOPS_TTY" => slave_path }
|
|
231
|
+
pid = fork do
|
|
232
|
+
report_r.close
|
|
233
|
+
begin
|
|
234
|
+
Process.setsid
|
|
235
|
+
File.open(slave_path, File::RDWR) do |tty|
|
|
236
|
+
Kernel.exec(child_env, [@argv[0], @argv[0]], *@argv[1..], in: tty, out: tty, err: tty)
|
|
237
|
+
end
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
report_w.write("cannot run #{@argv[0]}: #{e.message}")
|
|
240
|
+
ensure
|
|
241
|
+
exit!(127)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
report_w.close
|
|
245
|
+
error = report_r.read
|
|
246
|
+
return pid if error.empty?
|
|
247
|
+
|
|
248
|
+
Process.wait(pid)
|
|
249
|
+
raise ChildStartError, error
|
|
250
|
+
ensure
|
|
251
|
+
report_r&.close
|
|
252
|
+
report_w&.close unless report_w.nil? || report_w.closed?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The shell starts with the user's terminal settings (erase key, UTF-8
|
|
256
|
+
# input, ...), as script(1) does.
|
|
257
|
+
def copy_termios(slave)
|
|
258
|
+
return unless @stdin.respond_to?(:console_mode) && @stdin.tty?
|
|
259
|
+
|
|
260
|
+
slave.console_mode = @stdin.console_mode
|
|
261
|
+
rescue SystemCallError, IOError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def copy_winsize(master)
|
|
266
|
+
return unless @stdin.respond_to?(:winsize) && @stdin.tty?
|
|
267
|
+
|
|
268
|
+
rows, cols = @stdin.winsize
|
|
269
|
+
master.winsize = [rows, cols] if rows.positive? && cols.positive?
|
|
270
|
+
rescue SystemCallError, IOError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# --- signals ---
|
|
275
|
+
|
|
276
|
+
def install_traps
|
|
277
|
+
@wake_r, @wake_w = IO.pipe
|
|
278
|
+
wake = @wake_w
|
|
279
|
+
@old_traps = %w[WINCH CHLD].to_h do |sig|
|
|
280
|
+
[sig, trap(sig) { wake.write_nonblock(sig[0], exception: false) }]
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def restore_traps
|
|
285
|
+
@old_traps&.each { |sig, old| trap(sig, old || "DEFAULT") }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# --- terminal ---
|
|
289
|
+
|
|
290
|
+
def with_raw_mode
|
|
291
|
+
return yield unless @stdin.respond_to?(:raw!) && @stdin.tty?
|
|
292
|
+
|
|
293
|
+
saved = @stdin.console_mode
|
|
294
|
+
begin
|
|
295
|
+
@stdin.raw!
|
|
296
|
+
@raw = true
|
|
297
|
+
yield
|
|
298
|
+
ensure
|
|
299
|
+
@raw = false
|
|
300
|
+
begin
|
|
301
|
+
@stdin.console_mode = saved
|
|
302
|
+
rescue SystemCallError, IOError
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# --- relay loop ---
|
|
309
|
+
|
|
310
|
+
def relay
|
|
311
|
+
@input = +"".b
|
|
312
|
+
@stdin_open = true
|
|
313
|
+
@output_open = true
|
|
314
|
+
@status = nil
|
|
315
|
+
step while @output_open && @status.nil?
|
|
316
|
+
drain_output if @output_open
|
|
317
|
+
write_terminal(@filter.finish)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Errors the relay expects (EIO, EOF, EPIPE) are handled where they
|
|
321
|
+
# occur; anything else is reported once and the relay goes on, because
|
|
322
|
+
# ending it would hang up the shell.
|
|
323
|
+
def step
|
|
324
|
+
relay_step
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
unless @relay_error_reported
|
|
327
|
+
@relay_error_reported = true
|
|
328
|
+
warn_line "oops: warning: unexpected relay error (#{e.class}: #{e.message}); continuing"
|
|
329
|
+
end
|
|
330
|
+
sleep ERROR_BACKOFF
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def relay_step
|
|
334
|
+
readers = [@master, @wake_r]
|
|
335
|
+
readers << @stdin if @stdin_open && @input.bytesize < MAX_PENDING_INPUT
|
|
336
|
+
writers = @input.empty? ? nil : [@master]
|
|
337
|
+
readable, writable = IO.select(readers, writers, nil, POLL_INTERVAL)
|
|
338
|
+
readable ||= []
|
|
339
|
+
handle_wakeup if readable.include?(@wake_r)
|
|
340
|
+
read_input if readable.include?(@stdin)
|
|
341
|
+
write_input if writable&.include?(@master)
|
|
342
|
+
read_output if readable.include?(@master)
|
|
343
|
+
reap_child
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def handle_wakeup
|
|
347
|
+
signals = @wake_r.read_nonblock(READ_SIZE, exception: false)
|
|
348
|
+
copy_winsize(@master) if signals.is_a?(String) && signals.include?("W")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# The user's terminal went away: hang up the shell, as closing a
|
|
352
|
+
# terminal window does.
|
|
353
|
+
def read_input
|
|
354
|
+
@input << @stdin.readpartial(READ_SIZE).b
|
|
355
|
+
rescue EOFError, Errno::EIO
|
|
356
|
+
@stdin_open = false
|
|
357
|
+
hang_up_shell if @stdin.tty?
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def write_input
|
|
361
|
+
written = @master.write_nonblock(@input, exception: false)
|
|
362
|
+
@input = @input.byteslice(written..) if written.is_a?(Integer)
|
|
363
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
364
|
+
@input.clear
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def read_output
|
|
368
|
+
data = @master.read_nonblock(READ_SIZE, exception: false)
|
|
369
|
+
return if data == :wait_readable
|
|
370
|
+
|
|
371
|
+
data.nil? ? (@output_open = false) : write_terminal(@filter.feed(data))
|
|
372
|
+
rescue Errno::EIO
|
|
373
|
+
# The child side closed the pty: the shell is gone.
|
|
374
|
+
@output_open = false
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# After the shell exited, a background job may still hold the pty open;
|
|
378
|
+
# forward what is already there instead of waiting for it.
|
|
379
|
+
def drain_output
|
|
380
|
+
loop do
|
|
381
|
+
data = @master.read_nonblock(READ_SIZE, exception: false)
|
|
382
|
+
break if data.nil? || data == :wait_readable
|
|
383
|
+
|
|
384
|
+
write_terminal(@filter.feed(data))
|
|
385
|
+
end
|
|
386
|
+
rescue Errno::EIO
|
|
387
|
+
nil
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def reap_child
|
|
391
|
+
_, status = Process.wait2(@pid, Process::WNOHANG)
|
|
392
|
+
@status = status if status
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def write_terminal(bytes)
|
|
396
|
+
return if bytes.empty? || @terminal_gone
|
|
397
|
+
|
|
398
|
+
@stdout.write(bytes)
|
|
399
|
+
@stdout.flush
|
|
400
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
401
|
+
# The terminal is gone; hang up the shell as closing it would.
|
|
402
|
+
@terminal_gone = true
|
|
403
|
+
hang_up_shell
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def hang_up_shell
|
|
407
|
+
Process.kill("HUP", @pid)
|
|
408
|
+
rescue SystemCallError
|
|
409
|
+
nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def exit_code(status)
|
|
413
|
+
status.exitstatus || (128 + status.termsig.to_i)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
data/lib/oops/secrets.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module Oops
|
|
2
|
+
# Masks likely secrets in captured text. Each pattern replaces either its
|
|
3
|
+
# whole match or, when it defines a named group `secret`, only that group.
|
|
4
|
+
module Secrets
|
|
5
|
+
TOKEN = "[secret]".freeze
|
|
6
|
+
|
|
7
|
+
# Octet 0-255, used by the IPv4 pattern.
|
|
8
|
+
OCTET = "(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)".freeze
|
|
9
|
+
|
|
10
|
+
# Order matters: specific token formats first, then URL userinfo before
|
|
11
|
+
# email, and the generic key/value rule last so it skips values that an
|
|
12
|
+
# earlier pattern already replaced.
|
|
13
|
+
BUILTIN = [
|
|
14
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, # AWS access key id
|
|
15
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{20,})/, # GitHub tokens
|
|
16
|
+
/\bxox[abprs]-[A-Za-z0-9-]+/, # Slack tokens
|
|
17
|
+
/\bsk-[A-Za-z0-9_-]{20,}/, # OpenAI / Anthropic keys
|
|
18
|
+
/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/, # Stripe secret keys
|
|
19
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/, # GitLab tokens
|
|
20
|
+
/\bAIza[0-9A-Za-z_-]{35}/, # Google API keys
|
|
21
|
+
/\bnpm_[A-Za-z0-9]{36}/, # npm tokens
|
|
22
|
+
%r{hooks\.slack\.com/services/(?<secret>[A-Za-z0-9/]+)}, # Slack webhook URLs
|
|
23
|
+
/\beyJ[\w-]+\.[\w-]+\.[\w-]+/, # JWT
|
|
24
|
+
%r{Authorization:[ \t]*(?:Bearer|Basic|token)[ \t]+(?<secret>[\w.~+/=-]+)}i,
|
|
25
|
+
# Without an END line (cut off by head, a failed command or the capture
|
|
26
|
+
# limit) everything after BEGIN is masked.
|
|
27
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)/m,
|
|
28
|
+
%r{\b[a-z][a-z0-9+.-]*://(?<secret>[^\s/@:]+:[^\s/@]+)@}i, # URL userinfo
|
|
29
|
+
%r{[A-Za-z0-9._%+-]+@(?>[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?!:[\w~/])}, # email, not scp-style git@host:path
|
|
30
|
+
# Public IPv4: not private/loopback/unspecified, and not part of a longer
|
|
31
|
+
# dotted number such as a version string (1.2.3.4.5).
|
|
32
|
+
Regexp.new(
|
|
33
|
+
"(?<![\\w.])" \
|
|
34
|
+
"(?!127\\.|0\\.0\\.0\\.0|10\\.|192\\.168\\.|172\\.(?:1[6-9]|2\\d|3[01])\\.)" \
|
|
35
|
+
"(?<secret>#{OCTET}(?:\\.#{OCTET}){3})(?!\\w|\\.\\d)"
|
|
36
|
+
),
|
|
37
|
+
# Passwords given as command-line arguments. The spans between the
|
|
38
|
+
# command name and the option are bounded so a long line of repeated
|
|
39
|
+
# command names cannot make matching quadratic.
|
|
40
|
+
/(?<![\w-])--(?:password|passwd|pwd)[ \t]+(?<q>["'])?
|
|
41
|
+
(?<secret>(?![-\[])(?(<q>)(?>\\.|(?!\k<q>)[^\\\n])++|[^\s"']+))\k<q>?/ix,
|
|
42
|
+
/\bcurl\b[^\n|;&]{0,200}?\s(?:-u|--user)[ \t]+(?<secret>[^\s:]+:\S+)/,
|
|
43
|
+
/\bmysql(?:dump|admin|pump)?\b[^\n|;&]{0,200}?\s-p(?<secret>[^\s"']+)/,
|
|
44
|
+
/\bdocker\b[^\n|;&]{0,200}?\blogin\b[^\n|;&]{0,200}?\s(?:-p|--password)[ \t]+(?<secret>\S+)/,
|
|
45
|
+
# name containing key/token/secret/password/passwd/pwd, then = or :
|
|
46
|
+
# Quoted values run to the matching closing quote (backslash escapes
|
|
47
|
+
# allowed) using an atomic body, so matching stays linear; an unterminated
|
|
48
|
+
# quote masks to the end of the line. Unquoted values stop at whitespace,
|
|
49
|
+
# quotes, , ; and & (so query strings keep each pair apart). The name
|
|
50
|
+
# parts are bounded: with unbounded repeats on both sides of the keyword,
|
|
51
|
+
# a long word that is full of keywords took quadratic time.
|
|
52
|
+
/(?<![\w.-])[\w.-]{0,64}(?:key|token|secret|password|passwd|pwd)[\w.-]{0,64}["']?[ \t]*[=:][ \t]*
|
|
53
|
+
(?<q>["'])?
|
|
54
|
+
(?<secret>(?!\[secret\])(?(<q>)(?>\\.|(?!\k<q>)[^\\\n])++|[^\s"',;&]+))
|
|
55
|
+
\k<q>?/ix
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# Returns [masked_text, number_of_replacements]. `extra` regexps run first.
|
|
61
|
+
# Input is scrubbed (invalid UTF-8 bytes become U+FFFD) so regexps never
|
|
62
|
+
# raise on arbitrary captured output; the returned copy is always valid.
|
|
63
|
+
def mask(text, extra: [])
|
|
64
|
+
out = text.scrub
|
|
65
|
+
count = 0
|
|
66
|
+
(extra + BUILTIN).each do |pattern|
|
|
67
|
+
out = out.gsub(pattern) do
|
|
68
|
+
count += 1
|
|
69
|
+
replacement(Regexp.last_match)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
[out, count]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def replacement(match)
|
|
76
|
+
return TOKEN unless match.names.include?("secret") && match[:secret]
|
|
77
|
+
|
|
78
|
+
# Byte offsets: char offsets would cost O(n) per match on long input.
|
|
79
|
+
whole = match[0]
|
|
80
|
+
start = match.byteoffset(0).first
|
|
81
|
+
from, to = match.byteoffset(:secret).map { |offset| offset - start }
|
|
82
|
+
whole.byteslice(0, from) + TOKEN + whole.byteslice(to..)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
module Oops
|
|
2
|
+
# Append-only JSONL log of captured commands for one shell session. A log
|
|
3
|
+
# that grows past its cap is rewritten to keep only its newest records.
|
|
4
|
+
class SessionLog
|
|
5
|
+
CHUNK_SIZE = 64 * 1024
|
|
6
|
+
DEFAULT_MAX_BYTES = 50 * 1024 * 1024
|
|
7
|
+
# When a log passes its cap it is cut to this share of the cap, so a busy
|
|
8
|
+
# tab is not rewritten on every command.
|
|
9
|
+
TRIM_TO = 0.75
|
|
10
|
+
LOG_NAME = /\A(\d{8}-\d{6})-(\d+)\.jsonl\z/
|
|
11
|
+
|
|
12
|
+
attr_reader :path
|
|
13
|
+
|
|
14
|
+
def self.new_path(dir, pid: Process.pid, now: Time.now)
|
|
15
|
+
stamp = now.strftime("%Y%m%d-%H%M%S")
|
|
16
|
+
File.join(dir, "#{stamp}-#{pid}.jsonl")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.process_alive?(pid)
|
|
20
|
+
Process.kill(0, pid)
|
|
21
|
+
true
|
|
22
|
+
rescue Errno::EPERM
|
|
23
|
+
true
|
|
24
|
+
rescue SystemCallError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Deletes the oldest logs of finished sessions until the total size of
|
|
29
|
+
# all session logs is at most max_bytes. Logs of live sessions are kept.
|
|
30
|
+
def self.rotate!(dir, max_bytes: DEFAULT_MAX_BYTES, alive: method(:process_alive?))
|
|
31
|
+
logs = Dir.children(dir).filter_map do |name|
|
|
32
|
+
m = LOG_NAME.match(name)
|
|
33
|
+
next unless m
|
|
34
|
+
|
|
35
|
+
size = begin
|
|
36
|
+
File.size(File.join(dir, name))
|
|
37
|
+
rescue SystemCallError
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
{ name: name, pid: m[2].to_i, size: size }
|
|
41
|
+
end
|
|
42
|
+
total = logs.sum { |l| l[:size] }
|
|
43
|
+
logs.sort_by { |l| l[:name] }.each do |log|
|
|
44
|
+
break if total <= max_bytes
|
|
45
|
+
next if alive.call(log[:pid])
|
|
46
|
+
|
|
47
|
+
remove_log(File.join(dir, log[:name]))
|
|
48
|
+
total -= log[:size]
|
|
49
|
+
end
|
|
50
|
+
rescue Errno::ENOENT
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.remove_log(file)
|
|
55
|
+
[file, "#{file}.paused", "#{file}.status", "#{file}.trim"].each do |f|
|
|
56
|
+
File.delete(f)
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :remove_log
|
|
62
|
+
|
|
63
|
+
def initialize(path, max_bytes: DEFAULT_MAX_BYTES)
|
|
64
|
+
@path = path
|
|
65
|
+
@max_bytes = max_bytes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Raises if the record cannot be written. Trimming an oversized log is
|
|
69
|
+
# best effort and never raises.
|
|
70
|
+
def append(record)
|
|
71
|
+
line = record.to_json_line
|
|
72
|
+
size = File.open(@path, File::APPEND | File::CREAT | File::WRONLY, 0o600) do |f|
|
|
73
|
+
f.syswrite(line)
|
|
74
|
+
f.size
|
|
75
|
+
end
|
|
76
|
+
trim if size > @max_bytes
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Yields records newest first. Unparsable lines are skipped. The block can
|
|
80
|
+
# return :stop to end the iteration.
|
|
81
|
+
def each_backward
|
|
82
|
+
return enum_for(:each_backward) unless block_given?
|
|
83
|
+
|
|
84
|
+
File.open(@path, "rb") do |io|
|
|
85
|
+
each_line_backward(io) do |line|
|
|
86
|
+
record = parse(line)
|
|
87
|
+
next unless record
|
|
88
|
+
return nil if yield(record) == :stop
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
rescue Errno::ENOENT
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Newest count finished, non-oops records, oldest first.
|
|
97
|
+
def last(count, skip_oops: true)
|
|
98
|
+
found = []
|
|
99
|
+
each_backward do |r|
|
|
100
|
+
next if r.exit.nil? || (skip_oops && r.oops_self?)
|
|
101
|
+
|
|
102
|
+
found << r
|
|
103
|
+
:stop if found.size >= count
|
|
104
|
+
end
|
|
105
|
+
found.reverse
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def last_failed
|
|
109
|
+
each_backward do |r|
|
|
110
|
+
return r if r.exit.is_a?(Integer) && r.exit != 0 && !r.oops_self?
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def paused?
|
|
116
|
+
File.exist?(pause_path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pause!
|
|
120
|
+
File.open(pause_path, File::CREAT | File::WRONLY, 0o600, &:itself)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resume!
|
|
124
|
+
File.delete(pause_path)
|
|
125
|
+
rescue Errno::ENOENT
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def pause_path
|
|
132
|
+
"#{@path}.paused"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Rewrites the log with the newest complete records that fit in TRIM_TO
|
|
136
|
+
# of the cap (always at least the newest one). The new file is written
|
|
137
|
+
# next to the log and renamed over it, so a reader sees either the old or
|
|
138
|
+
# the new log, never a mix.
|
|
139
|
+
def trim
|
|
140
|
+
keep = newest_lines((@max_bytes * TRIM_TO).to_i)
|
|
141
|
+
tmp = "#{@path}.trim"
|
|
142
|
+
remove_file(tmp)
|
|
143
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
144
|
+
keep.reverse_each { |l| f.write(l, "\n") }
|
|
145
|
+
end
|
|
146
|
+
File.rename(tmp, @path)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
remove_file(tmp) if tmp
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Newest lines first, as many as fit in limit bytes (newlines included),
|
|
153
|
+
# but at least one.
|
|
154
|
+
def newest_lines(limit)
|
|
155
|
+
lines = []
|
|
156
|
+
total = 0
|
|
157
|
+
File.open(@path, "rb") do |io|
|
|
158
|
+
each_line_backward(io) do |line|
|
|
159
|
+
next if line.empty?
|
|
160
|
+
break if !lines.empty? && total + line.bytesize + 1 > limit
|
|
161
|
+
|
|
162
|
+
lines << line
|
|
163
|
+
total += line.bytesize + 1
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
lines
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def remove_file(file)
|
|
170
|
+
File.delete(file)
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse(line)
|
|
176
|
+
return nil if line.empty?
|
|
177
|
+
|
|
178
|
+
Record.from_json_line(line)
|
|
179
|
+
rescue Record::ParseError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_chunk(io, offset, length)
|
|
184
|
+
io.seek(offset)
|
|
185
|
+
io.read(length)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Yields complete lines (without newline) from the end of the file. An
|
|
189
|
+
# unterminated tail, if any, is ignored as a partial write.
|
|
190
|
+
def each_line_backward(io, &)
|
|
191
|
+
pos = io.size
|
|
192
|
+
return if pos.zero?
|
|
193
|
+
|
|
194
|
+
discard = read_chunk(io, pos - 1, 1) != "\n"
|
|
195
|
+
carry = "".b
|
|
196
|
+
while pos.positive?
|
|
197
|
+
len = [CHUNK_SIZE, pos].min
|
|
198
|
+
pos -= len
|
|
199
|
+
parts = (read_chunk(io, pos, len) + carry).split("\n", -1)
|
|
200
|
+
if discard
|
|
201
|
+
if parts.size > 1
|
|
202
|
+
parts.pop
|
|
203
|
+
discard = false
|
|
204
|
+
else
|
|
205
|
+
parts.clear
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
carry = pos.positive? && !parts.empty? ? parts.shift : "".b
|
|
209
|
+
parts.reverse_each(&)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|