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,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Runtime
|
|
7
|
+
module Molecules
|
|
8
|
+
# Decodes actual Linux mountinfo records. Paths alone do not select a
|
|
9
|
+
# visible mount; callers must use the pinned descriptor's mnt_id.
|
|
10
|
+
class LinuxMountInfo
|
|
11
|
+
LIMIT = 1_048_576
|
|
12
|
+
ESCAPES = {"040" => " ", "011" => "\t", "012" => "\n", "134" => "\\"}.freeze
|
|
13
|
+
attr_reader :records
|
|
14
|
+
|
|
15
|
+
def initialize(bytes)
|
|
16
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, LIMIT) && !bytes.include?("\0")
|
|
17
|
+
unavailable!("kernel mount table is unavailable")
|
|
18
|
+
end
|
|
19
|
+
@records = bytes.lines.map do |line|
|
|
20
|
+
fields = line.chomp.split(" ")
|
|
21
|
+
separator = fields.index("-")
|
|
22
|
+
unless separator && separator >= 6 && fields.size == separator + 4 &&
|
|
23
|
+
fields[0].match?(/\A[1-9][0-9]*\z/) && fields[1].match?(/\A[0-9]+\z/) &&
|
|
24
|
+
fields[2].match?(/\A[0-9]+:[0-9]+\z/) &&
|
|
25
|
+
fields[6...separator].all? { |field| field == "unbindable" || field.match?(/\A(?:shared|master|propagate_from):[1-9][0-9]*\z/) }
|
|
26
|
+
unavailable!("kernel mount table is malformed")
|
|
27
|
+
end
|
|
28
|
+
root, mountpoint = fields.values_at(3, 4).map { |value| decode(value) }
|
|
29
|
+
filesystem = fields[separator + 1]
|
|
30
|
+
root_valid = root.start_with?("/") && File.expand_path(root) == root
|
|
31
|
+
root_valid ||= filesystem == "nsfs" && root.match?(/\A(?:net|mnt|user|pid|uts|ipc|cgroup|time):\[[1-9][0-9]*\]\z/)
|
|
32
|
+
unless root_valid && mountpoint.start_with?("/") && File.expand_path(mountpoint) == mountpoint &&
|
|
33
|
+
filesystem.match?(/\A[a-zA-Z0-9_.-]+\z/)
|
|
34
|
+
unavailable!("kernel mount paths/filesystem are malformed")
|
|
35
|
+
end
|
|
36
|
+
{"mount_id" => Integer(fields[0], 10), "parent_id" => Integer(fields[1], 10),
|
|
37
|
+
"major_minor" => fields[2], "root" => root, "mountpoint" => mountpoint,
|
|
38
|
+
"options" => options(fields[5]), "optional_fields" => fields[6...separator],
|
|
39
|
+
"filesystem_type" => fields[separator + 1], "source" => decode(fields[separator + 2]),
|
|
40
|
+
"super_options" => options(fields[separator + 3])}.then { |record| freeze_tree(record) }
|
|
41
|
+
end.freeze
|
|
42
|
+
unless records.map { |record| record.fetch("mount_id") }.uniq.size == records.size
|
|
43
|
+
unavailable!("kernel mount IDs are duplicate")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def by_id(mount_id)
|
|
48
|
+
record = records.find { |entry| entry.fetch("mount_id") == mount_id }
|
|
49
|
+
unavailable!("pinned object mount is missing") unless record
|
|
50
|
+
record
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def filesystem_path(record, path)
|
|
54
|
+
unavailable!("namespace object has no ordinary backing filesystem path") unless record.fetch("filesystem_type") != "nsfs" && record.fetch("root").start_with?("/")
|
|
55
|
+
prefix = record.fetch("mountpoint")
|
|
56
|
+
unless path.is_a?(String) && File.expand_path(path) == path &&
|
|
57
|
+
(path == prefix || prefix == "/" && path.start_with?("/") || path.start_with?(prefix + "/"))
|
|
58
|
+
unavailable!("object is outside its descriptor mount")
|
|
59
|
+
end
|
|
60
|
+
relative = prefix == "/" ? path.delete_prefix("/") : path.delete_prefix(prefix).delete_prefix("/")
|
|
61
|
+
relative.empty? ? record.fetch("root") : File.join(record.fetch("root"), relative)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def freeze_tree(value)
|
|
67
|
+
case value
|
|
68
|
+
when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
|
|
69
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
70
|
+
end
|
|
71
|
+
value.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def options(value)
|
|
75
|
+
parts = value.split(",", -1)
|
|
76
|
+
unavailable!("kernel mount options are malformed") if parts.any?(&:empty?) || parts.uniq.size != parts.size
|
|
77
|
+
parts.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def decode(value)
|
|
81
|
+
value.gsub(/\\([^ ]*)/) do
|
|
82
|
+
remaining = Regexp.last_match(1)
|
|
83
|
+
result = +""
|
|
84
|
+
until remaining.empty?
|
|
85
|
+
code = remaining.slice(0, 3)
|
|
86
|
+
unavailable!("kernel mount path escape is malformed") unless ESCAPES.key?(code)
|
|
87
|
+
result << ESCAPES.fetch(code)
|
|
88
|
+
remaining = remaining.slice(3..)
|
|
89
|
+
literal, escaped = remaining.split("\\", 2)
|
|
90
|
+
result << literal
|
|
91
|
+
remaining = escaped || ""
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unavailable!(message) = raise(RuntimeUnavailableError, message)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
require "date"
|
|
6
|
+
require_relative "protected_artifact_set"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Runtime
|
|
10
|
+
module Molecules
|
|
11
|
+
# Authenticates bounded root-produced installation content. Actual policy
|
|
12
|
+
# comparison/flow observations belong to the trusted domain producer; the
|
|
13
|
+
# installed owner independently pins the nsfs object and supplies expected.
|
|
14
|
+
class NetworkInstallationEvidence
|
|
15
|
+
ID = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
|
|
16
|
+
SHA = /\A[0-9a-f]{64}\z/
|
|
17
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
18
|
+
FAMILIES = %w[ipv4 ipv6].freeze
|
|
19
|
+
PROFILE_KEYS = %w[version profile_id slot_id namespace_path families protected_controls loopback_tools
|
|
20
|
+
credential_profiles].freeze
|
|
21
|
+
REPORT_KEYS = %w[version report_id profile_id slot_id boot_id namespace_path namespace_identity profile_sha256
|
|
22
|
+
policy_export_sha256 observed_topology installer_artifact_sha256 producer_source_sha
|
|
23
|
+
completed_at checks].freeze
|
|
24
|
+
TRACE_KEYS = %w[version report_id check_id family target_id boot_id namespace_identity profile_sha256
|
|
25
|
+
policy_export_sha256 producer_artifact_sha256 observations].freeze
|
|
26
|
+
EXPECTED_KEYS = %w[slot_id namespace_path boot_id namespace_identity installer_artifact_sha256].freeze
|
|
27
|
+
|
|
28
|
+
def self.verify!(selection:, expected:)
|
|
29
|
+
new.verify!(selection: selection, expected: expected)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The injectable content reader is a source-test seam, never a runtime
|
|
33
|
+
# transport selector or alternate trust root.
|
|
34
|
+
def initialize(artifacts: ProtectedArtifactSet.new)
|
|
35
|
+
@artifacts = artifacts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify!(selection:, expected:)
|
|
39
|
+
object!(selection, %w[profile policy_export report installer_artifact])
|
|
40
|
+
expected!(expected)
|
|
41
|
+
@edges, @refs = Hash.new { |hash, path| hash[path] = [] }, {}
|
|
42
|
+
@artifacts.with do
|
|
43
|
+
selection.each_value { |ref| artifact!(ref) }
|
|
44
|
+
unless selection.fetch("installer_artifact").fetch("sha256") == expected.fetch("installer_artifact_sha256")
|
|
45
|
+
refuse!("selected network producer differs")
|
|
46
|
+
end
|
|
47
|
+
profile = json!(selection.fetch("profile"))
|
|
48
|
+
profile!(profile, expected, selection.fetch("profile").fetch("path"))
|
|
49
|
+
report = json!(selection.fetch("report"))
|
|
50
|
+
report!(report, profile, selection, expected)
|
|
51
|
+
graph_acyclic!
|
|
52
|
+
@artifacts.verify_unchanged!
|
|
53
|
+
result = report.slice("report_id", "boot_id", "slot_id", "namespace_path", "namespace_identity",
|
|
54
|
+
"profile_sha256", "policy_export_sha256", "installer_artifact_sha256")
|
|
55
|
+
result["report_sha256"] = selection.fetch("report").fetch("sha256")
|
|
56
|
+
immutable(result)
|
|
57
|
+
end
|
|
58
|
+
rescue SystemCallError, IOError, JSON::ParserError, IPAddr::Error, KeyError, TypeError, ArgumentError
|
|
59
|
+
refuse!("network installation evidence cannot be verified")
|
|
60
|
+
ensure
|
|
61
|
+
@edges = @refs = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def expected!(value)
|
|
67
|
+
object!(value, EXPECTED_KEYS)
|
|
68
|
+
id!(value.fetch("slot_id"))
|
|
69
|
+
path!(value.fetch("namespace_path"))
|
|
70
|
+
format!(value.fetch("boot_id"), UUID)
|
|
71
|
+
identity!(value.fetch("namespace_identity"))
|
|
72
|
+
format!(value.fetch("installer_artifact_sha256"), SHA)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def profile!(value, expected, profile_path)
|
|
76
|
+
object!(value, PROFILE_KEYS)
|
|
77
|
+
version!(value)
|
|
78
|
+
%w[profile_id slot_id].each { |key| id!(value.fetch(key)) }
|
|
79
|
+
path!(value.fetch("namespace_path"))
|
|
80
|
+
%w[slot_id namespace_path].each { |key| equal!(value.fetch(key), expected.fetch(key)) }
|
|
81
|
+
families = collection!(value.fetch("families"), "family", min: 2)
|
|
82
|
+
equal!(families.map { |entry| entry.fetch("family") }.sort, FAMILIES)
|
|
83
|
+
families.each do |entry|
|
|
84
|
+
object!(entry, %w[family mode])
|
|
85
|
+
enum!(entry.fetch("mode"), %w[enabled disabled])
|
|
86
|
+
end
|
|
87
|
+
controls = collection!(value.fetch("protected_controls"), "id")
|
|
88
|
+
tools = collection!(value.fetch("loopback_tools"), "id")
|
|
89
|
+
credentials = collection!(value.fetch("credential_profiles"), "id")
|
|
90
|
+
entries = families.size + controls.size + tools.size + credentials.size
|
|
91
|
+
refuse!("network profile collections are oversized") if entries > 256
|
|
92
|
+
controls.each { |entry| control!(entry) }
|
|
93
|
+
tools.each { |entry| loopback!(entry) }
|
|
94
|
+
credentials.each { |entry| credential!(entry, profile_path) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def control!(entry)
|
|
98
|
+
object!(entry, %w[id family kind address_ranges protocol ports])
|
|
99
|
+
id!(entry.fetch("id"))
|
|
100
|
+
enum!(entry.fetch("family"), FAMILIES)
|
|
101
|
+
enum!(entry.fetch("kind"), %w[host_process internal_process protected_storage])
|
|
102
|
+
enum!(entry.fetch("protocol"), %w[tcp udp])
|
|
103
|
+
ranges = array!(entry.fetch("address_ranges"), min: 1)
|
|
104
|
+
unique!(ranges)
|
|
105
|
+
ranges.each { |range| cidr!(range, entry.fetch("family")) }
|
|
106
|
+
ports = entry.fetch("ports")
|
|
107
|
+
unless ports == "all"
|
|
108
|
+
array!(ports, min: 1).each { |port| port!(port) }
|
|
109
|
+
unique!(ports)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def loopback!(entry)
|
|
114
|
+
object!(entry, %w[id family protocol address port artifact_sha256])
|
|
115
|
+
id!(entry.fetch("id"))
|
|
116
|
+
enum!(entry.fetch("family"), FAMILIES)
|
|
117
|
+
enum!(entry.fetch("protocol"), %w[tcp udp])
|
|
118
|
+
equal!(entry.fetch("address"), entry.fetch("family") == "ipv4" ? "127.0.0.1" : "::1")
|
|
119
|
+
port!(entry.fetch("port"))
|
|
120
|
+
format!(entry.fetch("artifact_sha256"), SHA)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def credential!(entry, profile_path)
|
|
124
|
+
object!(entry, %w[id kind helper_artifacts configuration_artifacts authority])
|
|
125
|
+
id!(entry.fetch("id"))
|
|
126
|
+
enum!(entry.fetch("kind"), %w[provider dns remote_git])
|
|
127
|
+
equal!(entry.fetch("authority"), "remote_only")
|
|
128
|
+
%w[helper_artifacts configuration_artifacts].each do |key|
|
|
129
|
+
refs = array!(entry.fetch(key))
|
|
130
|
+
unique!(refs.map { |ref| artifact!(ref, parent: profile_path); ref.fetch("path") })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def report!(value, profile, selection, expected)
|
|
135
|
+
object!(value, REPORT_KEYS)
|
|
136
|
+
version!(value)
|
|
137
|
+
%w[report_id boot_id].each { |key| format!(value.fetch(key), UUID) }
|
|
138
|
+
%w[profile_id slot_id].each { |key| id!(value.fetch(key)); equal!(value.fetch(key), profile.fetch(key)) }
|
|
139
|
+
%w[namespace_path boot_id namespace_identity installer_artifact_sha256].each do |key|
|
|
140
|
+
equal!(value.fetch(key), expected.fetch(key))
|
|
141
|
+
end
|
|
142
|
+
identity!(value.fetch("namespace_identity"))
|
|
143
|
+
%w[profile policy_export installer_artifact].each do |key|
|
|
144
|
+
format!(value.fetch("#{key}_sha256"), SHA)
|
|
145
|
+
equal!(value.fetch("#{key}_sha256"), selection.fetch(key).fetch("sha256"))
|
|
146
|
+
end
|
|
147
|
+
format!(value.fetch("producer_source_sha"), /\A[0-9a-f]{40}\z/)
|
|
148
|
+
timestamp!(value.fetch("completed_at"))
|
|
149
|
+
artifact!(value.fetch("observed_topology"), parent: selection.fetch("report").fetch("path"))
|
|
150
|
+
checks = array!(value.fetch("checks"), min: 1)
|
|
151
|
+
actual = checks.map do |check|
|
|
152
|
+
object!(check, %w[check_id family target_id outcome evidence])
|
|
153
|
+
id!(check.fetch("check_id"))
|
|
154
|
+
id!(check.fetch("target_id"))
|
|
155
|
+
enum!(check.fetch("family"), FAMILIES + ["all"])
|
|
156
|
+
equal!(check.fetch("outcome"), "pass")
|
|
157
|
+
check.values_at("check_id", "family", "target_id")
|
|
158
|
+
end
|
|
159
|
+
unique!(actual)
|
|
160
|
+
equal!(actual.sort, required_checks(profile).sort)
|
|
161
|
+
checks.each do |check|
|
|
162
|
+
ref = check.fetch("evidence")
|
|
163
|
+
trace!(json!(ref, parent: selection.fetch("report").fetch("path")), ref, check, value)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def required_checks(profile)
|
|
168
|
+
checks = [["effective_policy_export", "all", "policy"], ["namespace_identity", "all", "namespace"],
|
|
169
|
+
["untrusted_policy_mutation_denied", "all", "policy"]]
|
|
170
|
+
profile.fetch("families").each do |entry|
|
|
171
|
+
family = entry.fetch("family")
|
|
172
|
+
checks << ["unsolicited_ingress_denied", family, "ingress"]
|
|
173
|
+
if entry.fetch("mode") == "enabled"
|
|
174
|
+
%w[ordinary_internet_egress_allowed dns_response_allowed].each { |id|
|
|
175
|
+
checks << [id, family, id == "dns_response_allowed" ? "dns" : "internet"] }
|
|
176
|
+
else
|
|
177
|
+
checks << ["family_disabled_enforced", family, family]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
profile.fetch("protected_controls").each { |entry|
|
|
181
|
+
checks << ["host_internal_controls_denied", entry.fetch("family"), entry.fetch("id")] }
|
|
182
|
+
profile.fetch("loopback_tools").each { |entry|
|
|
183
|
+
checks << ["loopback_scope_only", entry.fetch("family"), entry.fetch("id")] }
|
|
184
|
+
profile.fetch("credential_profiles").each { |entry|
|
|
185
|
+
checks << ["credential_no_local_control", "all", entry.fetch("id")] }
|
|
186
|
+
checks
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def trace!(trace, reference, check, report)
|
|
190
|
+
object!(trace, TRACE_KEYS)
|
|
191
|
+
version!(trace)
|
|
192
|
+
%w[report_id boot_id namespace_identity profile_sha256 policy_export_sha256].each do |key|
|
|
193
|
+
equal!(trace.fetch(key), report.fetch(key))
|
|
194
|
+
end
|
|
195
|
+
%w[check_id family target_id].each { |key| equal!(trace.fetch(key), check.fetch(key)) }
|
|
196
|
+
equal!(trace.fetch("producer_artifact_sha256"), report.fetch("installer_artifact_sha256"))
|
|
197
|
+
array!(trace.fetch("observations"), min: 1, max: 256).each do |observation|
|
|
198
|
+
object!(observation, %w[kind artifact_refs summary])
|
|
199
|
+
enum!(observation.fetch("kind"), %w[policy_comparison flow namespace artifact_inspection])
|
|
200
|
+
object!(observation.fetch("summary"), %w[result procedure_id])
|
|
201
|
+
equal!(observation.fetch("summary").fetch("result"), "pass")
|
|
202
|
+
id!(observation.fetch("summary").fetch("procedure_id"))
|
|
203
|
+
refs = array!(observation.fetch("artifact_refs"), min: 1, max: 16)
|
|
204
|
+
refs.each { |ref| artifact!(ref, parent: reference.fetch("path")) }
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def artifact!(reference, parent: nil)
|
|
209
|
+
object!(reference, %w[path sha256 bytes])
|
|
210
|
+
path = reference.fetch("path")
|
|
211
|
+
path!(path)
|
|
212
|
+
format!(reference.fetch("sha256"), SHA)
|
|
213
|
+
size = reference.fetch("bytes")
|
|
214
|
+
unless size.is_a?(Integer) && size.between?(1, ProtectedArtifactSet::LIMIT)
|
|
215
|
+
refuse!("network artifact byte bound is invalid")
|
|
216
|
+
end
|
|
217
|
+
previous = @refs[path]
|
|
218
|
+
refuse!("network artifact references conflict") if previous && previous != reference
|
|
219
|
+
@refs[path] = reference
|
|
220
|
+
@edges[parent] << path if parent
|
|
221
|
+
@artifacts.read!(reference)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def json!(reference, parent: nil)
|
|
225
|
+
bytes = artifact!(reference, parent: parent).dup.force_encoding(Encoding::UTF_8)
|
|
226
|
+
refuse!("network evidence is not UTF-8") unless bytes.valid_encoding?
|
|
227
|
+
JSON.parse(bytes, max_nesting: 64, allow_nan: false, create_additions: false,
|
|
228
|
+
allow_duplicate_key: false, allow_comments: false,
|
|
229
|
+
allow_invalid_escape: false, allow_control_characters: false)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def graph_acyclic!
|
|
233
|
+
visited, pending = {}, {}
|
|
234
|
+
visit = lambda do |path|
|
|
235
|
+
refuse!("network artifact reference cycle") if pending[path]
|
|
236
|
+
return if visited[path]
|
|
237
|
+
pending[path] = true
|
|
238
|
+
@edges[path].each { |child| visit.call(child) }
|
|
239
|
+
pending.delete(path)
|
|
240
|
+
visited[path] = true
|
|
241
|
+
end
|
|
242
|
+
@refs.each_key { |path| visit.call(path) }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def cidr!(value, family)
|
|
246
|
+
refuse!("network address range is invalid") unless value.is_a?(String) &&
|
|
247
|
+
(match = /\A([^\/]+)\/(0|[1-9][0-9]{0,2})\z/.match(value))
|
|
248
|
+
ip = IPAddr.new(value)
|
|
249
|
+
bits = family == "ipv4" ? 32 : 128
|
|
250
|
+
refuse!("network address family or canonical CIDR differs") unless
|
|
251
|
+
(family == "ipv4" ? ip.ipv4? : ip.ipv6?) && match[2].to_i <= bits &&
|
|
252
|
+
"#{ip.to_s}/#{match[2]}" == value
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def timestamp!(value)
|
|
256
|
+
format!(value, /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|\+00:00)\z/)
|
|
257
|
+
DateTime.rfc3339(value)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def identity!(value)
|
|
261
|
+
object!(value, %w[device inode])
|
|
262
|
+
unless value.values.all? { |n| n.is_a?(Integer) && n.positive? }
|
|
263
|
+
refuse!("network namespace identity is invalid")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def path!(value)
|
|
268
|
+
refuse!("network artifact path is not canonical") unless value.is_a?(String) &&
|
|
269
|
+
value.valid_encoding? && !value.include?("\0") && value.start_with?("/") &&
|
|
270
|
+
!value.split("/").any? { |part| part == "." || part == ".." } && File.expand_path(value) == value
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def collection!(value, key, min: 0)
|
|
274
|
+
array!(value, min: min, max: 256)
|
|
275
|
+
value.each { |entry| refuse!("network collection entry is invalid") unless entry.is_a?(Hash) }
|
|
276
|
+
unique!(value.map { |entry| entry.fetch(key) })
|
|
277
|
+
value
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def object!(value, keys)
|
|
281
|
+
refuse!("network evidence schema is not closed") unless value.is_a?(Hash) && value.keys.sort == keys.sort
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def array!(value, min: 0, max: nil)
|
|
285
|
+
refuse!("network evidence collection is invalid") unless value.is_a?(Array) && value.size >= min &&
|
|
286
|
+
(max.nil? || value.size <= max)
|
|
287
|
+
value
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def unique!(values)
|
|
291
|
+
refuse!("network evidence entries are duplicate") unless values.uniq.size == values.size
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def version!(value) = equal!(value.fetch("version"), 1)
|
|
295
|
+
def id!(value) = format!(value, ID)
|
|
296
|
+
def format!(value, regex)
|
|
297
|
+
refuse!("network evidence identifier or format is invalid") unless value.is_a?(String) && regex.match?(value)
|
|
298
|
+
end
|
|
299
|
+
def enum!(value, values)
|
|
300
|
+
refuse!("network evidence enum is unsupported") unless values.include?(value)
|
|
301
|
+
end
|
|
302
|
+
def port!(value)
|
|
303
|
+
refuse!("network evidence port is invalid") unless value.is_a?(Integer) && value.between?(1, 65_535)
|
|
304
|
+
end
|
|
305
|
+
def equal!(actual, expected)
|
|
306
|
+
refuse!("network evidence context differs") unless same_value?(actual, expected)
|
|
307
|
+
end
|
|
308
|
+
def same_value?(actual, expected)
|
|
309
|
+
case expected
|
|
310
|
+
when Hash
|
|
311
|
+
actual.is_a?(Hash) && actual.keys.sort == expected.keys.sort &&
|
|
312
|
+
expected.all? { |key, value| same_value?(actual.fetch(key), value) }
|
|
313
|
+
when Array
|
|
314
|
+
actual.is_a?(Array) && actual.size == expected.size &&
|
|
315
|
+
expected.each_with_index.all? { |value, index| same_value?(actual[index], value) }
|
|
316
|
+
else actual.class == expected.class && actual == expected
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
def refuse!(message) = raise(RuntimeUnavailableError, message)
|
|
320
|
+
|
|
321
|
+
def immutable(value)
|
|
322
|
+
case value
|
|
323
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
324
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
325
|
+
when String then value.dup.freeze
|
|
326
|
+
else value.freeze
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "fiddle"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Runtime
|
|
9
|
+
module Molecules
|
|
10
|
+
# A PID is an address, not an identity. Keep only non-secret OS facts;
|
|
11
|
+
# command lines may contain credentials and are never journaled.
|
|
12
|
+
class ProcessIdentity
|
|
13
|
+
def initialize(executor: Open3, hostname: Socket.gethostname, birth_reader: nil)
|
|
14
|
+
@executor = executor
|
|
15
|
+
@hostname = hostname
|
|
16
|
+
@birth_reader = birth_reader || method(:native_birth)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def owner(shell_pid:, caller_pid:)
|
|
20
|
+
shell = capture(shell_pid)
|
|
21
|
+
return nil unless shell && caller_pid.is_a?(Integer) && caller_pid != shell_pid
|
|
22
|
+
|
|
23
|
+
seen = []
|
|
24
|
+
observations = []
|
|
25
|
+
current = caller_pid
|
|
26
|
+
child = nil
|
|
27
|
+
64.times do
|
|
28
|
+
return nil if seen.include?(current)
|
|
29
|
+
seen << current
|
|
30
|
+
identity = capture(current)
|
|
31
|
+
return nil unless identity && identity["uid"] == shell["uid"]
|
|
32
|
+
parent = parent_pid(current)
|
|
33
|
+
return nil unless parent
|
|
34
|
+
observations << [identity, parent]
|
|
35
|
+
child = identity
|
|
36
|
+
if parent == shell_pid
|
|
37
|
+
return nil unless capture(shell_pid) == shell && observations.all? { |saved, ppid| capture(saved["pid"]) == saved && parent_pid(saved["pid"]) == ppid }
|
|
38
|
+
return {"shell_identity" => shell, "process_identity" => child}
|
|
39
|
+
end
|
|
40
|
+
current = parent
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parent_pid(pid)
|
|
46
|
+
output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "ppid=", stdin_data: "")
|
|
47
|
+
value = output.strip
|
|
48
|
+
status.success? && value.match?(/\A[0-9]+\z/) ? value.to_i : nil
|
|
49
|
+
rescue SystemCallError, IOError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def capture(pid)
|
|
54
|
+
return nil unless pid.is_a?(Integer) && pid.positive?
|
|
55
|
+
|
|
56
|
+
before = @birth_reader.call(pid)
|
|
57
|
+
return nil unless before
|
|
58
|
+
output, status = @executor.capture2("ps", "-p", pid.to_s, "-o", "pid=,uid=,stat=,lstart=", stdin_data: "")
|
|
59
|
+
return nil unless status.success?
|
|
60
|
+
|
|
61
|
+
match = output.strip.match(/\A(\d+)\s+(\d+)\s+(\S+)\s+(.+)\z/)
|
|
62
|
+
return nil unless match && match[1].to_i == pid && !match[3].match?(/[ZX]/)
|
|
63
|
+
|
|
64
|
+
birth = @birth_reader.call(pid)
|
|
65
|
+
return nil unless birth && birth == before
|
|
66
|
+
|
|
67
|
+
{"pid" => pid, "uid" => match[2].to_i, "started_at" => birth, "host" => @hostname}
|
|
68
|
+
rescue SystemCallError, IOError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Linux start ticks are scoped to the boot; Darwin exposes microsecond
|
|
73
|
+
# process birth through libproc's documented proc_bsdinfo structure.
|
|
74
|
+
# Platforms lacking either exact facility remain unobservable.
|
|
75
|
+
def native_birth(pid)
|
|
76
|
+
if RUBY_PLATFORM.include?("linux")
|
|
77
|
+
stat = File.read("/proc/#{pid}/stat").b
|
|
78
|
+
# comm is parenthesized and may itself contain parentheses or
|
|
79
|
+
# newlines. The final closing parenthesis precedes numeric fields.
|
|
80
|
+
match = stat.match(/\A#{pid} \(.*\) ([^\n]+)\n?\z/m)
|
|
81
|
+
return nil unless match
|
|
82
|
+
fields = match[1].split
|
|
83
|
+
ticks = fields[19]
|
|
84
|
+
return nil unless fields[0]&.match?(/\A[A-Za-z]\z/) && ticks&.match?(/\A[0-9]+\z/)
|
|
85
|
+
boot = File.read("/proc/sys/kernel/random/boot_id").strip
|
|
86
|
+
return nil if boot.empty?
|
|
87
|
+
"linux:#{boot}:#{ticks}"
|
|
88
|
+
elsif RUBY_PLATFORM.include?("darwin")
|
|
89
|
+
library = Fiddle.dlopen("/usr/lib/libproc.dylib")
|
|
90
|
+
function = Fiddle::Function.new(library["proc_pidinfo"],
|
|
91
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
92
|
+
buffer = Fiddle::Pointer.malloc(136)
|
|
93
|
+
return nil unless function.call(pid, 3, 0, buffer, 136) == 136
|
|
94
|
+
seconds, micros = buffer[120, 16].unpack("Q2")
|
|
95
|
+
return nil unless seconds.positive? && micros < 1_000_000
|
|
96
|
+
"darwin:#{seconds}:#{micros}"
|
|
97
|
+
end
|
|
98
|
+
rescue SystemCallError, IOError, IndexError, Fiddle::DLError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def observe(identity)
|
|
103
|
+
at = Time.now.utc.iso8601
|
|
104
|
+
unless identity.is_a?(Hash) && identity["host"] == @hostname &&
|
|
105
|
+
identity["started_at"].is_a?(String) && identity["uid"].is_a?(Integer)
|
|
106
|
+
return {"liveness" => "unknown", "observed_at" => at, "reason" => "verified process identity unavailable"}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
live = capture(identity["pid"])
|
|
110
|
+
if live == identity
|
|
111
|
+
{"liveness" => "live", "observed_at" => at, "reason" => "process identity matches", "identity" => live}
|
|
112
|
+
elsif live
|
|
113
|
+
{"liveness" => "unknown", "observed_at" => at, "reason" => "process address reused"}
|
|
114
|
+
else
|
|
115
|
+
# An inaccessible ps result cannot prove death or absence of
|
|
116
|
+
# descendants. Recovery retains the worktree and writer slot.
|
|
117
|
+
{"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|