samagotchi 0.128.0 → 0.129.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 +4 -4
- data/CHANGELOG.md +82 -1
- data/docs/architecture.md +12 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +150 -25
- data/docs/internals/participants.md +17 -3
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
- data/lib/samagotchi/guardrails/outside.rb +54 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
- data/lib/samagotchi/guardrails/pair_command.rb +261 -66
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
- data/lib/samagotchi/guardrails/read_only_shell.rb +191 -8
- data/lib/samagotchi/guardrails/review_paths.rb +22 -8
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
- data/lib/samagotchi/guardrails/targets.rb +128 -24
- data/lib/samagotchi/session_manager.rb +9 -15
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- data/lib/samagotchi/web/public/app.js +8 -1
- data/lib/samagotchi/web/public/participant.js +2 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +12 -5
- data/lib/samagotchi/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "outside"
|
|
4
|
+
require_relative "read_only_shell"
|
|
4
5
|
require_relative "review_paths"
|
|
5
6
|
require_relative "shell_paths"
|
|
6
7
|
require_relative "shell_lex"
|
|
@@ -17,10 +18,15 @@ module Samagotchi
|
|
|
17
18
|
# a command of its own, one it hands another interpreter (ruby -e,
|
|
18
19
|
# python <<EOF) by its text. A command it can't read is refused
|
|
19
20
|
# (UNREADABLE), never let through on a text match;
|
|
20
|
-
# - #outside_writes?: whether it
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
21
|
+
# - #outside_writes?: whether it may change something outside the
|
|
22
|
+
# project, failing closed: no unless it only reads
|
|
23
|
+
# (ReadOnlyShell.read_only?), or only reads but for writes into the
|
|
24
|
+
# project or a tmp dir (ReadOnlyShell.read_only_but_writes?), or
|
|
25
|
+
# stays inside the project (#stays_inside?: it runs in the project and
|
|
26
|
+
# every path it names, an option's value, a glob's fixed part and the
|
|
27
|
+
# paths in a script it hands on among them, lands in the project or a
|
|
28
|
+
# tmp dir, Outside.inside? / Outside.tmp_write?). A word it can't
|
|
29
|
+
# resolve ($var, $(…), a dir after cd $X) counts as outside.
|
|
24
30
|
# A data heredoc (cat, tee, git commit -F - read it: ShellLex::HEREDOC)
|
|
25
31
|
# is not looked into: it's text being written, not run.
|
|
26
32
|
class PairCommand
|
|
@@ -37,45 +43,119 @@ module Samagotchi
|
|
|
37
43
|
INTERPRETERS = %w[python python3 python2 ruby irb node deno bun perl php lua osascript jshell].freeze
|
|
38
44
|
# Options whose next word is code or a command, for any program.
|
|
39
45
|
CODE_OPTIONS = %w[-c -e -exec --exec -execdir --command --eval -x].freeze
|
|
40
|
-
# Programs that detach what they start.
|
|
41
|
-
DETACHERS = %w[nohup setsid disown daemonize].freeze
|
|
46
|
+
# Programs that detach what they start, or run it later (cron, at).
|
|
47
|
+
DETACHERS = %w[nohup setsid disown daemonize crontab at batch].freeze
|
|
42
48
|
# Programs that print the environment or a keychain's secrets, whatever
|
|
43
49
|
# their arguments.
|
|
44
|
-
ENV_READERS = %w[printenv security].freeze
|
|
50
|
+
ENV_READERS = %w[printenv security op].freeze
|
|
51
|
+
# Commands that print a stored credential (a token, a password, a
|
|
52
|
+
# key), by the program and the words that say so in its arguments.
|
|
53
|
+
CREDENTIAL_PRINTERS = {
|
|
54
|
+
"gh" => [%w[auth token]],
|
|
55
|
+
"git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
|
|
56
|
+
%w[credential-manager]],
|
|
57
|
+
"gcloud" => [%w[print-access-token], %w[print-identity-token]],
|
|
58
|
+
"aws" => [%w[configure get], %w[configure export-credentials], %w[ecr get-login-password],
|
|
59
|
+
%w[sts get-session-token]],
|
|
60
|
+
"az" => [%w[account get-access-token]],
|
|
61
|
+
"heroku" => [%w[auth:token]],
|
|
62
|
+
"npm" => [%w[token]],
|
|
63
|
+
"kubectl" => [%w[config view --raw]],
|
|
64
|
+
"keyring" => [%w[get]]
|
|
65
|
+
}.freeze
|
|
66
|
+
# A credential helper run by itself (docker-credential-osxkeychain get).
|
|
67
|
+
CREDENTIAL_HELPER = /\A(?:docker-credential-|git-credential-)/
|
|
45
68
|
ENV_TEXT = %r{/proc/[^\s]*/environ}
|
|
69
|
+
# This machine's loopback names (.web_text): localhost and its
|
|
70
|
+
# subdomains, 127.x.y.z, 0.0.0.0, ::1 spelt any way, ::ffff:127.x.
|
|
71
|
+
LOOPBACK_HOSTS = [/(?:[\w-]+\.)*localhost\.?/, /127(?:\.\d{1,3}){1,3}/, /0\.0\.0\.0/,
|
|
72
|
+
/\[?(?:0{0,4}:){2,7}0{0,3}1\]?/, /\[?::ffff:127(?:\.\d{1,3}){3}\]?/].freeze
|
|
46
73
|
# An interpreter's script that reads the environment or detaches.
|
|
47
74
|
SCRIPT_ENV_TEXT = /\bos\.environ\b|\bgetenv\b|\bprocess\.env\b|\bENV\b|\bprintenv\b|%ENV\b/
|
|
48
75
|
SCRIPT_BACKGROUND_TEXT = /\b(?:nohup|setsid|disown|daemonize|Process\.daemon|detach(?:ed)?)\b/
|
|
49
76
|
# Programs that read a folder's whole tree: given home (or any folder
|
|
50
77
|
# above a denied one) they reach the secrets in it.
|
|
51
|
-
|
|
78
|
+
# A link to such a folder reaches them too (ln, link).
|
|
79
|
+
SWEEPERS = %w[find tar cp rsync scp zip 7z cpio grep egrep fgrep rg ag ack fd ditto ln link].freeze
|
|
80
|
+
# Programs that make a link (and cp's options that do).
|
|
81
|
+
LINKERS = %w[ln link mklink].freeze
|
|
82
|
+
CP_LINK_OPTION = /\A-[a-zA-Z]*[sl]|\A--(?:symbolic-)?link\z/
|
|
83
|
+
# An interpreter's script that makes a link.
|
|
84
|
+
SCRIPT_LINK_TEXT = /symlink|\blink\(|\bln\s|\bmklink\b|FileUtils\.ln|hardlink/i
|
|
52
85
|
# git settings that make git run a command (or show one), and git
|
|
53
86
|
# config's scopes beyond the repository.
|
|
54
87
|
GIT_RUNNING_KEYS = %w[core.fsmonitor core.sshcommand core.hookspath core.pager core.editor].freeze
|
|
55
88
|
GIT_WIDE_SCOPES = %w[--global --system].freeze
|
|
56
89
|
GIT_VALUE_OPTIONS = %w[-C --git-dir --work-tree --namespace --exec-path].freeze
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
90
|
+
# Devices a command may name in the project's stead.
|
|
91
|
+
DEVICES = %w[/dev/null /dev/stdout /dev/stderr /dev/tty /dev/zero /dev/random /dev/urandom].freeze
|
|
92
|
+
# A path in a script's text (an interpreter's -e/-c, a heredoc): ~, ..
|
|
93
|
+
# or an absolute path, at the text's start or after a space, quote,
|
|
94
|
+
# bracket, = or comma.
|
|
95
|
+
SCRIPT_PATH = %r{(?:\A|(?<=[\s'"(\[=,]))(?:~|\.\.|/[\w.~-])[^\s'"`;,()\]]*}
|
|
96
|
+
# A $(…) that is only a message's heredoc (git commit -m "$(cat <<EOF …)"),
|
|
97
|
+
# its body cut by ShellLex.code_text.
|
|
98
|
+
MESSAGE_SUBST = /\$\(cat <<-?(['"]?)(\w+)\1\n\2\n\)/
|
|
99
|
+
# Assignments whose value is a search list, not a path.
|
|
100
|
+
LIST_VARIABLES = %w[PATH].freeze
|
|
62
101
|
GLOB = /[*?\[\]{}]/
|
|
102
|
+
SHELL_BRACKETS = %w[[ [[ ]] { } ]].freeze
|
|
63
103
|
# How many matches of a glob are checked against the denylist.
|
|
64
104
|
GLOB_LIMIT = 500
|
|
65
105
|
# How deep scripts handed to a shell are read; deeper is UNREADABLE.
|
|
66
106
|
MAX_DEPTH = 3
|
|
67
107
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
# Whether a pair turn may write +path+ (absolute) without asking: it
|
|
109
|
+
# lands in the project (Outside.inside?) or in a tmp dir the project
|
|
110
|
+
# isn't in (Outside.tmp_write?), every way it resolves. No project
|
|
111
|
+
# (+root+ blank): the tmp dirs only.
|
|
112
|
+
def self.writable?(path, root:, cwd: nil)
|
|
113
|
+
root = root.to_s
|
|
114
|
+
return Outside.tmp_write?(path, root: cwd || Dir.pwd) if root.empty?
|
|
115
|
+
|
|
116
|
+
Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether +path+ is in a .git/hooks folder, as given or as it resolves
|
|
120
|
+
# (ShellPaths::HOOKS_PART): git runs what is there.
|
|
121
|
+
def self.git_hook?(path)
|
|
122
|
+
path.to_s.match?(ShellPaths::HOOKS_PART) ||
|
|
123
|
+
(path.to_s.start_with?("/") && ProtectedPaths.reals(path).any? { |real| real.match?(ShellPaths::HOOKS_PART) })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A command's text naming chi web's endpoint on this machine: a
|
|
127
|
+
# loopback name (LOOPBACK_HOSTS) or one of +hostnames+, and one of
|
|
128
|
+
# +ports+, anywhere in it (a URL's host:port, nc's host port, a
|
|
129
|
+
# script's ("host", port), /dev/tcp/host/port, curl's --resolve); or
|
|
130
|
+
# 0:port. nil without a port.
|
|
131
|
+
# @return [Regexp, nil]
|
|
132
|
+
def self.web_text(ports, hostnames: [])
|
|
133
|
+
return nil if ports.empty?
|
|
134
|
+
|
|
135
|
+
hosts = (LOOPBACK_HOSTS.map(&:source) + hostnames.map { |name| Regexp.escape(name) }).join("|")
|
|
136
|
+
port = ports.map { |number| Regexp.escape(number.to_s) }.join("|")
|
|
137
|
+
Regexp.new("\\A(?=[\\s\\S]*?(?<![\\w.-])(?:#{hosts})(?![\\w-]))(?=[\\s\\S]*?(?<![\\w.])(?:#{port})(?!\\d))|" \
|
|
138
|
+
"(?<![\\w.:-])0:(?:#{port})(?!\\d)", Regexp::IGNORECASE)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# This machine's own names (Socket.gethostname, with and without
|
|
142
|
+
# its domain and with .local), lowercased.
|
|
143
|
+
def self.hostnames
|
|
144
|
+
require "socket"
|
|
145
|
+
name = Socket.gethostname.to_s.downcase
|
|
146
|
+
short = name.split(".").first.to_s
|
|
147
|
+
[name, short, "#{short}.local"].reject { |host| host.empty? || host == ".local" }.uniq
|
|
148
|
+
rescue StandardError
|
|
149
|
+
[]
|
|
73
150
|
end
|
|
74
151
|
|
|
75
152
|
# @param paths [ReviewPaths] the denylist
|
|
76
|
-
# @param root [String, nil] the project root (outside writes
|
|
77
|
-
|
|
78
|
-
|
|
153
|
+
# @param root [String, nil] the project root (outside writes; none:
|
|
154
|
+
# every command but a read-only one writes outside)
|
|
155
|
+
# @param web [Regexp, nil] chi web's endpoint in a command's text
|
|
156
|
+
# (.web_text)
|
|
157
|
+
def initialize(text, cwd:, paths:, home:, root: nil, depth: 0, web: nil)
|
|
158
|
+
@web = web
|
|
79
159
|
@text = text.to_s
|
|
80
160
|
@cwd = cwd
|
|
81
161
|
@paths = paths
|
|
@@ -93,10 +173,28 @@ module Samagotchi
|
|
|
93
173
|
@refusal = PairBoundary::UNREADABLE
|
|
94
174
|
end
|
|
95
175
|
|
|
96
|
-
# Whether it
|
|
176
|
+
# Whether it makes a link (ln, link, cp -s/-l, a script's symlink): a
|
|
177
|
+
# path through a link it makes now is judged before the link exists,
|
|
178
|
+
# so the owner approves it (asked about; a link to a folder above a
|
|
179
|
+
# secret is refused: SWEEPERS). True for one it can't read.
|
|
180
|
+
def links?
|
|
181
|
+
return @links if defined?(@links)
|
|
182
|
+
|
|
183
|
+
@links = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
|
|
184
|
+
words.is_a?(Array) && link_command?(words)
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError
|
|
187
|
+
@links = true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether it may write outside the project (asked about, never
|
|
191
|
+
# denied); true for one it can't read.
|
|
97
192
|
def outside_writes?
|
|
98
|
-
|
|
99
|
-
|
|
193
|
+
return @outside_writes if defined?(@outside_writes)
|
|
194
|
+
|
|
195
|
+
@outside_writes = !(ReadOnlyShell.read_only?(@text) || writes_inside? || stays_inside?)
|
|
196
|
+
rescue StandardError
|
|
197
|
+
@outside_writes = true
|
|
100
198
|
end
|
|
101
199
|
|
|
102
200
|
private
|
|
@@ -110,14 +208,14 @@ module Samagotchi
|
|
|
110
208
|
return PairBoundary::CHI if subst && code.match?(CHI_TEXT)
|
|
111
209
|
return PairBoundary::BACKGROUND if tokens.any? { |type, value| type == :op && value == "&" }
|
|
112
210
|
return PairBoundary::ENVIRONMENT if code.match?(ENV_TEXT)
|
|
211
|
+
return PairBoundary::CHI_WEB if @web && code.match?(@web)
|
|
113
212
|
return PairBoundary::SECRET if subst && code.match?(PairBoundary::SECRET_TEXT)
|
|
114
213
|
|
|
115
|
-
@
|
|
214
|
+
@read_only = ReadOnlyShell.read_only?(@text)
|
|
116
215
|
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
117
216
|
ShellLex.simple_commands(tokens).each do |words|
|
|
118
217
|
next walk.step(words) if words.is_a?(Symbol)
|
|
119
218
|
|
|
120
|
-
@targets.concat(write_targets(words, walk))
|
|
121
219
|
reason = words_refusal(words) || scripts_refusal(words) || paths_refusal(words, walk.step(words))
|
|
122
220
|
return reason if reason
|
|
123
221
|
end
|
|
@@ -127,10 +225,12 @@ module Samagotchi
|
|
|
127
225
|
# A simple command's own refusal, by its program and arguments.
|
|
128
226
|
def words_refusal(words)
|
|
129
227
|
at = program_at(words)
|
|
228
|
+
return PairBoundary::CHI if chi_in_values?(words)
|
|
229
|
+
return PairBoundary::VERB if at && unplain_program?(words[at])
|
|
230
|
+
|
|
130
231
|
program = at && File.basename(words[at])
|
|
131
232
|
args = at ? words[(at + 1)..] : []
|
|
132
233
|
return PairBoundary::CHI if program == "chi" && !CHI_ALLOWED.include?(args)
|
|
133
|
-
return PairBoundary::CHI if chi_in_values?(words)
|
|
134
234
|
|
|
135
235
|
upto = at ? words[0..at] : words
|
|
136
236
|
return PairBoundary::BACKGROUND if upto.any? { |w| DETACHERS.include?(File.basename(w)) } || detached_session?(program, args)
|
|
@@ -140,6 +240,14 @@ module Samagotchi
|
|
|
140
240
|
nil
|
|
141
241
|
end
|
|
142
242
|
|
|
243
|
+
# A program word the shell expands: a $var, a $(…), a glob (bin/ch?,
|
|
244
|
+
# ${e}i); the test brackets and braces are words of their own.
|
|
245
|
+
def unplain_program?(word)
|
|
246
|
+
return false if SHELL_BRACKETS.include?(word)
|
|
247
|
+
|
|
248
|
+
word.include?("$") || word.include?(ShellLex::SUBST) || word.match?(GLOB)
|
|
249
|
+
end
|
|
250
|
+
|
|
143
251
|
# chi in an assignment's value (FOO=chi, alias x='chi …') or in the
|
|
144
252
|
# word after an option that takes code or a command (-exec, -c, -e).
|
|
145
253
|
def chi_in_values?(words)
|
|
@@ -158,8 +266,7 @@ module Samagotchi
|
|
|
158
266
|
program = File.basename(words[at])
|
|
159
267
|
scripts(words, at).each do |script|
|
|
160
268
|
reason = if SHELLS.include?(program) || program == "eval"
|
|
161
|
-
|
|
162
|
-
tmp_roots: @tmp_roots).refusal
|
|
269
|
+
nested(script).refusal
|
|
163
270
|
elsif INTERPRETERS.include?(program)
|
|
164
271
|
script_text_refusal(script)
|
|
165
272
|
end
|
|
@@ -218,21 +325,32 @@ module Samagotchi
|
|
|
218
325
|
(program == "tmux" && args.any? { |arg| arg.match?(/\Anew(-session)?\z/) } && args.include?("-d"))
|
|
219
326
|
end
|
|
220
327
|
|
|
221
|
-
# printenv, security
|
|
222
|
-
#
|
|
328
|
+
# printenv, security, op, a credential printer (CREDENTIAL_PRINTERS)
|
|
329
|
+
# or helper; set, export, declare and typeset that list (no
|
|
330
|
+
# assignment); launchctl getenv/export; ps with a BSD e (ps e, eww) or
|
|
331
|
+
# -E (ps -E, -axE).
|
|
223
332
|
def env_reader?(program, args)
|
|
224
|
-
return true if ENV_READERS.include?(program)
|
|
333
|
+
return true if ENV_READERS.include?(program) || program.to_s.match?(CREDENTIAL_HELPER)
|
|
334
|
+
return true if credential_printer?(program, args)
|
|
225
335
|
|
|
226
336
|
listing = args.none? { |arg| !arg.start_with?("-", "+") }
|
|
227
337
|
case program
|
|
228
338
|
when "set" then args.empty?
|
|
229
339
|
when "export", "declare", "typeset" then listing
|
|
230
340
|
when "launchctl" then %w[getenv export].include?(args.first)
|
|
231
|
-
when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z/) }
|
|
341
|
+
when "ps" then args.any? { |arg| arg.match?(/\A[a-zA-Z]*e[a-zA-Z]*\z|\A-[a-zA-Z]*E[a-zA-Z]*\z/) }
|
|
232
342
|
else false
|
|
233
343
|
end
|
|
234
344
|
end
|
|
235
345
|
|
|
346
|
+
# Whether +args+ hold one of +program+'s CREDENTIAL_PRINTERS words, in
|
|
347
|
+
# order and next to each other (git -C x credential fill).
|
|
348
|
+
def credential_printer?(program, args)
|
|
349
|
+
CREDENTIAL_PRINTERS.fetch(program, []).any? do |words|
|
|
350
|
+
args.each_cons(words.size).any?(words)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
236
354
|
# env with no command after it prints the environment.
|
|
237
355
|
def bare_env?(words)
|
|
238
356
|
assignment = ->(word) { word.start_with?("-") || word.match?(/\A[A-Za-z_]\w*=/) }
|
|
@@ -279,6 +397,8 @@ module Samagotchi
|
|
|
279
397
|
def paths_refusal(words, resolved)
|
|
280
398
|
program_word = (at = program_at(words)) && File.basename(words[at])
|
|
281
399
|
resolved.each do |word, path|
|
|
400
|
+
return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
|
|
401
|
+
|
|
282
402
|
if path
|
|
283
403
|
return PairBoundary::SECRET if @paths.secret_refusal([path])
|
|
284
404
|
return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
|
|
@@ -318,48 +438,123 @@ module Samagotchi
|
|
|
318
438
|
|
|
319
439
|
# Whether +path+ is a denied root's folder or one above it.
|
|
320
440
|
def above_denied?(path)
|
|
321
|
-
real
|
|
322
|
-
@paths.denied_roots.any? { |root| ShellPaths.within?(root, real) }
|
|
441
|
+
ProtectedPaths.reals(path).any? { |real| @paths.denied_roots.any? { |root| ShellPaths.within?(root, real) } }
|
|
323
442
|
end
|
|
324
443
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
words.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
444
|
+
def link_command?(words)
|
|
445
|
+
at = program_at(words)
|
|
446
|
+
program = at && File.basename(words[at])
|
|
447
|
+
return true if LINKERS.include?(program)
|
|
448
|
+
return true if program == "cp" && words[(at + 1)..].any? { |arg| arg.match?(CP_LINK_OPTION) }
|
|
449
|
+
return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && LINKERS.include?(File.basename(word)) }
|
|
450
|
+
|
|
451
|
+
runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
|
|
452
|
+
name = File.basename(words[runner])
|
|
453
|
+
scripts(words, runner).any? do |script|
|
|
454
|
+
SHELLS.include?(name) || name == "eval" ? nested(script).links? : script.match?(SCRIPT_LINK_TEXT)
|
|
335
455
|
end
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
|
|
459
|
+
depth: @depth + 1, web: @web)
|
|
460
|
+
|
|
461
|
+
# Whether it only reads but for writes (a > or >> target, cp's
|
|
462
|
+
# destination, tee's files) into the project or a tmp dir.
|
|
463
|
+
def writes_inside?
|
|
464
|
+
ReadOnlyShell.read_only_but_writes?(@text, cwd: @cwd, home: @home, env: ENV) { |path| writable?(path) }
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Whether it stays inside the project: every simple command runs in
|
|
468
|
+
# it, and every path it names lands in it or a tmp dir, a script it
|
|
469
|
+
# hands on included (a shell's read as a command, another
|
|
470
|
+
# interpreter's paths by its text). The program's own word is not a
|
|
471
|
+
# path it writes; a word it can't resolve ($var, a $(…) other than a
|
|
472
|
+
# message's heredoc, a dir it can't tell) is outside. A script file's
|
|
473
|
+
# body (ruby x.rb, make) is not read.
|
|
474
|
+
def stays_inside?
|
|
475
|
+
return false if @root.empty? || @depth > MAX_DEPTH
|
|
476
|
+
|
|
477
|
+
tokens = ShellLex.lex(@text)
|
|
478
|
+
return false if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && !substs_messages_only?
|
|
479
|
+
|
|
480
|
+
walk = ShellPaths::Walk.new(@cwd, @home, ENV)
|
|
481
|
+
ShellLex.simple_commands(tokens).all? do |words|
|
|
482
|
+
next walk.step(words) if words.is_a?(Symbol)
|
|
483
|
+
|
|
484
|
+
walk.dirs.all? { |dir| dir && writable?(dir) } && words_inside?(words, walk) &&
|
|
485
|
+
scripts_inside?(words).tap { walk.step(words) }
|
|
347
486
|
end
|
|
348
|
-
targets
|
|
349
487
|
end
|
|
350
488
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
489
|
+
# Whether every $(…) is a message's heredoc (its body cut as data).
|
|
490
|
+
def substs_messages_only?
|
|
491
|
+
rest = ShellLex.code_text(@text, keep_written: false).gsub(MESSAGE_SUBST, "")
|
|
492
|
+
!rest.include?("$(") && !rest.include?("`")
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Whether each word of a simple command but the program (and the
|
|
496
|
+
# wrappers before it) lands in the project or a tmp dir.
|
|
497
|
+
def words_inside?(words, walk)
|
|
498
|
+
at = program_at(words)
|
|
499
|
+
# xargs runs its program on paths it reads from its input
|
|
500
|
+
return false if at && words[0...at].any? { |word| File.basename(word) == "xargs" }
|
|
501
|
+
|
|
502
|
+
words.each_with_index.all? do |word, i|
|
|
503
|
+
next true if at && (i == at || (i < at && WRAPPERS.include?(File.basename(word))))
|
|
504
|
+
next true if LIST_VARIABLES.any? { |name| word.start_with?("#{name}=") }
|
|
505
|
+
|
|
506
|
+
word_inside?(walk, word)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
354
509
|
|
|
355
|
-
|
|
356
|
-
return
|
|
357
|
-
return
|
|
510
|
+
def word_inside?(walk, word)
|
|
511
|
+
return true if data?(word)
|
|
512
|
+
return substs_messages_only? if word.include?(ShellLex::SUBST)
|
|
358
513
|
|
|
359
|
-
|
|
514
|
+
candidate = walk.candidate(word) or return true
|
|
515
|
+
return text_inside?(walk, candidate) if candidate.match?(ShellPaths::SCRIPT)
|
|
516
|
+
return glob_inside?(walk, candidate) if candidate.match?(GLOB) && !candidate.include?("$")
|
|
517
|
+
|
|
518
|
+
paths = walk.paths(candidate)
|
|
519
|
+
!paths.empty? && paths.all? { |path| DEVICES.include?(path) || writable?(path) }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# A word with spaces or quotes is text (a message, a pattern) unless
|
|
523
|
+
# it starts like a path.
|
|
524
|
+
def text_inside?(walk, text)
|
|
525
|
+
return true unless text.match?(%r{\A(?:~|\.\.|/)})
|
|
526
|
+
|
|
527
|
+
expanded = text.sub(%r{\A~(?=/|\z)}, @home)
|
|
528
|
+
return false if expanded.start_with?("~")
|
|
529
|
+
|
|
530
|
+
walk.dirs.all? { |dir| (dir || expanded.start_with?("/")) && writable?(File.expand_path(expanded, dir || "/")) }
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# A glob by its fixed part (the text before its first glob character).
|
|
534
|
+
def glob_inside?(walk, word)
|
|
535
|
+
fixed = word[/\A[^*?\[\]{}]*/]
|
|
536
|
+
return walk.dirs.all? { |dir| dir && writable?(dir) } if fixed.empty?
|
|
537
|
+
|
|
538
|
+
paths = walk.paths(fixed)
|
|
539
|
+
!paths.empty? && paths.all? { |path| writable?(path) }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# The scripts a simple command hands on (#scripts): a shell's as a
|
|
543
|
+
# command of its own, another interpreter's by the paths in its text.
|
|
544
|
+
def scripts_inside?(words)
|
|
545
|
+
limit = program_at(words) || (words.size - 1)
|
|
546
|
+
at = (0..limit).find { |i| runner?(words[i]) } or return true
|
|
547
|
+
program = File.basename(words[at])
|
|
548
|
+
scripts(words, at).all? do |script|
|
|
549
|
+
if SHELLS.include?(program) || program == "eval"
|
|
550
|
+
!nested(script).outside_writes?
|
|
551
|
+
else
|
|
552
|
+
script.scan(SCRIPT_PATH).all? { |path| writable?(File.expand_path(path.sub(%r{\A~(?=/|\z)}, @home), @cwd || Dir.pwd)) }
|
|
553
|
+
end
|
|
554
|
+
end
|
|
360
555
|
end
|
|
361
556
|
|
|
362
|
-
def
|
|
557
|
+
def writable?(path) = PairCommand.writable?(path, root: @root, cwd: @cwd)
|
|
363
558
|
end
|
|
364
559
|
end
|
|
365
560
|
end
|
|
@@ -80,7 +80,8 @@ module Samagotchi
|
|
|
80
80
|
|
|
81
81
|
dirs = chi_dirs
|
|
82
82
|
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
83
|
-
|
|
83
|
+
reals = paths.flat_map { |path| ProtectedPaths.reals(path) }
|
|
84
|
+
return true if reals.any? { |real| dirs.any? { |dir| ProtectedPaths.within?(real, dir) } }
|
|
84
85
|
|
|
85
86
|
command = fetch(facts, :command).to_s
|
|
86
87
|
return false if command.empty?
|
|
@@ -4,7 +4,9 @@ module Samagotchi
|
|
|
4
4
|
module Guardrails
|
|
5
5
|
# Core checks on what the file tools (write, edit, memory_write) touch,
|
|
6
6
|
# after the hooks and before the rules. Paths are compared with
|
|
7
|
-
# symlinks resolved (on the longest existing parent
|
|
7
|
+
# symlinks resolved (on the longest existing parent, `..` taken
|
|
8
|
+
# physically, a dangling symlink followed: .reals, the lexical answer
|
|
9
|
+
# and where the write lands, a vote when either is protected).
|
|
8
10
|
# deny: the approval store's dir, installed bundles (.bundles/:
|
|
9
11
|
# hooks, rules, manifests) and attached context (a source's
|
|
10
12
|
# command runs later, outside the gate; chi context writes
|
|
@@ -41,7 +43,7 @@ module Samagotchi
|
|
|
41
43
|
targets = verdict.targets
|
|
42
44
|
return verdict unless targets && FILE_TOOLS.include?(targets.tool)
|
|
43
45
|
|
|
44
|
-
paths = targets.paths.
|
|
46
|
+
paths = targets.paths.flat_map { |p| self.class.reals(p) }
|
|
45
47
|
@deny.each do |rule, root, reason|
|
|
46
48
|
next unless paths.any? { |p| self.class.within?(p, root) }
|
|
47
49
|
|
|
@@ -55,8 +57,71 @@ module Samagotchi
|
|
|
55
57
|
verdict
|
|
56
58
|
end
|
|
57
59
|
|
|
58
|
-
# +path+
|
|
60
|
+
# +path+ as an absolute path the way the kernel opens it: ~ expanded,
|
|
61
|
+
# relative to +base+, and each `..` taken from where the dir before it
|
|
62
|
+
# really is (a `..` after a symlinked dir climbs out of its target,
|
|
63
|
+
# not back to the link's parent as File.expand_path has it). It
|
|
64
|
+
# differs from File.expand_path only for such a climb; the parts
|
|
65
|
+
# after the last `..` are kept as given (symlinks not resolved).
|
|
66
|
+
# A shell's cd may land at either (ShellPaths::Walk keeps both); a file
|
|
67
|
+
# argument and a tool's path go through this.
|
|
68
|
+
# @param path [String]
|
|
69
|
+
# @param base [String, nil] for a relative path (Dir.pwd when nil)
|
|
70
|
+
def self.expand(path, base = nil)
|
|
71
|
+
path = path.to_s
|
|
72
|
+
head, sep, tail = path.partition("/")
|
|
73
|
+
path = "#{File.expand_path(head)}#{sep}#{tail}" if head.start_with?("~")
|
|
74
|
+
path = File.join(File.expand_path(base || Dir.pwd), path) unless path.start_with?("/")
|
|
75
|
+
parts = []
|
|
76
|
+
path.split("/").each do |part|
|
|
77
|
+
next if part.empty? || part == "."
|
|
78
|
+
|
|
79
|
+
part == ".." ? parts = climb(parts) : parts << part
|
|
80
|
+
end
|
|
81
|
+
"/#{parts.join("/")}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The parts of +parts+'s parent dir: the lexical one unless the dir
|
|
85
|
+
# is (or goes through) a symlink whose target's parent is elsewhere.
|
|
86
|
+
def self.climb(parts)
|
|
87
|
+
lexical = parts[0...-1]
|
|
88
|
+
dir = "/#{parts.join("/")}"
|
|
89
|
+
return lexical unless parts.any? && File.directory?(dir)
|
|
90
|
+
|
|
91
|
+
physical = File.dirname(File.realpath(dir))
|
|
92
|
+
return lexical if File.realpath("/#{lexical.join("/")}") == physical
|
|
93
|
+
|
|
94
|
+
physical.split("/").reject(&:empty?)
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
lexical
|
|
97
|
+
end
|
|
98
|
+
private_class_method :climb
|
|
99
|
+
|
|
100
|
+
# How many symlinks .real follows on a path that doesn't exist
|
|
101
|
+
# (about the kernel's own ELOOP limit).
|
|
102
|
+
MAX_LINKS = 40
|
|
103
|
+
|
|
104
|
+
# +path+ (.expand) with symlinks resolved on its longest existing
|
|
105
|
+
# parent: where the kernel lands for it. A part that is a dangling
|
|
106
|
+
# symlink is followed to its target (a write there creates the
|
|
107
|
+
# target), up to MAX_LINKS times; the missing tail is kept as given.
|
|
59
108
|
def self.real(path)
|
|
109
|
+
path = expand(path)
|
|
110
|
+
MAX_LINKS.times do
|
|
111
|
+
resolved = existing_prefix_real(path)
|
|
112
|
+
return resolved unless resolved.is_a?(Array)
|
|
113
|
+
|
|
114
|
+
path = resolved.first
|
|
115
|
+
end
|
|
116
|
+
path
|
|
117
|
+
rescue SystemCallError
|
|
118
|
+
path
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The old, lexical answer: File.expand_path (lexical `..`), then
|
|
122
|
+
# realpath of the longest existing prefix, stopping at a dangling
|
|
123
|
+
# symlink. .reals keeps it beside .real.
|
|
124
|
+
def self.lexical_real(path)
|
|
60
125
|
path = File.expand_path(path)
|
|
61
126
|
rest = []
|
|
62
127
|
current = path
|
|
@@ -69,11 +134,39 @@ module Samagotchi
|
|
|
69
134
|
path
|
|
70
135
|
end
|
|
71
136
|
|
|
137
|
+
# Every place +path+ may be judged as: .lexical_real (what these
|
|
138
|
+
# checks always looked at) and .real (where the kernel lands), one
|
|
139
|
+
# entry when they agree. A check that holds for any of them (a
|
|
140
|
+
# protected dir, a secret) or refuses unless all of them are inside
|
|
141
|
+
# (the project, tmp) refuses at least what it did with the lexical
|
|
142
|
+
# answer alone.
|
|
143
|
+
# @return [Array<String>]
|
|
144
|
+
def self.reals(path) = [lexical_real(path), real(path)].uniq
|
|
145
|
+
|
|
146
|
+
# realpath of +path+'s longest existing prefix with the rest
|
|
147
|
+
# appended (a String), or [the path with the first dangling symlink
|
|
148
|
+
# on the way replaced by its target] to resolve again.
|
|
149
|
+
def self.existing_prefix_real(path)
|
|
150
|
+
rest = []
|
|
151
|
+
current = path
|
|
152
|
+
until File.exist?(current) || current == File.dirname(current)
|
|
153
|
+
if File.symlink?(current)
|
|
154
|
+
target = expand(File.readlink(current), File.dirname(current))
|
|
155
|
+
return [expand(File.join(target, *rest))]
|
|
156
|
+
end
|
|
157
|
+
rest.unshift(File.basename(current))
|
|
158
|
+
current = File.dirname(current)
|
|
159
|
+
end
|
|
160
|
+
File.join(File.realpath(current), *rest)
|
|
161
|
+
end
|
|
162
|
+
private_class_method :existing_prefix_real
|
|
163
|
+
|
|
164
|
+
# Whether +path+ (resolved) is +root+ or in it, +root+ resolved
|
|
165
|
+
# either way (.reals).
|
|
72
166
|
def self.within?(path, root)
|
|
73
167
|
return false if root.nil? || root.to_s.empty?
|
|
74
168
|
|
|
75
|
-
|
|
76
|
-
path == root || path.start_with?(File.join(root, ""))
|
|
169
|
+
reals(root.to_s.chomp("/")).any? { |dir| path == dir || path.start_with?(File.join(dir, "")) }
|
|
77
170
|
end
|
|
78
171
|
end
|
|
79
172
|
end
|