landlock 0.1.0 → 0.2

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.
data/lib/landlock.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "landlock/version"
4
4
  require_relative "landlock/landlock"
5
+ require_relative "landlock/safe_exec"
5
6
 
6
7
  module Landlock
7
8
  class Error < StandardError; end
@@ -41,12 +42,18 @@ module Landlock
41
42
  connect_tcp: ACCESS_NET_CONNECT_TCP
42
43
  }.freeze
43
44
 
45
+ SCOPE_FLAGS = {
46
+ abstract_unix_socket: SCOPE_ABSTRACT_UNIX_SOCKET,
47
+ signal: SCOPE_SIGNAL
48
+ }.freeze
49
+
44
50
  READ_RIGHTS = %i[read_file read_dir].freeze
45
51
  EXEC_RIGHTS = %i[execute read_file read_dir].freeze
46
52
  WRITE_RIGHTS = %i[
47
53
  read_file read_dir write_file truncate remove_dir remove_file make_char
48
54
  make_dir make_reg make_sock make_fifo make_block make_sym refer
49
55
  ].freeze
56
+ FILE_PATH_RIGHTS = %i[execute write_file read_file truncate ioctl_dev].freeze
50
57
 
51
58
  module_function
52
59
 
@@ -56,18 +63,19 @@ module Landlock
56
63
  false
57
64
  end
58
65
 
59
- def restrict!(read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], allow_all_known: false)
66
+ def restrict!(read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], allow_all_known: false)
60
67
  abi = abi_version
61
68
  raise UnsupportedError, "Linux Landlock is unavailable" unless abi.positive?
62
69
 
63
70
  fs_handled = allow_all_known ? _fs_rights_for_abi(abi) : _handled_fs_for(read:, write:, execute:, paths:, abi:)
64
71
  net_handled = _handled_net_for(connect_tcp:, bind_tcp:, abi:)
72
+ scoped = _scope_for(scope:, abi:)
65
73
 
66
- if fs_handled.zero? && net_handled.zero?
67
- raise ArgumentError, "empty Landlock policy: provide filesystem paths or TCP ports"
74
+ if fs_handled.zero? && net_handled.zero? && scoped.zero?
75
+ raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes"
68
76
  end
69
77
 
70
- fd = _create_ruleset(fs_handled, net_handled)
78
+ fd = _create_ruleset(fs_handled, net_handled, scoped)
71
79
  begin
72
80
  add_path_rules(fd, read, READ_RIGHTS, abi)
73
81
  add_path_rules(fd, execute, EXEC_RIGHTS, abi)
@@ -75,7 +83,10 @@ module Landlock
75
83
 
76
84
  paths.each do |rule|
77
85
  path, rights = normalize_path_rule(rule)
78
- _add_path_rule(fd, File.expand_path(path), mask(rights, FS_RIGHTS, abi))
86
+ access_mask = mask(rights, FS_RIGHTS, abi)
87
+ next if access_mask.zero?
88
+
89
+ _add_path_rule(fd, File.expand_path(path), access_mask)
79
90
  end
80
91
 
81
92
  add_net_rules(fd, connect_tcp, [:connect_tcp], abi)
@@ -89,20 +100,19 @@ module Landlock
89
100
  true
90
101
  end
91
102
 
92
- def exec(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true)
93
- argv = Array(argv)
94
- raise ArgumentError, "argv must not be empty" if argv.empty?
103
+ def exec(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false)
104
+ argv = normalize_argv(argv)
105
+ ensure_landlock_supported!
95
106
 
96
107
  pid = fork do
97
- # Safe after fork: this runs only in the child process before exec.
98
- Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
99
- restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:)
100
-
101
- if env
102
- exec_env = unsetenv_others ? env : ENV.to_h.merge(env)
103
- Kernel.exec(exec_env, *argv, close_others: close_others)
104
- else
105
- Kernel.exec(*argv, close_others: close_others)
108
+ begin
109
+ # Safe after fork: this runs only in the child process before exec.
110
+ Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
111
+ restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
112
+
113
+ Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:))
114
+ rescue Exception => error
115
+ exit_child!(error)
106
116
  end
107
117
  end
108
118
 
@@ -110,35 +120,85 @@ module Landlock
110
120
  status
111
121
  end
112
122
 
113
- def spawn(argv, **opts)
114
- argv = Array(argv)
115
- raise ArgumentError, "argv must not be empty" if argv.empty?
123
+ def spawn(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false)
124
+ argv = normalize_argv(argv)
125
+ ensure_landlock_supported!
116
126
 
117
127
  fork do
118
- # Safe after fork: this runs only in the child process before exec.
119
- Dir.chdir(opts[:chdir]) if opts[:chdir] # rubocop:disable Discourse/NoChdir
120
- restrict!(
121
- read: opts.fetch(:read, []),
122
- write: opts.fetch(:write, []),
123
- execute: opts.fetch(:execute, []),
124
- connect_tcp: opts.fetch(:connect_tcp, []),
125
- bind_tcp: opts.fetch(:bind_tcp, []),
126
- paths: opts.fetch(:paths, [])
127
- )
128
- Kernel.exec(*argv, close_others: opts.fetch(:close_others, true))
128
+ begin
129
+ # Safe after fork: this runs only in the child process before exec.
130
+ Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
131
+ restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
132
+ Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:))
133
+ rescue Exception => error
134
+ exit_child!(error)
135
+ end
136
+ end
137
+ end
138
+
139
+ def normalize_argv(argv)
140
+ raise ArgumentError, "argv must be an Array of command arguments" unless argv.is_a?(Array)
141
+ raise ArgumentError, "argv must not be empty" if argv.empty?
142
+
143
+ argv
144
+ end
145
+ private_class_method :normalize_argv
146
+
147
+ def argv_for_exec(argv)
148
+ command = argv.fetch(0)
149
+ [[command, command], *argv.drop(1)]
150
+ end
151
+ private_class_method :argv_for_exec
152
+
153
+ def kernel_exec_args(argv, env, unsetenv_others:, close_others:)
154
+ exec_options = { close_others: close_others }
155
+ exec_options[:unsetenv_others] = true if unsetenv_others
156
+
157
+ if env
158
+ [env, *argv_for_exec(argv), exec_options]
159
+ else
160
+ [*argv_for_exec(argv), exec_options]
129
161
  end
130
162
  end
163
+ private_class_method :kernel_exec_args
164
+
165
+ def ensure_landlock_supported!
166
+ raise UnsupportedError, "Linux Landlock is unavailable" unless abi_version.positive?
167
+ end
168
+ private_class_method :ensure_landlock_supported!
169
+
170
+ def exit_child!(error)
171
+ warn "Landlock child failed before exec: #{error.class}: #{error.message}"
172
+ ensure
173
+ exit! 127
174
+ end
175
+ private_class_method :exit_child!
176
+
177
+ def path_rights(path, rights)
178
+ File.directory?(path) ? rights : Array(rights) & FILE_PATH_RIGHTS
179
+ end
180
+ private_class_method :path_rights
131
181
 
132
182
  def add_path_rules(fd, paths, rights, abi)
133
- Array(paths).each { |path| _add_path_rule(fd, File.expand_path(path), mask(rights, FS_RIGHTS, abi)) }
183
+ Array(paths).each do |path|
184
+ expanded_path = File.expand_path(path)
185
+ access_mask = mask(path_rights(expanded_path, rights), FS_RIGHTS, abi)
186
+ next if access_mask.zero?
187
+
188
+ _add_path_rule(fd, expanded_path, access_mask)
189
+ end
134
190
  end
135
191
  private_class_method :add_path_rules
136
192
 
137
193
  def add_net_rules(fd, ports, rights, abi)
138
- return if Array(ports).empty?
194
+ ports = Array(ports)
195
+ return if ports.empty?
139
196
  raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4
140
197
 
141
- Array(ports).each { |port| _add_net_rule(fd, Integer(port), mask(rights, NET_RIGHTS, abi)) }
198
+ access_mask = mask(rights, NET_RIGHTS, abi)
199
+ return if access_mask.zero?
200
+
201
+ ports.each { |port| _add_net_rule(fd, Integer(port), access_mask) }
142
202
  end
143
203
  private_class_method :add_net_rules
144
204
 
@@ -192,4 +252,13 @@ module Landlock
192
252
  bits
193
253
  end
194
254
  private_class_method :_handled_net_for
255
+
256
+ def _scope_for(scope:, abi:)
257
+ bits = mask(scope, SCOPE_FLAGS, abi)
258
+ return 0 if bits.zero?
259
+ raise UnsupportedError, "Landlock scopes require ABI v6+; running ABI v#{abi}" if abi < 6
260
+
261
+ bits
262
+ end
263
+ private_class_method :_scope_for
195
264
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: landlock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: '0.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Saffron
@@ -74,11 +74,16 @@ extensions:
74
74
  - ext/landlock/extconf.rb
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - CHANGELOG.md
77
78
  - LICENSE.txt
78
79
  - README.md
80
+ - benchmark/landlock_overhead.rb
81
+ - ext/landlock/bin/safe_exec_helper.c
79
82
  - ext/landlock/extconf.rb
80
83
  - ext/landlock/landlock.c
84
+ - ext/landlock/landlock_native.h
81
85
  - lib/landlock.rb
86
+ - lib/landlock/safe_exec.rb
82
87
  - lib/landlock/version.rb
83
88
  homepage: https://github.com/discourse/ruby-landlock
84
89
  licenses: