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.
@@ -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