ace-runtime 0.1.1 → 0.3.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 +58 -0
- data/README.md +9 -0
- data/docs/usage.md +123 -0
- data/lib/ace/runtime/molecules/cgroup_observation.rb +148 -0
- data/lib/ace/runtime/molecules/codex_runtime_installation.rb +130 -0
- data/lib/ace/runtime/molecules/execution_boot_baseline.rb +134 -0
- data/lib/ace/runtime/molecules/execution_network_selection.rb +85 -0
- data/lib/ace/runtime/molecules/execution_unit_installation.rb +663 -0
- data/lib/ace/runtime/molecules/kernel_view_topology.rb +133 -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 +211 -0
- data/lib/ace/runtime/molecules/protected_linux.rb +118 -0
- data/lib/ace/runtime/molecules/protected_socket.rb +119 -0
- data/lib/ace/runtime/molecules/protected_task_context_entry.rb +162 -0
- data/lib/ace/runtime/molecules/protected_worker_entry.rb +32 -0
- data/lib/ace/runtime/molecules/readiness_configuration.rb +154 -0
- data/lib/ace/runtime/molecules/readiness_runtime.rb +94 -0
- data/lib/ace/runtime/molecules/server_resource_observation.rb +183 -0
- data/lib/ace/runtime/molecules/systemd_scope_manager.rb +411 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +9 -0
- data/lib/ace/runtime/version.rb +1 -1
- data/lib/ace/runtime.rb +3 -0
- data/native/README.md +124 -0
- data/native/build-worker-gate +20 -0
- data/native/worker_gate.c +385 -0
- metadata +63 -2
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "server_resource_observation"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Runtime
|
|
6
|
+
module Molecules
|
|
7
|
+
# Pure verification of the fixed supported unit's live report. Installed
|
|
8
|
+
# profile verification and original host proof authentication are separate.
|
|
9
|
+
class KernelViewTopology
|
|
10
|
+
VIEWS = ServerResourceObservation::VIEW_PATHS
|
|
11
|
+
MOUNT_FIELDS = ServerResourceObservation::MOUNT_FIELDS.sort.freeze
|
|
12
|
+
READONLY = %w[/proc/sys /sys /sys/fs/cgroup /dev /dev/shm].freeze
|
|
13
|
+
APIS = {"/proc" => "proc", "/dev/pts" => "devpts", "/dev/mqueue" => "mqueue"}.freeze
|
|
14
|
+
|
|
15
|
+
def verify!(topology:, host_ipc:, authority_socket:, writable_resources:, host_devpts:, selected_devpts:)
|
|
16
|
+
object!(topology, %w[authority_socket_identity hook_ipc_namespace_identity ipc_namespace_identity mounts ptmx_identity ptmx_link views])
|
|
17
|
+
ipc = topology.fetch("ipc_namespace_identity")
|
|
18
|
+
namespace!(ipc)
|
|
19
|
+
namespace!(topology.fetch("hook_ipc_namespace_identity"))
|
|
20
|
+
namespace!(host_ipc)
|
|
21
|
+
refuse! unless ipc == topology.fetch("hook_ipc_namespace_identity") && ipc != host_ipc
|
|
22
|
+
socket = topology.fetch("authority_socket_identity")
|
|
23
|
+
refuse! unless socket.is_a?(Array) && socket.size == 3 && socket.all? { |n| n.is_a?(Integer) && n >= 0 } &&
|
|
24
|
+
socket.first(2).all?(&:positive?) && socket == authority_socket
|
|
25
|
+
mounts = topology.fetch("mounts")
|
|
26
|
+
refuse! unless mounts.is_a?(Array) && mounts.size.between?(1, 256)
|
|
27
|
+
mounts.each { |mount| mount!(mount) }
|
|
28
|
+
refuse! unless mounts.map { |row| row.fetch("mount_id") }.uniq.size == mounts.size
|
|
29
|
+
roots = mounts.select { |row| row.fetch("mountpoint") == "/" }
|
|
30
|
+
refuse! unless !roots.empty? && roots.all? { |row| readonly?(row) }
|
|
31
|
+
views = topology.fetch("views")
|
|
32
|
+
refuse! unless views.is_a?(Array) && views.size == VIEWS.size && views.map { |view| view.fetch("path") }.sort == VIEWS.sort
|
|
33
|
+
by_path = views.to_h do |view|
|
|
34
|
+
object!(view, %w[device inode mount_id path type])
|
|
35
|
+
refuse! unless view["type"] == "directory" && %w[device inode mount_id].all? { |key| positive?(view[key]) }
|
|
36
|
+
row = mounts.find { |mount| mount.fetch("mount_id") == view.fetch("mount_id") }
|
|
37
|
+
refuse! unless row && covers?(row.fetch("mountpoint"), view.fetch("path"))
|
|
38
|
+
[view.fetch("path"), row]
|
|
39
|
+
end
|
|
40
|
+
READONLY.each { |path| refuse! unless readonly?(by_path.fetch(path)) }
|
|
41
|
+
APIS.each do |path, filesystem|
|
|
42
|
+
row = by_path.fetch(path)
|
|
43
|
+
refuse! unless row.fetch("mountpoint") == path && row.fetch("root") == "/" && row.fetch("filesystem_type") == filesystem
|
|
44
|
+
end
|
|
45
|
+
object!(host_devpts, %w[device inode major_minor])
|
|
46
|
+
object!(selected_devpts, %w[device inode major_minor path ptmx_inode])
|
|
47
|
+
[host_devpts, selected_devpts].each do |instance|
|
|
48
|
+
refuse! unless %w[device inode].all? { |key| positive?(instance[key]) } &&
|
|
49
|
+
instance["major_minor"].is_a?(String) && instance["major_minor"].match?(/\A(?:0|[1-9][0-9]*):(?:0|[1-9][0-9]*)\z/)
|
|
50
|
+
end
|
|
51
|
+
refuse! unless positive?(selected_devpts["ptmx_inode"]) && path?(selected_devpts["path"])
|
|
52
|
+
pts = views.find { |view| view["path"] == "/dev/pts" }
|
|
53
|
+
pts_mount = by_path.fetch("/dev/pts")
|
|
54
|
+
refuse! unless pts.values_at("device", "inode") == selected_devpts.values_at("device", "inode") &&
|
|
55
|
+
pts_mount["major_minor"] == selected_devpts["major_minor"] &&
|
|
56
|
+
selected_devpts["device"] != host_devpts["device"] && selected_devpts["major_minor"] != host_devpts["major_minor"] &&
|
|
57
|
+
topology["ptmx_link"] == "pts/ptmx"
|
|
58
|
+
ptmx = topology.fetch("ptmx_identity")
|
|
59
|
+
object!(ptmx, %w[device inode mount_id rdev_major rdev_minor type])
|
|
60
|
+
refuse! unless ptmx["type"] == "character" && %w[device inode mount_id].all? { |key| positive?(ptmx[key]) } &&
|
|
61
|
+
ptmx["rdev_major"].is_a?(Integer) && ptmx["rdev_minor"].is_a?(Integer) &&
|
|
62
|
+
ptmx.values_at("rdev_major", "rdev_minor") == [5, 2] &&
|
|
63
|
+
ptmx.values_at("device", "inode") == selected_devpts.values_at("device", "ptmx_inode")
|
|
64
|
+
node_mount = mounts.find { |row| row["mount_id"] == ptmx["mount_id"] }
|
|
65
|
+
refuse! unless node_mount && readonly?(node_mount) &&
|
|
66
|
+
node_mount.values_at("mountpoint", "root", "filesystem_type", "major_minor") ==
|
|
67
|
+
["/dev/pts/ptmx", "/ptmx", "devpts", selected_devpts["major_minor"]]
|
|
68
|
+
hidden_host = mounts.select { |row| row["major_minor"] == host_devpts["major_minor"] }
|
|
69
|
+
hidden_host.each do |row|
|
|
70
|
+
refuse! unless row.values_at("mountpoint", "root", "filesystem_type") == ["/dev/pts", "/", "devpts"]
|
|
71
|
+
end
|
|
72
|
+
refuse! unless hidden_host.size <= 1
|
|
73
|
+
pts_layers = mounts.select { |row| row["mountpoint"] == "/dev/pts" }
|
|
74
|
+
refuse! unless (pts_layers - [pts_mount] - hidden_host).empty?
|
|
75
|
+
unless hidden_host.empty?
|
|
76
|
+
chain = []
|
|
77
|
+
parent = pts_mount
|
|
78
|
+
while parent["mountpoint"] == "/dev/pts"
|
|
79
|
+
refuse! if chain.include?(parent["mount_id"])
|
|
80
|
+
chain << parent.fetch("mount_id")
|
|
81
|
+
parent = mounts.find { |row| row["mount_id"] == parent["parent_id"] }
|
|
82
|
+
refuse! unless parent
|
|
83
|
+
end
|
|
84
|
+
refuse! unless hidden_host.all? { |row| chain.include?(row["mount_id"]) }
|
|
85
|
+
end
|
|
86
|
+
refuse! unless by_path.fetch("/dev").fetch("filesystem_type") == "tmpfs" && by_path.fetch("/dev").fetch("mountpoint") == "/dev"
|
|
87
|
+
mounts.each do |row|
|
|
88
|
+
next if hidden_host.include?(row)
|
|
89
|
+
next if readonly?(row)
|
|
90
|
+
api = APIS[row.fetch("mountpoint")]
|
|
91
|
+
next if api && row == by_path.fetch(row.fetch("mountpoint")) && row.fetch("filesystem_type") == api
|
|
92
|
+
refuse! if %w[/dev /proc /sys].any? { |root| covers?(root, row.fetch("mountpoint")) }
|
|
93
|
+
refuse! unless writable_resources.any? do |resource|
|
|
94
|
+
row.values_at("mountpoint", "major_minor", "root") == resource.values_at("view_path", "major_minor", "filesystem_path") &&
|
|
95
|
+
row.fetch("filesystem_type") == resource.fetch("filesystem_type")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
%w[/tmp /var/tmp].each do |path|
|
|
99
|
+
row = by_path.fetch(path)
|
|
100
|
+
refuse! unless readonly?(row) || writable_resources.any? { |resource| resource["view_path"] == path && row["mountpoint"] == path }
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
rescue KeyError, TypeError, NoMethodError, ArgumentError
|
|
104
|
+
refuse!
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def mount!(row)
|
|
110
|
+
object!(row, MOUNT_FIELDS)
|
|
111
|
+
refuse! unless positive?(row["mount_id"]) && row["parent_id"].is_a?(Integer) && row["parent_id"] >= 0 &&
|
|
112
|
+
row["major_minor"].is_a?(String) && row["major_minor"].match?(/\A[0-9]+:[0-9]+\z/) &&
|
|
113
|
+
path?(row["root"]) && path?(row["mountpoint"]) && row["filesystem_type"].is_a?(String) &&
|
|
114
|
+
row["filesystem_type"].match?(/\A[a-zA-Z0-9_.-]+\z/) && row["options"].is_a?(Array) &&
|
|
115
|
+
row["options"].all? { |option| option.is_a?(String) && option.bytesize.between?(1, 128) } &&
|
|
116
|
+
row["options"].uniq == row["options"] && (row["options"] & %w[ro rw]).size == 1
|
|
117
|
+
end
|
|
118
|
+
def object!(value, fields)
|
|
119
|
+
refuse! unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
120
|
+
end
|
|
121
|
+
def namespace!(value)
|
|
122
|
+
object!(value, %w[device inode])
|
|
123
|
+
refuse! unless value.values.all? { |number| positive?(number) }
|
|
124
|
+
end
|
|
125
|
+
def positive?(number) = number.is_a?(Integer) && number.positive?
|
|
126
|
+
def path?(path) = path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
|
|
127
|
+
def covers?(root, path) = root == "/" || root == path || path.start_with?(root + "/")
|
|
128
|
+
def readonly?(row) = row.fetch("options").include?("ro")
|
|
129
|
+
def refuse! = raise(RuntimeUnavailableError, "live kernel/resource view does not match the supported profile")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -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
|