pray-cli 1.6.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.md +21 -0
- data/README.md +125 -0
- data/SECURITY.md +28 -0
- data/bin/polyrun +108 -0
- data/bin/pray +26 -0
- data/lib/pray/archive.rb +68 -0
- data/lib/pray/auth_client.rb +97 -0
- data/lib/pray/cli/commands/auth.rb +42 -0
- data/lib/pray/cli/commands/distribution.rb +17 -0
- data/lib/pray/cli/commands/init.rb +62 -0
- data/lib/pray/cli/commands/meta.rb +15 -0
- data/lib/pray/cli/commands/packages.rb +107 -0
- data/lib/pray/cli/commands/trust.rb +73 -0
- data/lib/pray/cli/commands/workflow.rb +126 -0
- data/lib/pray/cli/help.rb +168 -0
- data/lib/pray/cli/helpers.rb +166 -0
- data/lib/pray/cli/parse.rb +136 -0
- data/lib/pray/cli/parse_auth.rb +91 -0
- data/lib/pray/cli/parse_trust.rb +152 -0
- data/lib/pray/cli/suggest.rb +57 -0
- data/lib/pray/cli.rb +117 -0
- data/lib/pray/confess.rb +113 -0
- data/lib/pray/config.rb +52 -0
- data/lib/pray/constraint.rb +80 -0
- data/lib/pray/destination.rb +158 -0
- data/lib/pray/dotenv.rb +46 -0
- data/lib/pray/environment.rb +41 -0
- data/lib/pray/error.rb +55 -0
- data/lib/pray/format_manifest.rb +255 -0
- data/lib/pray/format_serialize.rb +135 -0
- data/lib/pray/git_sources.rb +228 -0
- data/lib/pray/hashing.rb +59 -0
- data/lib/pray/invocation.rb +110 -0
- data/lib/pray/literal.rb +382 -0
- data/lib/pray/lockfile.rb +259 -0
- data/lib/pray/lockfile_serialize.rb +125 -0
- data/lib/pray/manifest.rb +126 -0
- data/lib/pray/manifest_formatter.rb +56 -0
- data/lib/pray/manifest_json.rb +128 -0
- data/lib/pray/manifest_parser.rb +152 -0
- data/lib/pray/manifest_parser_blocks.rb +216 -0
- data/lib/pray/manifest_parser_helpers.rb +208 -0
- data/lib/pray/materialize.rb +120 -0
- data/lib/pray/package_spec.rb +245 -0
- data/lib/pray/path_safety.rb +41 -0
- data/lib/pray/plan.rb +83 -0
- data/lib/pray/project_context.rb +67 -0
- data/lib/pray/publish.rb +162 -0
- data/lib/pray/registry.rb +355 -0
- data/lib/pray/render.rb +360 -0
- data/lib/pray/resolve.rb +396 -0
- data/lib/pray/resolve_context.rb +19 -0
- data/lib/pray/serve.rb +130 -0
- data/lib/pray/serve_federation.rb +109 -0
- data/lib/pray/session.rb +90 -0
- data/lib/pray/ssh_agent.rb +115 -0
- data/lib/pray/statement_surface.rb +262 -0
- data/lib/pray/substitute.rb +50 -0
- data/lib/pray/sync.rb +219 -0
- data/lib/pray/terminal.rb +30 -0
- data/lib/pray/trust.rb +201 -0
- data/lib/pray/trust_feed.rb +110 -0
- data/lib/pray/trust_ops.rb +202 -0
- data/lib/pray/verify.rb +257 -0
- data/lib/pray/version.rb +6 -0
- data/lib/pray-cli.rb +4 -0
- data/lib/pray.rb +46 -0
- data/pray-cli.gemspec +48 -0
- metadata +187 -0
data/lib/pray/sync.rb
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Pray
|
|
8
|
+
module Sync
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def synchronize_registry(root, peer_sources)
|
|
12
|
+
root = File.expand_path(root)
|
|
13
|
+
if peer_sources.empty?
|
|
14
|
+
raise Error.unsupported("no federation peers configured")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
peer_sources.each do |peer|
|
|
18
|
+
if peer.start_with?("pray+ssh://", "ssh+pray://")
|
|
19
|
+
raise Error.unsupported("pray_ssh sync peers are not implemented yet in pray-cli Ruby")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
known_peers = load_known_peers(root)
|
|
24
|
+
pending = peer_sources.dup
|
|
25
|
+
discovered = peer_sources.to_set
|
|
26
|
+
peer_sources.each { |url| upsert_known_peer(known_peers, {"name" => url, "url" => url, "public" => false}) }
|
|
27
|
+
|
|
28
|
+
package_versions = {}
|
|
29
|
+
peer_count = 0
|
|
30
|
+
|
|
31
|
+
while (peer_source = pending.shift)
|
|
32
|
+
next unless discovered.delete?(peer_source)
|
|
33
|
+
|
|
34
|
+
peer_count += 1
|
|
35
|
+
discovery = fetch_json("#{trim_slash(peer_source)}/.well-known/pray-federation.json")
|
|
36
|
+
unless discovery["spec"] == "pray-federation-v1"
|
|
37
|
+
raise Error.resolution("peer #{peer_source} does not speak the pray federation protocol")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Array(discovery["peers"]).each do |peer|
|
|
41
|
+
peer = normalize_peer(peer)
|
|
42
|
+
next if peer["url"] == peer_source
|
|
43
|
+
|
|
44
|
+
upsert_known_peer(known_peers, peer)
|
|
45
|
+
next if pending.include?(peer["url"]) || discovered.include?(peer["url"])
|
|
46
|
+
|
|
47
|
+
pending << peer["url"]
|
|
48
|
+
discovered << peer["url"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
index = fetch_json("#{trim_slash(peer_source)}/v1/sync/index")
|
|
52
|
+
unless index["spec"] == "prayfile-distribution-1"
|
|
53
|
+
raise Error.resolution(
|
|
54
|
+
"peer #{peer_source} returned unsupported registry index spec: #{index["spec"]}"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Array(index["packages"]).each do |summary|
|
|
59
|
+
name = summary["name"]
|
|
60
|
+
metadata = fetch_json("#{trim_slash(peer_source)}/v1/sync/package/#{name}")
|
|
61
|
+
unless metadata["name"] == name
|
|
62
|
+
raise Error.resolution(
|
|
63
|
+
"peer #{peer_source} returned mismatched package metadata for #{name}"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
sync_package(root, peer_source, metadata, package_versions)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
write_known_peers(root, known_peers)
|
|
71
|
+
write_local_index(root, package_versions)
|
|
72
|
+
{peers: peer_count, packages: package_versions.length, known_peers: known_peers.length}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_sync_peers(root)
|
|
76
|
+
path = File.join(root, "v1", "peers.json")
|
|
77
|
+
unless File.file?(path)
|
|
78
|
+
raise Error.unsupported("no federation peers configured")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Array(JSON.parse(File.read(path))).map { |peer| normalize_peer(peer) }
|
|
82
|
+
rescue JSON::ParserError => error
|
|
83
|
+
raise Error.parse("peer list", error.message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sync_package(root, peer_source, metadata, package_versions)
|
|
87
|
+
name = metadata["name"]
|
|
88
|
+
package_versions[name] ||= load_local_versions(root, name)
|
|
89
|
+
|
|
90
|
+
Array(metadata["versions"]).each do |version_data|
|
|
91
|
+
version = registry_version_from_transport(version_data)
|
|
92
|
+
existing = package_versions[name][version.version]
|
|
93
|
+
if existing
|
|
94
|
+
if same_identity?(existing, version)
|
|
95
|
+
package_versions[name][version.version] = existing
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
raise Error.integrity(
|
|
99
|
+
"conflicting metadata for package #{name} version #{version.version}"
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
artifact_hash = version.artifact_hash
|
|
104
|
+
unless artifact_hash
|
|
105
|
+
raise Error.integrity("federation package #{name} #{version.version} is missing an artifact hash")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
bytes = fetch_bytes(peer_source, version.artifact)
|
|
109
|
+
computed = Hashing.sha256_prefixed(bytes)
|
|
110
|
+
if computed != artifact_hash
|
|
111
|
+
raise Error.integrity("artifact hash mismatch for #{name} #{version.version}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
write_artifact(root, version.artifact, bytes)
|
|
115
|
+
package_versions[name][version.version] = version
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def registry_version_from_transport(data)
|
|
120
|
+
signer = data.dig("publisher", "id") || data["signer"]
|
|
121
|
+
fingerprint = data.dig("publisher", "key_fingerprint") || data["signer_fingerprint"]
|
|
122
|
+
signature = data.dig("signature", "value") || data["signature"]
|
|
123
|
+
RegistryPackageVersion.new(
|
|
124
|
+
version: data["version"].to_s,
|
|
125
|
+
artifact: data["artifact"].to_s,
|
|
126
|
+
artifact_hash: data["artifact_hash"].to_s,
|
|
127
|
+
tree_hash: data["tree_hash"],
|
|
128
|
+
yanked: data.fetch("yanked", false),
|
|
129
|
+
targets: Array(data["targets"]),
|
|
130
|
+
exports: Array(data["exports"]),
|
|
131
|
+
signer: signer,
|
|
132
|
+
signer_fingerprint: fingerprint,
|
|
133
|
+
published_at: data["published_at"],
|
|
134
|
+
signature: signature.is_a?(Hash) ? signature["value"] : signature
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def same_identity?(left, right)
|
|
139
|
+
left.artifact_hash == right.artifact_hash && left.tree_hash == right.tree_hash
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def load_local_versions(root, package_name)
|
|
143
|
+
path = Publish.registry_metadata_path(root, package_name)
|
|
144
|
+
metadata = Publish.load_registry_package_metadata(path, package_name)
|
|
145
|
+
metadata.versions.to_h { |version| [version.version, version] }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def write_local_index(root, package_versions)
|
|
149
|
+
package_versions.each do |name, versions|
|
|
150
|
+
metadata = RegistryPackageMetadata.new(name: name, versions: versions.values)
|
|
151
|
+
Publish.write_registry_package_metadata(
|
|
152
|
+
Publish.registry_metadata_path(root, name), metadata
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
index = Publish.load_registry_index(root)
|
|
156
|
+
names = index.packages.to_set
|
|
157
|
+
package_versions.each_key { |name| names << name }
|
|
158
|
+
index.packages = names.sort
|
|
159
|
+
Publish.write_registry_index(root, index)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def write_artifact(root, artifact_path, bytes)
|
|
163
|
+
relative = PathSafety.sanitize_relative_path(artifact_path)
|
|
164
|
+
path = File.join(root, relative)
|
|
165
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
166
|
+
File.binwrite(path, bytes)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def load_known_peers(root)
|
|
170
|
+
path = File.join(root, "v1", "peers.json")
|
|
171
|
+
return [] unless File.file?(path)
|
|
172
|
+
|
|
173
|
+
Array(JSON.parse(File.read(path))).map { |peer| normalize_peer(peer) }
|
|
174
|
+
rescue JSON::ParserError
|
|
175
|
+
[]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def write_known_peers(root, peers)
|
|
179
|
+
path = File.join(root, "v1", "peers.json")
|
|
180
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
181
|
+
File.write(path, JSON.pretty_generate(peers))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def upsert_known_peer(peers, peer)
|
|
185
|
+
existing = peers.find { |entry| entry["url"] == peer["url"] }
|
|
186
|
+
if existing
|
|
187
|
+
existing.merge!(peer)
|
|
188
|
+
else
|
|
189
|
+
peers << peer
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def normalize_peer(peer)
|
|
194
|
+
url = peer["url"].to_s
|
|
195
|
+
raise Error.parse("peer list", "peer url is required") if url.empty?
|
|
196
|
+
|
|
197
|
+
{"name" => peer["name"].to_s.empty? ? url : peer["name"], "url" => url,
|
|
198
|
+
"public" => !!peer["public"]}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fetch_json(url)
|
|
202
|
+
JSON.parse(Registry.http_get(url))
|
|
203
|
+
rescue JSON::ParserError => error
|
|
204
|
+
raise Error.parse("federation response", error.message)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def fetch_bytes(peer_source, artifact)
|
|
208
|
+
if artifact.start_with?("http://", "https://")
|
|
209
|
+
return Registry.http_get(artifact).b
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
Registry.http_get("#{trim_slash(peer_source)}/#{artifact.delete_prefix("/")}").b
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def trim_slash(value)
|
|
216
|
+
value.to_s.sub(%r{/+\z}, "")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module Terminal
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def color_enabled?
|
|
8
|
+
return false if env_truthy?("PRAY_NO_COLOR") || no_color_requested?
|
|
9
|
+
|
|
10
|
+
term = ENV.fetch("TERM", nil)
|
|
11
|
+
term.nil? || term != "dumb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def no_color_requested?
|
|
15
|
+
value = ENV.fetch("NO_COLOR", nil)
|
|
16
|
+
!value.nil? && !value.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def no_input_requested?
|
|
20
|
+
env_truthy?("PRAY_NO_INPUT")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def env_truthy?(name)
|
|
24
|
+
value = ENV.fetch(name, nil)
|
|
25
|
+
return false if value.nil?
|
|
26
|
+
|
|
27
|
+
%w[1 true yes on].include?(value.strip.downcase)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/pray/trust.rb
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "toml-rb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
TrustRule = Struct.new(
|
|
8
|
+
:match_prefix, :allow, :require_signed_commit,
|
|
9
|
+
:allowed_signing_keys, :allowed_host_keys, :allowed_publishers
|
|
10
|
+
) do
|
|
11
|
+
def initialize(
|
|
12
|
+
match_prefix: nil, allow: true, require_signed_commit: false,
|
|
13
|
+
allowed_signing_keys: [], allowed_host_keys: [], allowed_publishers: []
|
|
14
|
+
)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
TrustPolicy = Struct.new(:default_rule, :rules) do
|
|
20
|
+
def initialize(default_rule: TrustRule.new, rules: [])
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
CompromisedKeyEntry = Struct.new(:key, :reason, :reference, :reported_at, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
module Trust
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def trust_home
|
|
31
|
+
return ENV["PRAY_HOME"] if ENV["PRAY_HOME"]
|
|
32
|
+
|
|
33
|
+
home = ENV["HOME"]
|
|
34
|
+
raise Error.manifest("HOME is not set; set PRAY_HOME to configure trust policy") unless home
|
|
35
|
+
|
|
36
|
+
File.join(home, ".pray")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def trust_policy_path(home = trust_home)
|
|
40
|
+
File.join(home, "trust.toml")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def load_policy(home = trust_home)
|
|
44
|
+
path = trust_policy_path(home)
|
|
45
|
+
return nil unless File.file?(path)
|
|
46
|
+
|
|
47
|
+
parse_policy(TomlRB.load_file(path))
|
|
48
|
+
rescue TomlRB::ParseError => error
|
|
49
|
+
raise Error.parse("trust policy", "#{path}: #{error.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_policy_or_default(home = trust_home)
|
|
53
|
+
load_policy(home) || TrustPolicy.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def save_policy(policy, home = trust_home)
|
|
57
|
+
path = trust_policy_path(home)
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
59
|
+
File.write(path, "#{format_policy(policy)}\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_policy(data)
|
|
63
|
+
TrustPolicy.new(
|
|
64
|
+
default_rule: parse_rule(data["default"] || {}),
|
|
65
|
+
rules: Array(data["rules"]).map { |entry| parse_rule(entry) }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_rule(data)
|
|
70
|
+
TrustRule.new(
|
|
71
|
+
match_prefix: data["match_prefix"],
|
|
72
|
+
allow: data.fetch("allow", true),
|
|
73
|
+
require_signed_commit: data.fetch("require_signed_commit", false),
|
|
74
|
+
allowed_signing_keys: Array(data["allowed_signing_keys"]),
|
|
75
|
+
allowed_host_keys: Array(data["allowed_host_keys"]),
|
|
76
|
+
allowed_publishers: Array(data["allowed_publishers"])
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def best_rule(policy, source_url)
|
|
81
|
+
selected = nil
|
|
82
|
+
selected_length = 0
|
|
83
|
+
policy.rules.each do |rule|
|
|
84
|
+
prefix = rule.match_prefix
|
|
85
|
+
next if prefix.nil? || prefix.empty?
|
|
86
|
+
next unless source_url.start_with?(prefix)
|
|
87
|
+
next unless prefix.length > selected_length
|
|
88
|
+
|
|
89
|
+
selected = rule
|
|
90
|
+
selected_length = prefix.length
|
|
91
|
+
end
|
|
92
|
+
selected || policy.default_rule
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def mutable_rule_for_match_prefix!(policy, match_prefix)
|
|
96
|
+
existing = policy.rules.find { |rule| rule.match_prefix == match_prefix }
|
|
97
|
+
return existing if existing
|
|
98
|
+
|
|
99
|
+
rule = TrustRule.new(match_prefix: match_prefix)
|
|
100
|
+
policy.rules << rule
|
|
101
|
+
rule
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_key(value)
|
|
105
|
+
value.strip.upcase
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def fingerprint_matches?(allowed, candidate)
|
|
109
|
+
normalize_key(allowed) == candidate || candidate.end_with?(normalize_key(allowed))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prepare_source_host_keys(sources)
|
|
113
|
+
policy = load_policy_or_default
|
|
114
|
+
sources.each_with_object({}) do |source, host_keys|
|
|
115
|
+
next unless source.kind == "pray_ssh"
|
|
116
|
+
|
|
117
|
+
rule = best_rule(policy, source.url)
|
|
118
|
+
fingerprint = rule.allowed_host_keys.first
|
|
119
|
+
host_keys[source.name] = fingerprint if fingerprint
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def verify_publisher_fingerprint!(source_url, selected)
|
|
124
|
+
return unless selected.signature
|
|
125
|
+
return unless selected.signer_fingerprint
|
|
126
|
+
|
|
127
|
+
policy = load_policy_or_default
|
|
128
|
+
rule = best_rule(policy, source_url)
|
|
129
|
+
return if rule.allowed_publishers.empty?
|
|
130
|
+
|
|
131
|
+
normalized = normalize_key(selected.signer_fingerprint)
|
|
132
|
+
trusted = rule.allowed_publishers.any? do |publisher|
|
|
133
|
+
fingerprint_matches?(publisher, normalized)
|
|
134
|
+
end
|
|
135
|
+
return if trusted
|
|
136
|
+
|
|
137
|
+
raise Error.integrity(
|
|
138
|
+
"publisher fingerprint #{selected.signer_fingerprint} is not trusted for #{source_url}"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def format_policy(policy)
|
|
143
|
+
lines = ["[default]"]
|
|
144
|
+
lines.concat(format_rule_lines(policy.default_rule))
|
|
145
|
+
policy.rules.each do |rule|
|
|
146
|
+
lines << ""
|
|
147
|
+
lines << "[[rules]]"
|
|
148
|
+
lines << "match_prefix = #{rule.match_prefix.inspect}" if rule.match_prefix
|
|
149
|
+
lines.concat(format_rule_lines(rule))
|
|
150
|
+
end
|
|
151
|
+
lines.join("\n")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def format_rule_lines(rule)
|
|
155
|
+
[
|
|
156
|
+
"allow = #{rule.allow}",
|
|
157
|
+
"require_signed_commit = #{rule.require_signed_commit}",
|
|
158
|
+
"allowed_signing_keys = #{toml_array(rule.allowed_signing_keys)}",
|
|
159
|
+
"allowed_host_keys = #{toml_array(rule.allowed_host_keys)}",
|
|
160
|
+
"allowed_publishers = #{toml_array(rule.allowed_publishers)}"
|
|
161
|
+
]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def toml_array(values)
|
|
165
|
+
"[#{values.map(&:inspect).join(", ")}]"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def format_rule_block(scope, rule)
|
|
169
|
+
lines = [
|
|
170
|
+
scope,
|
|
171
|
+
" allow: #{rule.allow}",
|
|
172
|
+
" require_signed_commit: #{rule.require_signed_commit}",
|
|
173
|
+
format_keyed_list("allowed_signing_keys", rule.allowed_signing_keys),
|
|
174
|
+
format_keyed_list("allowed_host_keys", rule.allowed_host_keys),
|
|
175
|
+
format_keyed_list("allowed_publishers", rule.allowed_publishers)
|
|
176
|
+
]
|
|
177
|
+
"#{lines.join("\n")}\n"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def format_keyed_list(name, values)
|
|
181
|
+
return " #{name}: []" if values.empty?
|
|
182
|
+
|
|
183
|
+
[" #{name}:", *values.map { |value| " - #{value}" }].join("\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def append_missing!(target, keys)
|
|
187
|
+
added = 0
|
|
188
|
+
keys.each do |key|
|
|
189
|
+
normalized = normalize_key(key)
|
|
190
|
+
next if normalized.empty?
|
|
191
|
+
next if target.any? { |existing| normalize_key(existing) == normalized }
|
|
192
|
+
|
|
193
|
+
target << normalized
|
|
194
|
+
added += 1
|
|
195
|
+
end
|
|
196
|
+
added
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
require_relative "trust_ops"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
module Trust
|
|
8
|
+
DEFAULT_COMPROMISED_KEYS_SOURCE =
|
|
9
|
+
"https://raw.githubusercontent.com/bmx-rs/trust-lists/main/compromised-keys.toml"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def check_compromised(source = nil, home: trust_home)
|
|
14
|
+
source_description, body = fetch_compromised_feed(source)
|
|
15
|
+
entries = parse_compromised_feed(body, source_description)
|
|
16
|
+
[source_description, compromised_hits(home, entries)]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_compromised_feed(body, source_hint)
|
|
20
|
+
if source_hint.downcase.end_with?(".txt")
|
|
21
|
+
parse_compromised_txt(body)
|
|
22
|
+
else
|
|
23
|
+
parse_compromised_toml(body)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch_compromised_feed(source)
|
|
28
|
+
url = source || DEFAULT_COMPROMISED_KEYS_SOURCE
|
|
29
|
+
if url.start_with?("http://", "https://")
|
|
30
|
+
uri = URI(url)
|
|
31
|
+
response = Net::HTTP.start(
|
|
32
|
+
uri.hostname, uri.port, use_ssl: uri.scheme == "https",
|
|
33
|
+
open_timeout: 10, read_timeout: 30
|
|
34
|
+
) { |http| http.get(uri.request_uri) }
|
|
35
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
+
raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
return [url, response.body]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
path = File.expand_path(url)
|
|
43
|
+
[path, File.read(path)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse_compromised_toml(body)
|
|
47
|
+
data = TomlRB.parse(body)
|
|
48
|
+
Array(data["keys"]).filter_map do |entry|
|
|
49
|
+
key = normalize_key(entry["value"].to_s)
|
|
50
|
+
next if key.empty?
|
|
51
|
+
|
|
52
|
+
CompromisedKeyEntry.new(
|
|
53
|
+
key: key, reason: entry["reason"],
|
|
54
|
+
reference: entry["reference"], reported_at: entry["reported_at"]
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
rescue TomlRB::ParseError
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_compromised_txt(body)
|
|
62
|
+
body.each_line.filter_map do |raw_line|
|
|
63
|
+
line = raw_line.strip
|
|
64
|
+
next if line.empty? || line.start_with?("#")
|
|
65
|
+
|
|
66
|
+
head, reason = line.split("#", 2)
|
|
67
|
+
key = normalize_key(head.to_s.split(/\s+/).first.to_s)
|
|
68
|
+
next if key.empty?
|
|
69
|
+
|
|
70
|
+
CompromisedKeyEntry.new(
|
|
71
|
+
key: key,
|
|
72
|
+
reason: reason&.strip&.empty? ? nil : reason&.strip,
|
|
73
|
+
reference: nil, reported_at: nil
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def compromised_hits(home, entries)
|
|
79
|
+
trusted = trusted_keys_by_scope(home)
|
|
80
|
+
return [] if trusted.empty?
|
|
81
|
+
|
|
82
|
+
by_key = entries.group_by(&:key)
|
|
83
|
+
trusted.filter_map do |key, scopes|
|
|
84
|
+
matches = by_key[key]
|
|
85
|
+
next unless matches
|
|
86
|
+
|
|
87
|
+
[key, scopes, matches]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def trusted_keys_by_scope(home)
|
|
92
|
+
policy = load_policy(home)
|
|
93
|
+
return {} unless policy
|
|
94
|
+
|
|
95
|
+
output = Hash.new { |hash, key| hash[key] = [] }
|
|
96
|
+
policy.default_rule.allowed_signing_keys.each do |key|
|
|
97
|
+
normalized = normalize_key(key)
|
|
98
|
+
output[normalized] << "global/default" unless normalized.empty?
|
|
99
|
+
end
|
|
100
|
+
policy.rules.each do |rule|
|
|
101
|
+
scope = "local:#{rule.match_prefix || "-"}"
|
|
102
|
+
rule.allowed_signing_keys.each do |key|
|
|
103
|
+
normalized = normalize_key(key)
|
|
104
|
+
output[normalized] << scope unless normalized.empty?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
output
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|