ace-runtime 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +9 -0
- data/docs/usage.md +68 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +309 -0
- data/lib/ace/runtime/molecules/linux_mount_info.rb +101 -0
- data/lib/ace/runtime/molecules/network_installation_evidence.rb +332 -0
- data/lib/ace/runtime/molecules/process_identity.rb +123 -0
- data/lib/ace/runtime/molecules/protected_artifact_set.rb +146 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +97 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +323 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +2 -0
- data/native/README.md +69 -0
- data/native/build-worker-gate +18 -0
- data/native/worker_gate.c +199 -0
- metadata +54 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "cgroup_observation"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "protected_socket"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
module Molecules
|
|
11
|
+
# Content evidence only. Retains every artifact and ancestor descriptor for
|
|
12
|
+
# one bounded verification, then rechecks their identities before closing.
|
|
13
|
+
class ProtectedArtifactSet
|
|
14
|
+
LIMIT = 1_048_576
|
|
15
|
+
TOTAL_LIMIT = 16 * LIMIT
|
|
16
|
+
COUNT_LIMIT = 256
|
|
17
|
+
|
|
18
|
+
class Protection
|
|
19
|
+
# These local Linux filesystems enforce POSIX mode/ACL mask semantics.
|
|
20
|
+
# Other models (including remote/FUSE filesystems) are not presumed safe.
|
|
21
|
+
FILESYSTEMS = %w[ext2 ext3 ext4 xfs btrfs tmpfs].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(mounts: CgroupObservation::KernelFiles.new)
|
|
24
|
+
@mounts = mounts
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def root_path!(path)
|
|
28
|
+
ProtectedSocket.root_path!(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def verify!(path, handle, directory:)
|
|
32
|
+
stat = handle.stat
|
|
33
|
+
unless trusted_owner?(stat) && (stat.mode & 0o022).zero? &&
|
|
34
|
+
(directory ? stat.directory? : stat.file?)
|
|
35
|
+
raise RuntimeUnavailableError, "network artifact ownership, mode or kind is unsafe"
|
|
36
|
+
end
|
|
37
|
+
unless FILESYSTEMS.include?(@mounts.mount_identity(handle).fetch("filesystem_type"))
|
|
38
|
+
raise RuntimeUnavailableError, "network artifact filesystem protection is unsupported"
|
|
39
|
+
end
|
|
40
|
+
# On these filesystems group bits are the access ACL mask: with no
|
|
41
|
+
# group/other write, no named non-root user/group can write either.
|
|
42
|
+
# Default ACLs grant no access to the existing descriptor. Every
|
|
43
|
+
# referenced file and ancestor is checked, including inherited mode.
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def trusted_owner?(stat) = stat.uid.zero?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(protection: Protection.new)
|
|
52
|
+
@protection = protection
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with
|
|
56
|
+
@handles, @references, @contents, @total = {}, {}, {}, 0
|
|
57
|
+
yield self
|
|
58
|
+
ensure
|
|
59
|
+
@handles&.each_value { |entry| entry.fetch(:handle).close }
|
|
60
|
+
@handles = @references = @contents = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read!(reference)
|
|
64
|
+
path = reference.fetch("path")
|
|
65
|
+
previous = @references[path]
|
|
66
|
+
if previous
|
|
67
|
+
raise RuntimeUnavailableError, "network artifact references conflict" unless previous == reference
|
|
68
|
+
return @contents.fetch(path)
|
|
69
|
+
end
|
|
70
|
+
raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= COUNT_LIMIT
|
|
71
|
+
size = reference.fetch("bytes")
|
|
72
|
+
unless size.is_a?(Integer) && size.between?(1, LIMIT) && @total + size <= TOTAL_LIMIT
|
|
73
|
+
raise RuntimeUnavailableError, "network artifact bytes exceed bounds"
|
|
74
|
+
end
|
|
75
|
+
@protection.root_path!(path)
|
|
76
|
+
ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
|
|
77
|
+
handle = pin!(path, directory: false)
|
|
78
|
+
unless handle.stat.size == size
|
|
79
|
+
raise RuntimeUnavailableError, "network artifact declared length differs"
|
|
80
|
+
end
|
|
81
|
+
bytes = handle.read(size + 1)
|
|
82
|
+
unless bytes.is_a?(String) && bytes.bytesize == size &&
|
|
83
|
+
Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
|
|
84
|
+
raise RuntimeUnavailableError, "network artifact content differs"
|
|
85
|
+
end
|
|
86
|
+
@references[path] = reference.dup.freeze
|
|
87
|
+
@contents[path] = bytes.freeze
|
|
88
|
+
@total += size
|
|
89
|
+
bytes
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def verify_unchanged!
|
|
93
|
+
@handles.each do |path, entry|
|
|
94
|
+
handle = entry.fetch(:handle)
|
|
95
|
+
unless snapshot(handle.stat) == entry.fetch(:snapshot) &&
|
|
96
|
+
snapshot(File.lstat(path)) == entry.fetch(:snapshot)
|
|
97
|
+
raise RuntimeUnavailableError, "network artifact changed during verification"
|
|
98
|
+
end
|
|
99
|
+
@protection.verify!(path, handle, directory: entry.fetch(:directory))
|
|
100
|
+
end
|
|
101
|
+
true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def ancestors(path)
|
|
107
|
+
result = []
|
|
108
|
+
current = File.dirname(path)
|
|
109
|
+
loop do
|
|
110
|
+
result << current
|
|
111
|
+
break if current == "/"
|
|
112
|
+
current = File.dirname(current)
|
|
113
|
+
end
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def pin!(path, directory:)
|
|
118
|
+
return @handles.fetch(path).fetch(:handle) if @handles.key?(path)
|
|
119
|
+
stat = File.lstat(path)
|
|
120
|
+
unless !stat.symlink? && (directory ? stat.directory? : stat.file?)
|
|
121
|
+
raise RuntimeUnavailableError, "network artifact kind or link is unsafe"
|
|
122
|
+
end
|
|
123
|
+
# NONBLOCK also prevents blocking if a hostile special file replaces a
|
|
124
|
+
# regular file between lstat and open; descriptor type is checked below.
|
|
125
|
+
handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
126
|
+
begin
|
|
127
|
+
handle.close_on_exec = true
|
|
128
|
+
@protection.verify!(path, handle, directory: directory)
|
|
129
|
+
unless snapshot(stat) == snapshot(handle.stat) && snapshot(File.lstat(path)) == snapshot(handle.stat)
|
|
130
|
+
raise RuntimeUnavailableError, "network artifact changed while opening"
|
|
131
|
+
end
|
|
132
|
+
@handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat)}
|
|
133
|
+
handle
|
|
134
|
+
rescue Exception
|
|
135
|
+
handle.close
|
|
136
|
+
raise
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def snapshot(stat)
|
|
141
|
+
[stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fiddle"
|
|
5
|
+
require_relative "process_identity"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# Kernel observations for protected launch. No absence inferred from ps.
|
|
11
|
+
class ProtectedLinux
|
|
12
|
+
def initialize(identity: ProcessIdentity.new)
|
|
13
|
+
@identity = identity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def supported!
|
|
17
|
+
unless RUBY_PLATFORM.include?("linux") && File.read("/proc/sys/kernel/yama/ptrace_scope").strip == "2"
|
|
18
|
+
raise RuntimeUnavailableError, "protected launch requires Linux Yama ptrace_scope=2"
|
|
19
|
+
end
|
|
20
|
+
probe = pidfd_handle(Process.pid)
|
|
21
|
+
raise RuntimeUnavailableError, "pidfd self observation is invalid" if exited?(probe)
|
|
22
|
+
true
|
|
23
|
+
rescue SystemCallError, IOError
|
|
24
|
+
raise RuntimeUnavailableError, "protected process policy is unavailable"
|
|
25
|
+
ensure
|
|
26
|
+
probe&.close
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def capture(pid)
|
|
30
|
+
before = @identity.capture(pid)
|
|
31
|
+
raise RuntimeUnavailableError, "exact kernel process is unavailable" unless before
|
|
32
|
+
fields = File.readlines("/proc/#{pid}/status").to_h { |line| line.strip.split(/:\s*/, 2) }
|
|
33
|
+
ids = %w[Uid Gid].to_h { |name| [name, fields.fetch(name).split.map { |v| Integer(v, 10) }] }
|
|
34
|
+
caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].to_h { |name| [name, Integer(fields.fetch(name), 16)] }
|
|
35
|
+
unless @identity.capture(pid) == before && ids.fetch("Uid").first == before.fetch("uid") && ids.values.all? { |v| v.size == 4 && v.uniq.size == 1 } &&
|
|
36
|
+
caps.values.all?(&:zero?) && fields.fetch("NoNewPrivs") == "1"
|
|
37
|
+
raise RuntimeUnavailableError, "process identity or capability policy is unsafe"
|
|
38
|
+
end
|
|
39
|
+
before.merge("gid" => ids.fetch("Gid").first,
|
|
40
|
+
"groups" => fields.fetch("Groups", "").split.map { |v| Integer(v, 10) }.sort,
|
|
41
|
+
"parent_pid" => Integer(fields.fetch("PPid"), 10))
|
|
42
|
+
rescue SystemCallError, KeyError, ArgumentError
|
|
43
|
+
raise RuntimeUnavailableError, "kernel process policy is unreadable"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def peer(socket)
|
|
47
|
+
raise RuntimeUnavailableError, "protected socket peers require Linux" unless RUBY_PLATFORM.include?("linux")
|
|
48
|
+
pid, uid, gid = socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
|
|
49
|
+
value = capture(pid)
|
|
50
|
+
unless value["uid"] == uid && value["gid"] == gid
|
|
51
|
+
raise RuntimeUnavailableError, "socket peer changed kernel identity"
|
|
52
|
+
end
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pin(identity)
|
|
57
|
+
handle = pidfd_handle(identity.fetch("pid"))
|
|
58
|
+
begin
|
|
59
|
+
unless same?(identity, capture(identity.fetch("pid")))
|
|
60
|
+
raise RuntimeUnavailableError, "process changed while acquiring pidfd"
|
|
61
|
+
end
|
|
62
|
+
handle
|
|
63
|
+
rescue Exception
|
|
64
|
+
handle.close
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def same?(left, right)
|
|
70
|
+
%w[pid uid gid started_at host groups parent_pid].all? { |key| left[key] == right[key] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Socket peer ancestry, with every PID incarnation rechecked after the
|
|
74
|
+
# walk. Same UID alone never attributes a sibling to the native child.
|
|
75
|
+
def descendant?(identity, ancestor)
|
|
76
|
+
observations = []
|
|
77
|
+
current = identity.fetch("pid")
|
|
78
|
+
64.times do
|
|
79
|
+
return false if observations.any? { |item| item.fetch("pid") == current }
|
|
80
|
+
observed = capture(current)
|
|
81
|
+
return false unless observed["uid"] == ancestor["uid"]
|
|
82
|
+
return false if observations.empty? && !same?(identity, observed)
|
|
83
|
+
observations << observed
|
|
84
|
+
if current == ancestor.fetch("pid")
|
|
85
|
+
return same?(ancestor, observed) && observations.all? { |saved| same?(saved, capture(saved.fetch("pid"))) }
|
|
86
|
+
end
|
|
87
|
+
current = observed.fetch("parent_pid")
|
|
88
|
+
return false unless current.is_a?(Integer) && current.positive?
|
|
89
|
+
end
|
|
90
|
+
false
|
|
91
|
+
rescue RuntimeUnavailableError, KeyError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def live!(identity)
|
|
96
|
+
raise RuntimeUnavailableError, "exact process identity changed" unless same?(identity, capture(identity.fetch("pid")))
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def exited?(handle, timeout: 0)
|
|
101
|
+
!IO.select([handle], nil, nil, timeout).nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def pidfd_handle(pid)
|
|
107
|
+
library = Fiddle.dlopen(nil)
|
|
108
|
+
call = Fiddle::Function.new(library["pidfd_open"], [Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
109
|
+
fd = call.call(pid, 0)
|
|
110
|
+
raise RuntimeUnavailableError, "exact pidfd is unavailable" if fd.negative?
|
|
111
|
+
IO.for_fd(fd, autoclose: true)
|
|
112
|
+
rescue Fiddle::DLError
|
|
113
|
+
raise RuntimeUnavailableError, "exact pidfd capability is unsupported"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
module ProtectedSocket
|
|
9
|
+
LIMIT = 65_536
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def root_path!(path, directory: false, owner: 0)
|
|
13
|
+
unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path
|
|
14
|
+
raise RuntimeUnavailableError, "protected path must be canonical and absolute"
|
|
15
|
+
end
|
|
16
|
+
current = path
|
|
17
|
+
loop do
|
|
18
|
+
stat = File.lstat(current)
|
|
19
|
+
unless !stat.symlink? && (current == path ? stat.uid == owner : [0, owner].include?(stat.uid)) && (stat.mode & 0o022).zero?
|
|
20
|
+
raise RuntimeUnavailableError, "protected path ownership or ancestry is unsafe"
|
|
21
|
+
end
|
|
22
|
+
if current != path && !stat.directory?
|
|
23
|
+
raise RuntimeUnavailableError, "protected ancestor is not a directory"
|
|
24
|
+
end
|
|
25
|
+
if current == path && directory && !stat.directory?
|
|
26
|
+
raise RuntimeUnavailableError, "protected root is not a directory"
|
|
27
|
+
end
|
|
28
|
+
break if current == "/"
|
|
29
|
+
current = File.dirname(current)
|
|
30
|
+
end
|
|
31
|
+
true
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
raise RuntimeUnavailableError, "protected path is unavailable"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def connect(path, deadline: self.deadline)
|
|
37
|
+
socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_STREAM, 0)
|
|
38
|
+
socket.close_on_exec = true
|
|
39
|
+
result = socket.connect_nonblock(Socket.sockaddr_un(path), exception: false)
|
|
40
|
+
if result == :wait_writable
|
|
41
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
unless remaining.positive? && IO.select(nil, [socket], nil, remaining) && socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
|
|
43
|
+
raise RuntimeUnavailableError, "protected socket connection deadline expired"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
yield socket
|
|
47
|
+
ensure
|
|
48
|
+
socket&.close unless socket&.closed?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def socket_identity(path)
|
|
52
|
+
stat = File.lstat(path)
|
|
53
|
+
raise RuntimeUnavailableError, "protected endpoint is not a socket" unless stat.socket? && !stat.symlink?
|
|
54
|
+
[stat.dev, stat.ino, stat.uid]
|
|
55
|
+
rescue SystemCallError
|
|
56
|
+
raise RuntimeUnavailableError, "protected endpoint is unavailable"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read(socket, deadline:, limit: LIMIT, with_size: false)
|
|
60
|
+
buffer = +""
|
|
61
|
+
loop do
|
|
62
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
63
|
+
raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
|
|
64
|
+
IO.select([socket], nil, nil, remaining)
|
|
65
|
+
chunk = socket.read_nonblock(1, exception: false)
|
|
66
|
+
next if chunk == :wait_readable
|
|
67
|
+
raise RuntimeUnavailableError, "protected socket closed before response" unless chunk
|
|
68
|
+
buffer << chunk
|
|
69
|
+
raise RuntimeUnavailableError, "protected socket frame is oversized" if buffer.bytesize > limit
|
|
70
|
+
break if chunk == "\n"
|
|
71
|
+
end
|
|
72
|
+
value = JSON.parse(buffer)
|
|
73
|
+
with_size ? {data: value, bytesize: buffer.bytesize} : value
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
raise RuntimeUnavailableError, "protected socket frame is malformed"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write(socket, value, deadline:, limit: LIMIT)
|
|
79
|
+
bytes = JSON.generate(value) + "\n"
|
|
80
|
+
raise RuntimeUnavailableError, "protected socket frame is oversized" if bytes.bytesize > limit
|
|
81
|
+
until bytes.empty?
|
|
82
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
raise RuntimeUnavailableError, "protected socket deadline expired" unless remaining.positive? &&
|
|
84
|
+
IO.select(nil, [socket], nil, remaining)
|
|
85
|
+
sent = socket.write_nonblock(bytes, exception: false)
|
|
86
|
+
next if sent == :wait_writable
|
|
87
|
+
bytes = bytes.byteslice(sent..)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def deadline(seconds = 5)
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|