From 35948ac36718c4050315f78bb0dfbcecfb320d45 Mon Sep 17 00:00:00 2001 From: Samuel Giddins Date: Fri, 11 Oct 2024 09:47:34 -0500 Subject: [PATCH] Add demo package manager + cedar impl Signed-off-by: Samuel Giddins --- Gemfile | 8 +- Gemfile.lock | 16 +- Rakefile | 8 +- bin/demo.rb | 221 ++++ lib/sigstore/cedar.rb | 1504 +++++++++++++++++++++++ lib/sigstore/rekor/client.rb | 2 +- lib/sigstore/signer.rb | 2 +- policy-schema.kdl | 43 + policy-tests.kdl | 221 ++++ policy.rb | 496 ++++++++ policy2.rb | 599 +++++++++ policy3.rb | 552 +++++++++ test/.gitignore | 1 + test/sigstore/cedar_integration_test.rb | 178 +++ thunks.rb | 176 +++ 15 files changed, 4022 insertions(+), 5 deletions(-) create mode 100755 bin/demo.rb create mode 100644 lib/sigstore/cedar.rb create mode 100644 policy-schema.kdl create mode 100644 policy-tests.kdl create mode 100644 policy.rb create mode 100644 policy2.rb create mode 100755 policy3.rb create mode 100644 test/sigstore/cedar_integration_test.rb create mode 100644 thunks.rb diff --git a/Gemfile b/Gemfile index b415ac2..930cdd0 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,6 @@ source "https://rubygems.org" gemspec gemspec path: "cli" -gem "base64", "~> 0.2.0" # Until https://github.com/vcr/vcr/commit/5c9230b43b6a51dec78941d16bf8e2954042964c is released gem "rake", "~> 13.2" gem "rubocop", "~> 1.67" gem "rubocop-performance", "~> 1.23" @@ -17,3 +16,10 @@ gem "thor", "~> 1.3" gem "timecop", "~> 0.9.10" gem "vcr", "~> 6.3" gem "webmock", "~> 3.24" + +gem "literal" +# gem "xdg", "~> 8.8" + +gem "kdl", "~> 1.0" + +gem "paramesan", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index d0e498d..fe8dde5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -29,10 +29,17 @@ GEM hashdiff (1.1.1) json (2.8.2) json (2.8.2-java) + kdl (1.0.6) + base64 (~> 0.2.0) + bigdecimal (~> 3.1.6) + racc (~> 1.5) + simpleidn (~> 0.2.1) language_server-protocol (3.17.0.3) + literal (1.1.0) net-http (0.5.0) uri parallel (1.26.3) + paramesan (1.0.1) parser (3.3.6.0) ast (~> 2.4.1) racc @@ -78,6 +85,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) + simpleidn (0.2.3) test-unit (3.6.4) power_assert thor (1.3.2) @@ -101,7 +109,9 @@ PLATFORMS x86_64-linux DEPENDENCIES - base64 (~> 0.2.0) + kdl (~> 1.0) + literal + paramesan (~> 1.0) rake (~> 13.2) rubocop (~> 1.67) rubocop-performance (~> 1.23) @@ -126,9 +136,12 @@ CHECKSUMS hashdiff (1.1.1) sha256=c7966316726e0ceefe9f5c6aef107ebc3ccfef8b6db55fe3934f046b2cf0936a json (2.8.2) sha256=dd4fa6c9c81daecf72b86ea36e56ed8955fdbb4d4dc379c93d313a59344486cf json (2.8.2-java) sha256=7a7321efd8fad215a1afe92b5f16546203f193781da2d5c01587600cc00aa302 + kdl (1.0.6) sha256=372b05de298a7fd757fbc421f71464807ffbf24f32862938236e807858d6f574 language_server-protocol (3.17.0.3) sha256=3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f + literal (1.1.0) sha256=92dc4f78fc7b6e1eaefd9929eacfc80bd14182a55e883aadfe52f2a62b2c9275 net-http (0.5.0) sha256=ed7f88205afe03bf53142a4b81ded91f2c01522dcf03089cb6ad4acb476ce1da parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + paramesan (1.0.1) sha256=b1b2c0f855273201b94f0a4fb5c2ab11e67265ec4e1448181823438de2562db0 parser (3.3.6.0) sha256=25d4e67cc4f0f7cab9a2ae1f38e2005b6904d2ea13c34734511d0faad038bc3b power_assert (2.0.4) sha256=43da564b535c758f2fc8c80fee031b744b4d4b388362d8c1ba669a0dc81be0c5 protobug (0.1.0) sha256=5bf1356cedf99dcf311890743b78f5e602f62ca703e574764337f1996b746bf2 @@ -152,6 +165,7 @@ CHECKSUMS simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.12.3) sha256=4b1aad33259ffba8b29c6876c12db70e5750cb9df829486e4c6e5da4fa0aa07b simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 + simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29 test-unit (3.6.4) sha256=7a4bd7dd2fb6d372c8b7499d04fd6478b7163518c6e577cbf2a4cddc8cda7688 thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda timecop (0.9.10) sha256=12ba45ce57cdcf6b1043cb6cdffa6381fd89ce10d369c28a7f6f04dc1b0cd8eb diff --git a/Rakefile b/Rakefile index c8678d9..13c251c 100644 --- a/Rakefile +++ b/Rakefile @@ -70,7 +70,7 @@ task :find_action_versions do # rubocop:disable Rake/Desc @action_versions = actions.transform_values(&:first) end -task test: %w[sigstore_conformance] +task test: %w[sigstore_conformance cedar_integration_tests] desc "Update the vendored data files" task :update_data do @@ -168,6 +168,12 @@ GitRepo.define_task(tuf_conformance: %w[find_action_versions]).tap do |task| task.commit = -> { @action_versions.fetch("theupdateframework/tuf-conformance") } end +GitRepo.define_task(cedar_integration_tests: []).tap do |task| + task.path = "test/cedar-integration-tests" + task.url = "https://github.com/cedar-policy/cedar-integration-tests.git" + task.commit = "3903b933e29fd60f2c40d779b250cd4ffb150f5d" +end + namespace :tuf_conformance do file "test/tuf-conformance/env/pyvenv.cfg" => :tuf_conformance do sh "make", "dev", chdir: "test/tuf-conformance" diff --git a/bin/demo.rb b/bin/demo.rb new file mode 100755 index 0000000..f9c8a6c --- /dev/null +++ b/bin/demo.rb @@ -0,0 +1,221 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +require "bundler/setup" + +require "json" +require "rake" +require "base64" +require "tmpdir" +require "uri" +require "digest" +require "pathname" +require "xdg" +require "literal" +require "sigstore" +require "sigstore/cedar" + +include FileUtils # rubocop:disable Style/MixinUsage + +chdir Dir.mktmpdir + +mkdir_p ["index"] + +sh "curl", "--fail", "--silent", "http://localhost:8000/targets/trusted_root.json", "-o", "trusted_root.json" + +def id_token(**claims) + claims.merge!(iss: "http://foo.com", aud: "sigstore", sub: "http://github.com/foo/workflow.yml@refs/heads/main", + iat: 0, exp: 9_999_999_999, nbf: 0) + claims[:email] = claims[:sub] + + [ + "", + JSON.dump(claims), + "" + ].map { Base64.strict_encode64(_1).chomp }.join(".") +end + +def publish(name, version) + artifact = JSON.dump(name:, version:) + full_name = URI.encode_www_form({ name:, version: }) + index = begin + JSON.parse File.read("index.json") + rescue Errno::ENOENT + [] + end + index << { name:, version:, full_name:, sha256: Digest::SHA256.hexdigest(artifact) } + File.write "index.json", JSON.dump(index) + File.write "index/#{full_name}", artifact + sign("index/#{full_name}", bundle: "index/#{full_name}.sigstore.jsonl") +end + +def sign(file, bundle:, **kwargs) + sh "/Users/segiddins/Development/github.com/sigstore/sigstore-ruby/bin/sigstore-ruby", "sign", + "--trusted-root", "trusted_root.json", "--identity-token=#{id_token(**kwargs)}", "--bundle=#{bundle}", + file +end + +publish "rails", "1.0" + +module PolicyConstraint + def self.from_json(constraint) + Literal.check(actual: constraint, expected: Hash) + keys = constraint.keys + if keys == ["all"] + all = constraint["all"] + Literal.check(actual: all, expected: Array) + AllOf.new(all.map { from_json(_1) }) + else + ID.new(**constraint) + end + end +end + +class ID < Literal::Struct + include PolicyConstraint + prop :matchers, _Hash( + _Union(String), _Union(String, Integer) + ), :** +end + +class AllOf < Literal::Struct + include PolicyConstraint + prop :of, _Array(PolicyConstraint), :positional +end + +class AtLeast < Literal::Struct + include PolicyConstraint + prop :count, Integer, :positional, default: 1 + prop :of, _Array(PolicyConstraint) +end + +class Policy < Literal::Struct + prop :name, String + prop :min, _String? + prop :max, _String? + prop :source, _String? + prop :platform, _String? + prop :expected, _Array(PolicyConstraint) + + def self.from_json(json) + new( + name: json["name"], + min: json["min"], + max: json["max"], + expected: json["expected"].map do |constraint| + PolicyConstraint.from_json(constraint) + end + ) + end +end + +require "yaml" +pp Policy.from_json(YAML.load(<<~YAML).dig("rubygems", 0)) + rubygems: + - name: rails + min: 0.0.0 + max: 7.0.0 + source: "https://rubygems.org" + expected: + - issuer: "https://token.actions.githubusercontent.com" + - all: + - Issuer: "https://token.actions.githubusercontent.com" + Build Config Digest: "sha256:1234" +YAML + +class Installer + attr_reader :home, :dir + + def initialize(machine, project, requirements = {}) + @machine = machine + @project = project + @requirements = requirements + + @dir = Pathname("projects") / @project + @home = Pathname(@machine).join("home") + [@dir, @home].each(&:mkpath) + @xdg = XDG::Environment.new(environment: { "HOME" => @home.to_s }) + end + + def self.call(...) + new(...).call + end + + def call + installed = install(resolve) + lock(installed) + end + + def resolve + index = JSON.parse File.read("index.json") + index.select! do |artifact| + @requirements[artifact["name"]] == artifact["version"] + end + + raise StandardError, "Missing artifacts" unless index.size == @requirements.size + + index + end + + def install(resolve) + # 1) check if existing signatures satisfy the policy + # 2) if not, download the signatures and verify it + # 2) download artifact if missing from the cache, verifying checksum + # 3) copy artifact to the project's directory + + resolve.map do |artifact| + signatures = JSON.parse File.read("index/#{artifact["full_name"]}.sigstore.jsonl") + { artifact:, attestations: [signatures] } + end + + # policy_set = Sigstore::Cedar::PolicySet.parse <<~CEDAR + # @Foo("bar") + # permit ( + # principal, + # action, + # resource in package::"name=rails" + # ) + # when { resource } + # // when { resource.version.greaterThanOrEqual(gem_version("1.0")) } + # // when { resource.version.lessThan(gem_version("2.0")) } + # ; + # CEDAR + # entities = Sigstore::Cedar::Authorizer::Entities.new(packages.map do |pkg| + # parents = pkg[:attestations].flat_map do |a| + # pp a + # end + # Sigstore::Cedar::Entity.new( + # uid: Sigstore::Cedar::Entity::UID.new(type: "package", id: pkg[:artifact]["full_name"]), + # parents: [ + # Sigstore::Cedar::Entity::UID.new(type: "package", id: "name=#{pkg[:artifact].fetch("name")}"), + # Sigstore::Cedar::Entity::UID.new(type: "sigstore::issuer", id: "https://token.actions.githubusercontent.com") + # ], + # attrs: {} + # ) + # end) + # authorizer = Sigstore::Cedar::Authorizer.new(policy_set:, entities:) + + # unverified = packages.reject do |package| + # resp.verb == "allow" + # end + # # pp authorizer + # # pp unverified + end + + def signatures_path(artifact) + purl = "pkg:demo/#{artifact["name"]}" + @xdg.state_home / "signatures" / "#{URI.encode_uri_component(purl)}.jsonl" + end + + def lock(installed) + File.write dir / "requirements.json", JSON.dump(@requirements) + File.write dir / "requirements.lock.json", JSON.dump(installed.map do |i| + i[:artifact] + end) + end +end + +Installer.call("machine_1", "a", { "rails" => "1.0" }) + +# sh "code", "." diff --git a/lib/sigstore/cedar.rb b/lib/sigstore/cedar.rb new file mode 100644 index 0000000..0043a96 --- /dev/null +++ b/lib/sigstore/cedar.rb @@ -0,0 +1,1504 @@ +# frozen_string_literal: true + +require "literal" + +class Literal::DataStructure + alias eql? == +end + +module Sigstore + class Cedar + class Error < StandardError + end + + class Entity < Literal::Struct + class UID < Literal::Data + prop :type, String + prop :id, String + + def self.from_json(json) + new( + type: json.fetch("type"), + id: json.fetch("id") + ) + end + + def to_s + "#{type}::#{id.inspect}" + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text(to_s) + end + + def hash + [@type, @id].hash + end + end + + prop :uid, UID + prop :parents, _Array(UID), default: [].freeze + prop :attrs, _Hash(String, _JSONData), default: {}.freeze + prop :tags, _Array(String), default: [].freeze + + def self.from_json(json) + new( + uid: UID.from_json(json.fetch("uid")), + parents: json.fetch("parents").map { UID.from_json(_1) }, + attrs: json.fetch("attrs"), + tags: json.fetch("tags", []) + ) + end + end + + class AuthorizationRequest < Literal::Struct + prop :principal, Entity::UID + prop :action, Entity::UID + prop :resource, Entity::UID + prop :context, _Hash(String, _Any) + + def self.from_json(json) + new( + principal: Entity::UID.from_json(json.fetch("principal")), + action: Entity::UID.from_json(json.fetch("action")), + resource: Entity::UID.from_json(json.fetch("resource")), + context: expand_context(json.fetch("context")) + ) + end + + def self.expand_context(context) + case context + when Hash + if context.size == 1 && context.key?("__extn") + extn = context.fetch("__extn") + Policy::JsonExpr::Function.new(name: extn.fetch("fn"), + args: [Policy::JsonExpr::Value.new(value: extn.fetch("arg"))]) + .evaluate(nil, nil) + elsif context.size == 2 && context.key?("id") && context.key?("type") + Entity::UID.from_json(context) + else + context.transform_values! { expand_context(_1) } + context + end + else + context + end + end + end + + class AuthorizationResponse < Literal::Struct + prop :verb, _Union("allow", "deny", "error") + + prop :determining_policies, _Array(->(arg) { arg&.is_a?(Policy) }) + prop :error_conditions, _Hash(->(arg) { arg&.is_a?(Policy) }, _Nilable(_Any)) + end + + class Authorizer < Literal::Struct + class Entities < Literal::Struct + prop :list, _Array(Entity), :positional + + def after_initialize + @by_uid = list.each_with_object({}) { |entity, by_uid| by_uid[entity.uid] = entity } + end + + def in?(lhs:, rhs:) + Literal.check(actual: lhs, expected: Entity::UID) + Literal.check(actual: rhs, expected: Entity::UID) + + return true if lhs == rhs + + seen = Set.new + seen << lhs + queue = [lhs] + + while (current = queue.shift) + return true if current == rhs + + entity = @by_uid[current] || next + entity.parents.each do |parent| + next unless seen.add?(parent) + + queue << parent + end + end + + false + end + + def is?(lhs:, type:) + Literal.check(actual: lhs, expected: Entity::UID) + Literal.check(actual: type, expected: String) + + lhs.type == type + end + + def fetch(uid) + @by_uid.fetch(uid) do + Entity.new(uid:, parents: [], attrs: {}, tags: []) + end + end + end + prop :policy_set, ->(arg) { arg&.is_a?(PolicySet) } + prop :entities, Entities + + def authorize(request) + Literal.check(actual: request, expected: AuthorizationRequest) + + relevant_policies = policy_set.static_policies.each_with_object(Hash.new do |h, k| + h[k] = [] + end) do |statement, relevant| + satisfies = statement.satisfies(request, entities) + next if satisfies == false + + relevant[satisfies] << statement + end + + verb = if relevant_policies.empty? || relevant_policies[true].any? { _1.effect != Policy::Effect::Permit } + relevant_policies[true].delete_if { _1.effect == Policy::Effect::Permit } + "deny" + elsif relevant_policies.keys != [true] + "deny" + else + "allow" + end + + determining_policies = relevant_policies[true] + relevant_policies.delete(true) + + AuthorizationResponse.new( + verb:, + determining_policies:, + error_conditions: relevant_policies.flat_map { |k, v| v.map { [_1, k.message] } }.to_h + ) + end + end + + class Policy < Literal::Struct + module JsonExpr + if false # rubocop:disable Lint/LiteralAsCondition + def self.included(base) + base.prepend(Module.new do + def evaluate(...) + super.tap { pp(self => _1) } + end + end) + end + end + + Entity::UID.class_eval do + include JsonExpr + + def evaluate(_, entities) + entities.fetch(self) + end + end + + # Value = new(_Any) + class Value < Literal::Struct + include JsonExpr + + prop :value, _Union(_Array(JsonExpr), String, Integer, _Hash(String, JsonExpr), _Boolean) + + def evaluate(...) + case value + when Array + value.map { _1.evaluate(...) } + when Hash + value.transform_values { _1.evaluate(...) } + else + value + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + case value + when Array + q.group(0, "[", "]") do + q.seplist(value) { q.pp(_1) } + end + when Hash + q.group(0, "{", "}") do + q.seplist(value) do |k, v| + q.pp(k) + q.text(": ") + q.pp(v) + end + end + else + q.text(JSON.generate(value)) + end + end + end + + class Var < Literal::Enum(String) + include JsonExpr + + Principal = new("principal") + Action = new("action") + Resource = new("resource") + Context = new("context") + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text(value) + end + + def evaluate(parc, entities) + case value + in "principal" + parc.principal.evaluate(parc, entities) + in "action" + parc.action.evaluate(parc, entities) + in "resource" + parc.resource.evaluate(parc, entities) + in "context" + parc.context + end + end + end + + class Slot < Literal::Enum(String) + Principal = new("?principal") + Resource = new("?resource") + end + + # Unknown = new(_Never) + # Neg = new(JsonExpr) + # BinaryOp = new(_Tuple(String, JsonExpr, JsonExpr)) + class BinaryOp < Literal::Struct + include JsonExpr + + class Op < Literal::Enum(String) + Add = new("+") + Sub = new("-") + Mul = new("*") + Div = new("/") + Lt = new("<") + Le = new("<=") + Gt = new(">") + Ge = new(">=") + Eq = new("==") + Ne = new("!=") + And = new("&&") + Or = new("||") + In = new("in") + Like = new("like") + end + prop :op, Op + prop :lhs, JsonExpr + prop :rhs, JsonExpr + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.group(0, "(", ")") do + q.pp(lhs) + q.text(" ") + q.text(op.value) + q.text(" ") + q.pp(rhs) + end + end + + def evaluate(parc, entities) + lhs = self.lhs.evaluate(parc, entities) + + case [lhs, op] + in true, Op::Or + return true + in false, Op::And + return false + in false, Op::Or + rhs = self.rhs.evaluate(parc, entities) + Literal.check(actual: rhs, expected: Literal::Types::BooleanType) + return rhs + in true, Op::And # rubocop:disable Lint/DuplicateBranch + rhs = self.rhs.evaluate(parc, entities) + Literal.check(actual: rhs, expected: Literal::Types::BooleanType) + return rhs + else + # rhs needs to evaluate + end + + rhs = self.rhs.evaluate(parc, entities) + + case [lhs, op, rhs] + in Integer, Op::Le | Op::Lt | Op::Gt | Op::Ge, Integer + lhs.public_send(op.value, rhs) + in Integer, Op::Add | Op::Sub | Op::Mul, Integer + result = lhs.public_send(op.value, rhs) + raise RangeError, "#{result} (#{lhs} #{op.value} #{rhs}) out of bounds" unless result.bit_length < 64 + + result + in l, Op::Eq | Op::Ne, r if l.instance_of?(r.class) + l.public_send(op.value, r) + in IPAddr, Op::Eq, String + lhs.to_s == rhs + in _, Op::Eq, _ + false + in _, Op::Ne, _ + true + in Entity, Op::In, Entity + entities.in?(lhs: lhs.uid, rhs: rhs.uid) + in String, Op::Like, String + parts = rhs.scan(/(?:[^\\*]|\\\*)+|\*/) + parts.map! do |part| + case part + when "*" + ".*" + when '\*' + part + else + Regexp.escape(part) + end + end + pattern = "\\A#{parts.join}\\z" + Regexp.new(pattern).match?(lhs) + in Entity, Op::In, Array + rhs.reduce(false) do |acc, elem| + Literal.check(actual: elem, expected: Entity) + acc && entities.in?(lhs: lhs.uid, rhs: elem.uid) + end + else + raise Error, + "BinaryOp#evaluate not implemented for #{lhs.class} #{op.value} #{rhs.class}\n" \ + "#{{ lhs:, rhs:, left: self.lhs, right: self.rhs }.inspect}" + end + end + end + + class Dot < Literal::Struct + include JsonExpr + + prop :left, JsonExpr + prop :attr, String + + def evaluate(parc, entities) + left = self.left.evaluate(parc, entities) + + case left + when Entity + result = left.attrs.fetch(attr) { raise Error, "Unknown attribute\n\t#{attr} not in #{left.uid}" } + if result.is_a?(Hash) + if result.size == 2 && result.key?("id") && result.key?("type") + uid = Entity::UID.from_json(result) + entities.fetch(uid) + else + result + end + else + result + end + when Hash + left.fetch(attr) { raise Error, "Unknown attribute\n\t#{attr} not in #{left.keys}" } + else + raise Error, "Dot#evaluate not implemented for #{left.class}\n#{pretty_inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.pp(left) + if /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.match?(attr) + q.text(".") + q.text(attr) + else + q.text("[") + q.pp(attr) + q.text("]") + end + end + end + + class Has < Literal::Struct + include JsonExpr + + prop :left, JsonExpr + prop :attr, String + + def evaluate(...) + left = self.left.evaluate(...) + + case left + when Entity + left.attrs.key?(attr) + when Hash + left.key?(attr) + else + raise Error, "Has#evaluate not implemented for #{left.class}\n#{pretty_inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.pp(left) + q.text(" has ") + q.pp(attr) + end + end + + # Has = new(String) + # Like = new(String) + # IfThenElse = new(_Tuple(JsonExpr, JsonExpr, JsonExpr)) + # Set = new(String) + # Record = new(String) + + class Function < Literal::Struct + include JsonExpr + + prop :name, String + prop :args, _Array(JsonExpr) + + def evaluate(parc, entities) + receiver, *args = @args.map { _1.evaluate(parc, entities) } + + case [receiver, name] + in [Array, "contains"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + case arg + when Entity + receiver.any? { entities.in?(lhs: arg.uid, rhs: Entity::UID.from_json(_1)) } + else + receiver.include?(args.first) + end + in [String, "decimal"] + raise ArgumentError, "Expected 0 arguments, got #{args}" unless args.empty? + + raise Error, "Invalid decimal: #{receiver}" unless receiver.match?(/\A[+-]?[0-9]+(\.[0-9]+)\z/) + + Rational(receiver) + in [String, "ip"] + raise ArgumentError, "Expected 0 arguments, got #{args}" unless args.empty? + + IPAddr.new(receiver) + in [Rational, "greaterThan"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + Literal.check(actual: arg, expected: Rational) + receiver > Rational(arg) + in [Rational, "greaterThanOrEqual"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + receiver >= Rational(arg) + in [Rational, "lessThan"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + receiver < Rational(arg) + in [Rational, "lessThanOrEqual"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + receiver <= Rational(arg) + in [IPAddr, "isLoopback"] + raise ArgumentError, "Expected 0 arguments, got #{args}" unless args.empty? + + receiver.loopback? + in [IPAddr, "isMulticast"] + raise ArgumentError, "Expected 0 arguments, got #{args}" unless args.empty? + + case receiver.family + when Socket::AF_INET + IPAddr.new("224.0.0.0/4").include?(receiver) + else + false + end + in [IPAddr, "isInRange"] + raise ArgumentError, "Expected 1 arguments, got #{args}" unless args.size == 1 + + arg = args.first + Literal.check(actual: arg, expected: IPAddr) + arg.include?(receiver) + else + raise Error, "Function#evaluate not implemented for #{receiver.class}.#{name}\n" \ + "#{pretty_inspect}\n#{receiver.inspect}\n#{@args[0].inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + receiver, *args = @args + q.pp(receiver) + q.text(".") + q.text(name) + q.group(0, "(", ")") do + q.seplist(args) { q.pp(_1) } + end + end + end + + class IfThenElse < Literal::Struct + include JsonExpr + + prop :cond, JsonExpr + prop :then, JsonExpr + prop :else, JsonExpr + + def evaluate(...) + c = cond.evaluate(...) + if c.is_a?(TrueClass) + @then.evaluate(...) + elsif c.is_a?(FalseClass) + @else.evaluate(...) + else + raise Error, "if condition must be a boolean, not #{c.class}\n\t#{c.inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.group(0, "(", ")") do + q.text("if ") + q.pp(cond) + q.text(" then ") + q.pp(@then) + q.text(" else ") + q.pp(@else) + end + end + end + + class Neg < Literal::Struct + include JsonExpr + + prop :expr, JsonExpr + + def evaluate(...) + value = expr.evaluate(...) + case value + when Integer + raise RangeError, "#{-value} [-(#{expr})] out of bounds" unless (-value).bit_length < 64 + + -value + else + raise Error, "Neg#evaluate not defined for #{value.class}\n\t#{value.inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("-") + q.pp(expr) + end + end + + class Not < Literal::Struct + include JsonExpr + + prop :expr, JsonExpr + + def evaluate(...) + value = expr.evaluate(...) + case value + when TrueClass, FalseClass + !value + else + raise Error, "Not#evaluate not defined for #{value.class}\n\t#{value.inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("!") + q.pp(expr) + end + end + + class Is < Literal::Struct + include JsonExpr + prop :left, JsonExpr + prop :entity_type, String + prop :in, _Nilable(JsonExpr) + + def evaluate(parc, entities) + left = self.left.evaluate(parc, entities) + + case left + when Entity + return false unless entities.is?(lhs: left.uid, type: entity_type) + + if @in + in_ = @in.evaluate(parc, entities) + raise Error, "in must evaluate to an array" unless in_.is_a?(Array) + + in_.any? { entities.in?(lhs: left.uid, rhs: Entity::UID.from_json(_1)) } + else + true + end + else + raise Error, "Is#evaluate not implemented for #{left.class}\n#{pretty_inspect}" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.pp(left) + q.text(" is ") + q.text(entity_type) + return unless @in + + q.text(" in ") + q.pp(@in) + end + end + + def evaluate(...) + raise Error, "#{self.class.name}#evaluate not implemented, called on #{inspect}" + end + end + + class Effect < Literal::Enum(String) + Permit = new("permit") + Forbid = new("forbid") + end + + module Principal + class All + include Principal + def satisfies?(_, _) = true + def pretty_print(q) = q.text("principal") # rubocop:disable Naming/MethodParameterName + end + ALL = All.new.freeze + + class Eq < Literal::Struct + include Principal + prop :ref, Entity::UID + + def satisfies?(parc, _) + parc.principal == ref + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("principal = ") + q.pp(ref) + end + end + + class In < Literal::Struct + include Principal + prop :ref, Entity::UID + + def satisfies?(parc, entities) + entities.in?(lhs: parc.principal, rhs: ref) + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("principal in ") + q.pp(ref) + end + end + + class Is < Literal::Struct + include Principal + prop :type, String + prop :ref, _Nilable(Entity::UID) + + def satisfies?(parc, entities) + return false unless entities.is?(lhs: parc.principal, type:) + + if ref + entities.in?(lhs: parc.principal, rhs: ref) + else + true + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("principal is ") + q.text(type) + return unless ref + + q.text(" in ") + q.pp(ref) + end + end + + def self.from_json(json) + case json.fetch("op") + when "All" + All + else + Principal[json.fetch("op")] + end + end + end + + module Action + class All + include Action + def satisfies?(_, _) = true + def pretty_print(q) = q.text("action") # rubocop:disable Naming/MethodParameterName + end + ALL = All.new.freeze + class Eq < Literal::Struct + include Action + prop :ref, Entity::UID + def satisfies?(parc, _) + parc.action == ref + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("action = ") + q.pp(ref) + end + end + + class In < Literal::Struct + include Action + prop :refs, _Array(Entity::UID) + + def satisfies?(parc, entities) + refs.any? { entities.in?(lhs: parc.action, rhs: _1) } + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("action in ") + q.group(0, "[", "]") do + q.seplist(refs) { q.pp(_1) } + end + end + end + + def self.from_json(json) + case json.fetch("op") + when "All" + All + else + Action[json.fetch("op")] + end + end + end + + module Resource + class All + include Resource + def satisfies?(_, _) = true + def pretty_print(q) = q.text("resource") # rubocop:disable Naming/MethodParameterName + end + ALL = All.new.freeze + class Eq < Literal::Struct + include Resource + prop :ref, Entity::UID + def satisfies?(parc, _) + parc.resource == ref + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("resource = ") + q.pp(ref) + end + end + + class In < Literal::Struct + include Resource + prop :ref, Entity::UID + def satisfies?(parc, entities) + entities.in?(lhs: parc.resource, rhs: ref) + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("resource in ") + q.pp(ref) + end + end + + class Is < Literal::Struct + include Resource + prop :type, String + prop :ref, _Nilable(Entity::UID) + + def satisfies?(parc, entities) + return false unless entities.is?(lhs: parc.resource, type:) + + ref ? entities.in?(lhs: parc.resource, rhs: ref) : true + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text("resource is ") + q.text(type) + return unless ref + + q.text(" in ") + q.pp(ref) + end + end + + def self.from_json(json) + case json.fetch("op") + when "All" + ALL + else + Resource[json.fetch("op")] + end + end + end + + module Condition + class When < Literal::Struct + include Condition + prop :expr, JsonExpr + + def satisfies(...) + result = expr.evaluate(...) + if result.is_a?(TrueClass) + true + elsif result.is_a?(FalseClass) + false + else + raise Error, "Expected boolean, got #{result.inspect}" + end + rescue Error, Literal::TypeError, IPAddr::InvalidAddressError, RangeError => e + e + end + end + + class Unless < Literal::Struct + include Condition + prop :expr, JsonExpr + + def satisfies(...) + result = expr.evaluate(...) + if result.is_a?(FalseClass) + true + elsif result.is_a?(TrueClass) + false + else + raise Error, "Expected boolean, got #{result.inspect}" + end + rescue Error, Literal::TypeError => e + e + end + end + + def self.included(_base) + raise Error, "Use When or Unless" + end + + def self.from_json(json) + case json.fetch("kind") + when "when" + When + when "unless" + Unless + else + raise Error, "Unknown condition kind" + end + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + q.text(self.class.name.downcase.split("::").last) + q.group(0, "{", "}") do + q.pp(expr) + end + end + end + + prop :effect, Effect + prop :principal, Principal + prop :action, Action + prop :resource, Resource + prop :conditions, _Array(Condition) + prop :annotations, _JSONData? + + def satisfies(...) + principal = @principal.satisfies?(...) + action = @action.satisfies?(...) + resource = @resource.satisfies?(...) + + pp(principal:, action:, resource:) if false # rubocop:disable Lint/LiteralAsCondition + + return false unless principal && action && resource + + conditions.each do |cond| + res = cond.satisfies(...) + return res unless res == true + end + + true + end + + def self.from_json(json) + new( + effect: Effect[json["effect"]], + principal: Principal.from_json(json["principal"]), + action: Action.from_json(json["action"]), + resource: Resource.from_json(json["resource"]), + conditions: json["conditions"]&.map { Condition.new(_1) } + ) + end + + def pretty_print(q) # rubocop:disable Naming/MethodParameterName + annotations.each do |annotation| + annotation.each do |k, v| + q.text("@#{k}(") + q.pp(v) + q.text(")\n") + end + end + q.text(effect.value) + q.group(2, " (", ")") do + q.breakable + q.pp(principal) + q.text(",") + q.breakable + q.pp(action) + q.text(",") + q.breakable + q.pp(resource) + q.breakable + end + conditions.each do |cond| + q.pp(cond) + q.breakable + end + q.text(";") + end + end + + class PolicySet < Literal::Struct + class Reference < Literal::Struct + prop :condition, String + prop :name, String + end + + prop :static_policies, _Array(Policy) + prop :templates, _Array(Policy) + prop :template_links, + _Array(_Map(**{ "templateId" => String, "newId" => String, "values" => _Hash(String, String) })) + + def self.from_json(json) + new( + static_policies: json["staticPolicies"]&.map { Policy.from_json(_1) }, + templates: json["templates"] || [], + template_links: json["templateLinks"] || [] + ) + end + + def self.parse(string) + PolicyParser.parse(string) + end + end + + class PolicyParser + class Error < Error + end + + def self.parse(string) + new(string).parse + end + + if false # rubocop:disable Lint/LiteralAsCondition + def self.method_added(name) + super + return unless name.match?(/\Aparse_/) && !method_defined?(:"__#{name}") # rubocop:disable Performance/StartWith + + alias_method :"__#{name}", name + define_method(name) do |*args| + pos = @scanner.pos + send(:"__#{name}", *args).tap do |result| + pp(name => result, args:, span: @scanner.string[pos...@scanner.pos]) if result || @scanner.pos != pos + end + end + end + end + + def initialize(string) + require "strscan" + @scanner = StringScanner.new(string) + end + + def parse + policies = [] + loop do + skip_trivia + break if @scanner.eos? + + policies << parse_policy + end + PolicySet.new(static_policies: policies, templates: [], template_links: []) + rescue StandardError => e + context = @scanner.string[0..@scanner.pos] + raise e.exception("Error parsing policy: #{e.message}\n#{context.inspect}\n\n" \ + "#{@scanner.string[@scanner.pos..].inspect}") + end + + def parse_policy + annotations = [] + loop do + skip_trivia + a = parse_annotation + break unless a + + annotations << a + end + + effect = parse_effect || raise(Error, "Expected effect") + skip_trivia + @scanner.skip("(") || raise(Error, "Expected (") + principal, action, resource = parse_scope + skip_trivia + @scanner.skip(")") || raise(Error, "Expected )") + conditions = [] + # TODO: ensure that parse_condition made progress + loop do + skip_trivia + break if @scanner.skip(";") + + conditions << parse_condition + end + + skip_trivia + + Policy.new(effect:, principal:, action:, resource:, conditions:, + annotations:) + end + + def skip_trivia + @scanner.skip(%r{(?:\s+|//.*|\n)+}) + end + + def parse_effect + Policy::Effect.fetch(@scanner.scan(/permit|forbid/)) + end + + def parse_scope + principal = parse_principal + @scanner.skip(",") || raise(Error, "Expected ,") + action = parse_action + @scanner.skip(",") || raise(Error, "Expected ,") + resource = parse_resource + [principal, action, resource] + end + + def parse_principal + skip_trivia + raise Error, "missing principal" unless @scanner.skip("principal") + + skip_trivia + case @scanner.scan(/is|in|==/) + when nil + Policy::Principal::ALL + when "==" + ref = parse_entity || parse_literal("?principal") + Policy::Principal::Eq.new(ref:) + when "in" + ref = parse_entity || parse_literal("?principal") + Policy::Principal::In.new(ref:) + when "is" + skip_trivia + path = parse_path || raise(Error, "Expected path") + skip_trivia + ref = parse_entity || parse_literal("?principal") if @scanner.skip("in") + Policy::Principal::Is.new(type: path, ref:) + else + raise Error, "Expected is, in, or ==" + end + end + + def parse_action + skip_trivia + @scanner.skip("action") + skip_trivia + case @scanner.scan(/is|in|==/) + when nil + Policy::Action::ALL + when "==" + Policy::Action::Eq.new(ref: parse_entity || parse_literal("?action")) + when "in" + + l = surrounded("[", "]") { parse_ent_list } || parse_entity || parse_literal("?action") + Policy::Action::In.new(refs: l) + else + raise Error, "Expected is, in, or ==" + end + end + + def parse_resource + skip_trivia + @scanner.skip("resource") + skip_trivia + case (op = @scanner.scan(/is|in|==/)&.tap { skip_trivia }) + when nil + Policy::Resource::ALL + when "in" + Policy::Resource::In.new(ref: parse_entity || parse_literal("?resource")) + when "==" + Policy::Resource::Eq.new(ref: parse_entity || parse_literal("?resource")) + when "is" + skip_trivia + path = parse_path || raise(Error, "Expected path") + skip_trivia + ref = parse_entity || parse_literal("?resource") if @scanner.skip("in") + Policy::Resource::Is.new(type: path, ref:) + else + raise Error, "Handle #{op.inspect}" + end + end + + def parse_annotation + return unless @scanner.skip(/@/) + + ident = parse_any_ident || raise(Error, "Expected identifier") + @scanner.skip("(") || raise(Error, "Expected (") + string = parse_str + @scanner.skip(")") || raise(Error, "Expected )") + { ident => string } + end + + def parse_any_ident + @scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/) + end + + def parse_str + return unless (str = @scanner.scan(/"(?:[^"\\]|\\.)*"/)) + + str.gsub!(/(?(c) { "\\u#{c.ord.to_s(16).rjust(4, "0")}" } + str.encode!(Encoding::US_ASCII, fallback: replace) + + str.undump.encode(Encoding::UTF_8) + rescue RuntimeError => e + raise e.exception("Error parsing string: #{e.message}\n\t#{str}") + end + + def parse_literal(literal) + @scanner.skip(literal) || raise(Error, "Expected #{literal.inspect}") + end + + def parse_entity + skip_trivia + pos = @scanner.pos + return unless (path = parse_path) + + unless @scanner.skip("::") + @scanner.pos = pos + return + end + id = parse_str || raise(Error, "Expected string") + Entity::UID.new(type: path, id:) + end + + def parse_path + return unless (ident = parse_ident) + + path = [ident] + loop do + pos = @scanner.pos + (@scanner.skip(/::/) && (id = parse_ident)) || (break @scanner.pos = pos) + path << id + end + path.join("::") + end + + def parse_ident + case ident = parse_any_ident + when "true", "false", "if", "then", "else", "in", "like", "has", "is", "__cedar" + @scanner.unscan + nil + else + ident + end + end + + def parse_condition + kind = @scanner.scan(/when|unless/) || raise(Error, "Expected when or unless") + skip_trivia + parse_literal("{") + skip_trivia + + expr = parse_expr + parse_literal("}") + case kind + when "when" + Policy::Condition::When.new(expr:) + when "unless" + Policy::Condition::Unless.new(expr:) + end + end + + def parse_expr + skip_trivia + if (o = parse_or) + return o + end + + return unless @scanner.skip("if") + + c = parse_expr + parse_literal("then") + t = parse_expr + parse_literal("else") + e = parse_expr + Policy::JsonExpr::IfThenElse.new(cond: c, then: t, else: e) + end + + def parse_or + return unless (a = parse_and) + + o = a + loop do + skip_trivia + break unless @scanner.skip("||") + + o = Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op::Or, lhs: o, rhs: parse_and) + end + o + end + + def parse_and + return unless (r = parse_relation) + + a = r + loop do + skip_trivia + break unless @scanner.skip("&&") + + rhs = parse_relation + raise Error, "Expected relation" unless rhs + + a = Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op::And, lhs: a, rhs:) + end + a + end + + def parse_relation + return unless (add = parse_add) + return add unless (op = @scanner.scan(Regexp.union("<=", "<", ">=", ">", "!=", "==", "in", "has", "like", + "is"))) + + skip_trivia + + case op + when "has" + Policy::JsonExpr::Has.new(left: add, attr: parse_ident || parse_str) + when "like" + Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op.fetch(op), lhs: add, rhs: parse_pat) + when "is" + path = parse_path + i = parse_add if @scanner.skip("in") + Policy::JsonExpr::Is.new(left: add, entity_type: path, in: i) + else + Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op.fetch(op), lhs: add, rhs: parse_add) + end + end + + def parse_add + return unless (lhs = parse_mult) + + a = lhs + loop do + op = @scanner.scan(/[-+]/) || break + a = Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op[op], lhs: a, rhs: parse_mult) + end + a + end + + def parse_mult + skip_trivia + return unless (unary = parse_unary) + + mult = unary + loop do + break unless @scanner.skip("*") + + mult = Policy::JsonExpr::BinaryOp.new(op: Policy::JsonExpr::BinaryOp::Op::Mul, lhs: mult, rhs: parse_unary) + end + mult + end + + def parse_unary + prefix = @scanner.scan(/[!-]{1,4}/) + member = parse_member + + case prefix + when nil + member + when "!" + Policy::JsonExpr::Not.new(expr: member) + when "-" + Policy::JsonExpr::Neg.new(expr: member) + else + raise Error, "Unary operator #{prefix.inspect} not implemented for #{member}" + end + end + + def parse_member + return unless (primary = parse_primary) + + member = primary + loop do + a = parse_access + case a + when nil + break + when Policy::JsonExpr::Function + Literal.check(actual: member, expected: Policy::JsonExpr) + a.args.unshift(member) + member = a + else + member = Policy::JsonExpr::Dot.new(left: member, attr: a) + end + end + member + end + + def parse_primary + skip_trivia + parse_lit || + parse_var || parse_entity || begin + pos = @scanner.pos + ef = parse_extfun + if ef && (e = surrounded("(", ")") { parse_expr_list || [] }) + Policy::JsonExpr::Function.new(name: ef, args: e) + else + @scanner.pos = pos + nil + end + end || + surrounded("(", ")") { parse_expr } || + surrounded("[", "]") { parse_expr_list || [] }&.then { Policy::JsonExpr::Value.new(value: _1) } || + surrounded("{", "}") { parse_rec_inits || Policy::JsonExpr::Value.new(value: {}) } + end + + def surrounded(left, right) + pos = @scanner.pos + skip_trivia + return unless @scanner.skip(left) + + skip_trivia + + unless (ret = yield) + @scanner.pos = pos + return + end + + skip_trivia + unless @scanner.skip(right) + @scanner.pos = pos + return + end + ret + end + + def parse_lit + if @scanner.skip("true") + true + elsif @scanner.skip("false") + false + elsif (match = @scanner.scan(/-?[0-9]+/)) + match.to_i + elsif (match = parse_str) + match + end&.then do |value| + Policy::JsonExpr::Value.new(value:) + end + end + + def parse_access + skip_trivia + if @scanner.skip(".") + ident = parse_ident || raise(Error, "no ident") + if (l = surrounded("(", ")") { parse_expr_list || [] }) + Policy::JsonExpr::Function.new(name: ident, args: l) + else + ident + end + else + surrounded("[", "]") { parse_str } + end + end + + def parse_var + Policy::JsonExpr::Var[@scanner.scan(/principal|action|resource|context/)] + end + + def parse_expr_list + return unless (e = parse_expr) + + list = [e] + loop do + break unless @scanner.skip(",") + + list << parse_expr + end + list + end + + def parse_extfun + pos = @scanner.pos + if (path = parse_path) + unless @scanner.skip("::") + @scanner.pos = pos + return parse_ident + end + [path, parse_ident] + else + parse_ident + end + end + + def parse_ent_list + list = [] + return list unless (ent = parse_entity) + + list << ent + loop do + break unless @scanner.skip(",") + + list << parse_entity + end + list + end + + def parse_rec_inits + record = {} + + skip_trivia + k = parse_ident || parse_str + return unless k + + @scanner.skip(":") || raise(Error, "Expected :") + skip_trivia + v = parse_expr + + record[k] = v + + loop do + break unless @scanner.skip(",") + + skip_trivia + + k = parse_ident || parse_str + @scanner.skip(":") || raise(Error, "Expected :") + skip_trivia + v = parse_expr + + record[k] = v + end + + Policy::JsonExpr::Value.new(value: record) + end + + def parse_pat + parse_str&.then do |str| + Policy::JsonExpr::Value.new(value: str) + end + end + end + + class Schema + def self.parse(string) + SchemaParser.parse(string) + end + end + + class SchemaParser + def self.parse(string) + new(string).parse + end + + def initialize(string) + @scanner = StringScanner.new(string) + end + + def parse + [] + end + end + end +end diff --git a/lib/sigstore/rekor/client.rb b/lib/sigstore/rekor/client.rb index 632a577..1272ba4 100644 --- a/lib/sigstore/rekor/client.rb +++ b/lib/sigstore/rekor/client.rb @@ -27,7 +27,7 @@ def initialize(url:) net = defined?(Gem::Net) ? Gem::Net : Net @session = net::HTTP.new(@url.host, @url.port) - @session.use_ssl = true + @session.use_ssl = @url.scheme != "http" end def self.production diff --git a/lib/sigstore/signer.rb b/lib/sigstore/signer.rb index da2e08f..dfdc1d6 100644 --- a/lib/sigstore/signer.rb +++ b/lib/sigstore/signer.rb @@ -112,7 +112,7 @@ def fetch_cert(csr) { "Content-Type" => "application/json" } ) - unless resp.code == "200" + unless resp.code == "200" || resp.code == "201" raise Error::Signing, "#{resp.code} #{resp.message}\n\n#{resp.body}" end diff --git a/policy-schema.kdl b/policy-schema.kdl new file mode 100644 index 0000000..b77b725 --- /dev/null +++ b/policy-schema.kdl @@ -0,0 +1,43 @@ +description "A policy for RubyGems" + +def "githubAttestation" owner="string" repository="string" workflow="string" environment="string?" { + attestation { + x509.Issuer "https://token.actions.githubusercontent.com" + x509.Repository (var)"repository" + x509.Workflow (var)"workflow" + + $if (var)"environment" { x509.Environment (var)"environment" } + $elseif (var)"environment" { x509.Environment (var)"environment" } + $else { x509.Environment "production" } + + $oneOf { + messageSignature + $all { + dsseEnvelope.payloadType "application/vnd.in-toto+json" + dsseEnvelope.payload { + _type "https://in-toto.io/Statement/v1" + predicateType "https://slsa.dev/provenance/v1" + predicate.buildDefinition.externalParameters.workflow.repository (var)"repository" + } + } + } + } +} + +rubygem "bundler" { + version.major.$gte 7 + platform "ruby" + source "https://rubygems.org" + + %githubAttestation owner="rubygems" repo="bundler" workflow=".github/workflows/release.yml" +} + +rubygem "bundler" { + version.major.$lt 3 + platform "ruby" + source "https://rubygems.org" +} + +/-test "test" { + x "y" +} diff --git a/policy-tests.kdl b/policy-tests.kdl new file mode 100644 index 0000000..95bcd46 --- /dev/null +++ b/policy-tests.kdl @@ -0,0 +1,221 @@ +test "empty document" { + document {} + ast r#"["PolicySet", nil, [], []]"# + + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby", + "version": { + "major": 7, + "minor": 0, + "patch": 0, + "exact": "7.0.0" + } + }"# + + match false + } + + example { + subject "{}" + match false + } +} + +test "empty policy" { + document { + rubygem "rails" {} + } + /-ast r#"["PolicySet", nil, {}, []]"# + + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby", + "version": { + "major": 7, + "minor": 0, + "patch": 0, + "exact": "7.0.0" + } + }"# + + match true + } + + example { + subject "{}" + match false + } +} + +test "simple policy" { + document { + rubygem "rails" { + platform "ruby" + } + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby" + }"# + + match true + } + + example { + subject r#"{ + "type": "rubygem", + "name": "boo", + "platform": "ruby" + }"# + + match false + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "boo" + }"# + + match false { + "" + } + } +} + +test "policy with macro" { + document { + (def)semver_tilde major="integer" minor="integer?" patch="integer?" { + (var)"major_plus_one" { + (op)"+" 1 (var)"major" + } + major (var)"major" + /- major ">="=(var)"major" + /- major "<"=(var)"major_plus_one" + } + rubygem "rails" { + version { + (call)semver_tilde major=7 minor=1 + } + } + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby", + "version": { "major": 7, "minor": 1 } + }"# + + match true + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby", + "version": { "major": 3, "minor": 1 } + }"# + + match false + } +} + +test "policy with nested attribute" { + document { + rubygem "rails" { + version.major 7 + version { + major 7 + } + } + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "version": { "major": 7 } + }"# + + match true + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "version": { "major": 6 } + }"# + + match false + } +} + +test "big policy" { + document { + description "A policy for RubyGems" + + def "githubAttestation" owner="string" repository="string" workflow="string" environment="string?" { + attestation { + x509.Issuer "https://token.actions.githubusercontent.com" + x509.Repository (var)"repository" + x509.Workflow (var)"workflow" + + $if (var)"environment" { x509.Environment (var)"environment" } + $elseif (var)"environment" { x509.Environment (var)"environment" } + $else { x509.Environment "production" } + + $oneOf { + messageSignature + $all { + dsseEnvelope.payloadType "application/vnd.in-toto+json" + dsseEnvelope.payload { + _type "https://in-toto.io/Statement/v1" + predicateType "https://slsa.dev/provenance/v1" + predicate.buildDefinition.externalParameters.workflow.repository (var)"repository" + } + } + } + } + } + + rubygem "bundler" { + version.major.$gte 7 + platform "ruby" + source "https://rubygems.org" + + %githubAttestation owner="rubygems" repo="bundler" workflow=".github/workflows/release.yml" + } + + rubygem "bundler" { + version.major.$lt 3 + platform "ruby" + source "https://rubygems.org" + } + } + + example { + subject r#"{ + "type": "rubygem", + "name": "rails", + "platform": "ruby", + "version": { "major": 3, "minor": 1 } + }"# + + match false + } +} diff --git a/policy.rb b/policy.rb new file mode 100644 index 0000000..7a114ee --- /dev/null +++ b/policy.rb @@ -0,0 +1,496 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "kdl" +require "literal" + +class KDLVisitor + def visit(node) + case node + when KDL::Node + visit_node(node) + when KDL::Value + visit_value(node) + when KDL::Document + visit_document(node) + else + raise ArgumentError, "unexpected node type: #{node.class}" + end + end + + def visit_document(document) + document.nodes.each { visit(_1) } + end + + def visit_node(node) + visit_arguments(node.arguments) + visit_properties(node.properties) + visit_children(node.children) + end + + def visit_children(children) + children.each { visit(_1) } + end + + def visit_arguments(arguments) + arguments.each { visit(_1) } + end + + def visit_properties(properties) + properties.each { |k, v| visit_property(k, v) } + end + + def visit_property(_key, value) + visit_value(value) + end + + def visit_value(value) + case value + when KDL::Value::Int + visit_int(value) + when KDL::Value::Float + visit_float(value) + when KDL::Value::Boolean + visit_boolean(value) + when KDL::Value::String + visit_string(value) + when KDL::Value::NullImpl + visit_null(value) + else + raise ArgumentError, "unexpected value type: #{value.class}" + end + end + + def visit_int(value); end + + def visit_float(value); end + + def visit_boolean(value); end + + def visit_string(value); end + + def visit_null(value); end + + def call(node) + visit(node) + self + end +end + +class PolicyParser < KDLVisitor + def initialize + super + @description = nil + @package_types = {} + @defs = {} + end + + def visit_node(node) + case node.name + when "description" + when "rubygem" + pp RubygemPolicyParser.new.call(node) + else + raise ArgumentError, "unexpected top-level node type: #{node.name}" + end + end +end + +class PropsType + def initialize(**shape) + @shape = shape + end + + def ===(value) + value.is_a?(Hash) && value.all? do |k, v| + @shape.any? do |k_type, v_type| + k_type === k && v_type === v + end + end + end + + def inspect + @shape.map { |k, v| "#{k.inspect}=#{v.inspect}" }.join(" ") + end +end + +class ExpectKDLNode + include Literal::Types + + class Children + def initialize(&blk) + @children = {} + instance_eval(&blk) if blk + end + + def respond_to_missing?(...) = true + + def method_missing(name, ...) + name = name.name + raise ArgumentError, "unexpected method: #{name}" if @children[name] + + @children[name] = ExpectKDLNode.new(name, ...) + end + + def ===(other) + other.is_a?(KDL::Node) && + @children[other.name] === other + end + end + + def initialize(name, *args, **kwargs, &) + @name = name + @args = args.empty? ? [] : _Tuple(*args) + @props = PropsType.new(**kwargs.transform_keys(&:to_s)) + @children = Children.new(&) + end + + def transform(&block) + @transform = block + self + end + + def parent(&block) + @parent = block + self + end + + def on_child(parent, value) + @parent.call(parent, value) + end + + def ===(other) + other.is_a?(KDL::Node) && @name === other.name && + @args === other.arguments && + @props === other.properties # && + # @children === other.children + end + + def inspect + "#{@name} #{@args.inspect} #{@props.inspect} #{@children.inspect}" + # super + end + + def record_literal_type_errors(context) + # return + return unless context.actual.is_a?(KDL::Node) + + context.add_child(label: "name", expected: @name, actual: context.actual.name) unless @name === context.actual.name + unless @args === context.actual.arguments + context.add_child(label: "arguments", expected: @args, + actual: context.actual.arguments) + end + return if @props === context.actual.properties + + context.add_child(label: "properties", expected: @props, + actual: context.actual.properties) + + # return if @children === context.actual.children + + # context.add_child(label: "children", expected: @children, + # actual: context.actual.children) + end + + def call(node, context: nil) + raise "Cannot call #{inspect} without a transform block" unless @transform + + i = 0 + args = @args.instance_variable_get(:@types)&.map do |type| + a = type.call(node.arguments[i]) + i += 1 + a + end + raise unless i >= node.arguments.size + + props = @props.instance_variable_get(:@shape).each_with_object({}) do |(k, v), h| + h[k.intern] = v.call(node.properties[k]) + rescue Exception + raise $!.exception($!.message + "\n\tin #{k.inspect}") + end + + result = @transform.call(*args, **props) + + node.children.map do |child| + ct = @children.instance_variable_get(:@children).fetch(child.name) + ct.on_child result, ct.call(child) + end + + result + rescue Exception + raise $!.exception($!.message + "\n\tin #{inspect}") + raise + end +end + +class PolicyDocument < Literal::Struct + prop :description, _String? + prop :package_types, _Hash(String, _Any), default: -> { {} } + prop :defs, _Hash(String, _Any), default: -> { {} } + + def self.from_kdl(kdl) + Literal.check(actual: kdl, expected: KDL::Document) + + kdl.nodes.each_with_object(new) do |node, policy| + case node.name + when "description" + _parse_description(node) + when "rubygem" + # policy.package_types["rubygem"] << RubygemPolicy.from_kdl(node) + when "def" + d = Def.from_kdl(node) + policy.defs[d.name] = d + else + raise ArgumentError, "unexpected top-level node type: #{node.name}" + end + end + end + + def self._parse_description(node) + Literal.check(actual: node, expected: ExpectKDLNode.new("description", String)) + end +end + +class KDLValueType + def initialize(value, type = nil) + @value = value + @type = type + end + + def ===(other) + other.is_a?(KDL::Value) && + @value === other.value && + @type === other.type + end +end + +class Def < Literal::Struct + prop :name, String + prop :props, _Hash(String, _Any) + prop :body, _Array(KDL::Node) + + extend Literal::Types + + def self.from_kdl(node) + Literal.check(actual: node, + expected: ExpectKDLNode.new("def", KDLValueType.new(String), + **{ String => KDLValueType.new(String) })) + + name = node.arguments[0].value + props = node.properties.each_with_object({}) do |(k, v), h| + h[k] = v.value + end + body = node.children + new(name:, props:, body:) + end + + def pretty_print(pp) + pp.text "def" + pp.text " " + pp.seplist(props, -> { pp.text(" ") }) do |(k, v)| + pp.text(k) + pp.text "=" + pp.pp(v) + end + return if body.empty? + + pp.group(1, " {", "}") do + pp.seplist(body, -> { pp.breakable("; ") }) do |node| + pp.breakable "" + pp.pp(node) + end + end + end +end + +class RubygemPolicyParser < KDLVisitor + def initialize + super + @name = nil + @requirements = [] + @verb = :permit + @fallback = false + + @state = :rubygem + end + + def visit_node(node) + raise unless @state == :rubygem + + @name = node.arguments + end +end + +class KDLValueTransformer + def initialize; end + + def call(value) + value.value + end +end + +class KDLDocumentTransformer + def initialize(nodes, &block) + @nodes = nodes + @block = block + end + + def call(document) + context = Literal::TypeError::Context.new + unless document.is_a?(KDL::Document) + context.add_child(label: nil, expected: KDL::Document, + actual: document) && return + end + + @block.call( + document.nodes.map { |node| @nodes.fetch(node.name).call(node, context: context) } + ) + end +end + +class KDLNodeTransformer + def initialize( + arguments, + properties, + children, + &block + ) + @arguments = arguments + @properties = properties + @children = children + @block = block + end + + def call(node, context: Literal::TypeError::Context.new) + # context.add_child(label: nil, expected: KDL::Node, actual: node) && return unless node.is_a?(KDL::Node) + + args = @arguments.call(node.arguments, context: context) + props = @properties.call(node.properties, context: context) + children = @children.call(node.children, context: context) + + @block.call(args, props, children) + end +end + +class Def < Literal::Struct + class Type + end + prop :name, String + prop :props, _Hash(String, Type) + prop :body, _Array(KDL::Node) + + extend Literal::Types + + def self.from_kdl(node) + Literal.check(actual: node, + expected: ExpectKDLNode.new("def", KDLValueType.new(String), + **{ String => KDLValueType.new(String) })) + + name = node.arguments[0].value + props = node.properties.transform_values(&:value) + body = node.children + new(name:, props:, body:) + end + + def pretty_print(pp) + pp.text "def" + pp.text " " + pp.seplist(props, -> { pp.text(" ") }) do |(k, v)| + pp.text(k) + pp.text "=" + pp.pp(v) + end + return if body.empty? + + pp.group(1, " {", "}") do + pp.seplist(body, -> { pp.breakable("; ") }) do |node| + pp.breakable "" + pp.pp(node) + end + end + end +end + +class Evaluable + def initialize(receiver) + @receiver = receiver + end + + def self.[](receiver) + new(receiver) + end +end + +class RubygemRule < Literal::Struct + prop :deny, _Boolean, default: -> { false } + prop :version, ::Gem::Requirement + prop :platform, _Nilable(::Gem::Platform) + prop :source, _String? + + prop :body, Evaluable[{ + attestation: Evaluable[{ + x509: { + String => _Any? + }, + messageSignature: {}, + dsse_envelope: { + payloadType: _String, + payload: Hash + } + }] + }] +end + +class Sip < Literal::Struct + prop :description, _String? + prop :defs, _Hash(String, Def), default: -> { {} } + prop :package_rules, _Hash(String, _Any), default: -> { Hash.new { |h, k| h[k] = [] } } +end + +doc = KDL.parse_document(File.read("policy-schema.kdl")) +# pp doc +# pp PolicyDocument.from_kdl(doc) + +KDLDocumentTransformer.new( + { + "description" => ExpectKDLNode.new("description", KDLValueTransformer.new).transform do |value| + { "description" => value } + end, + "def" => ->(a, _) { { "def" => a.to_s.gsub(/\n{2,}/, "\n") } }, + "rubygem" => ExpectKDLNode.new("rubygem", KDLValueTransformer.new) do + version(KDLValueTransformer.new).transform { |*version| Gem::Requirement.new(*version) }.parent do |p, version| + p[:version] = version + end + platform(KDLValueTransformer.new).transform { |platform| platform }.parent do |p, platform| + p[:platform] = platform + end + source(KDLValueTransformer.new).transform { |source| source }.parent do |p, source| + p[:source] = source + end + + send(:"%githubAttestation", + owner: KDLValueTransformer.new, + "repo" => KDLValueTransformer.new, + workflow: KDLValueTransformer.new).transform do |owner:, repo:, workflow:| + { owner:, repo:, workflow: } + end.parent do |p, attestation| + p[:attestation] = attestation + end + end + .transform do |name| + { "rubygem" => name } + end + } +) do |nodes| + nodes.each_with_object Sip.new do |node, sip| + case node.keys + when ["description"] + sip.description = node["description"] + when ["def"] + sip.defs[nil] = node + when ->(k) { k.include?("rubygem") } + sip.package_rules["rubygem"] << node + else + raise ArgumentError, "unexpected node type: #{node}" + end + end +end.call(doc).tap { pp _1 } diff --git a/policy2.rb b/policy2.rb new file mode 100644 index 0000000..a129a0d --- /dev/null +++ b/policy2.rb @@ -0,0 +1,599 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "kdl" +require "literal" +require "sigstore" +require "sigstore/models" + +KDL.parse_document(File.read("policy-schema.kdl")) + +class PolicySet < Literal::Struct + prop :description, _String? + prop :defs, _Hash(_String, _Any), default: -> { {} } + prop :policies_by_package_type, _Hash(_String, _Array(_Any)), default: -> { {} } + + def authorize(subject) + case subject + when RubygemSubject + policies = policies_by_package_type["rubygem"] + relevant_policies = policies.select do |policy| + policy.name == subject.name + end + + return { effect: "deny", relevant_policies: } if relevant_policies.empty? + + results = Hash.new { |h, k| h[k] = [] } + relevant_policies.each do |policy| + ev = policy.evaluate(subject, self) + results[ev] << policy + end + results + else + raise ArgumentError, "Unsupported subject type" + end + end +end + +class Policy < Literal::Struct + prop :name, _String + prop :effect, _Union("allow", "deny"), default: -> { "allow" } + prop :rules, _Array(_Any), default: -> { [] } + + def evaluate(subject, doc) + (rules.to_h do |rule| + [rule, rule.evaluate(subject, doc, {})] + end.all? { _2 } && effect.to_sym) || :deny + end +end + +class Macro < Literal::Struct + prop :name, _String + prop :kwargs, _Hash(_String, _Any), default: -> { {} } + prop :body, _Array(_Any), default: -> { [] } + + def evaluate(this, doc, kwargs) + body.to_h do |rule| + [rule, rule.evaluate(this, doc, kwargs)] + end + end + + def self.from_kdl(node) + case node + in KDL::Node[ "def", [[name, nil, nil]], props, body ] + kwargs = props + new(name: name, kwargs: kwargs, body: body.map { Rule.from_kdl(_1) }) + end + end +end + +class KDL::Document + def deconstruct + nodes + end +end + +class KDL::Node + def deconstruct + [name, arguments, properties, children] + end +end + +class KDL::Value + def deconstruct + [value, format, type] + end +end + +class Rule < Literal::Struct + def evaluate(this, doc) + raise NotImplementedError, "#{self.class.name}#evaluate not implemented" + end + + def self.from_kdl(node, scope = nil) + case node + + in KDL::Value[value, nil, nil] + value + in KDL::Value[value, nil, "var"] + VarRef.new(name: value) + in KDL::Node[ "$if", [cond], {}, children ] + IfRule.new(cond: from_kdl(cond, scope), then: children.map { from_kdl(_1, scope) }) + in KDL::Node[ "$elseif", [cond], {}, children ] + ElseIfRule.new(cond: from_kdl(cond, scope), then: children.map { from_kdl(_1, scope) }) + in KDL::Node[ "$else", [], {}, children ] + ElseRule.new(then: children.map { from_kdl(_1, scope) }) + in KDL::Node[ /\A%(.+)/, [], kwargs, [] ] + MacroCall.new(name: ::Regexp.last_match(1), kwargs: kwargs.transform_values { from_kdl(_1, scope) }) + in KDL::Node[ name, [], {}, children ] unless children.empty? + n = scoped(scope, name) + ScopeRule.new(key: name, rules: children.map { from_kdl(_1, n) }) + in KDL::Node[ name, arguments, {}, [] ] + n = scoped(scope, name) + KeyValueRule.new(key: n, value: arguments.map { from_kdl(_1, n) }) + end + end + + def self.scoped(base, part) + return base if part == "$this" + return part if base.nil? + + "#{base}.#{part}" + end +end + +class MacroCall < Rule + prop :name, _String + prop :kwargs, _Hash(_String, _Any) + + def evaluate(this, doc, kwargs) + doc.defs.fetch(name).evaluate(this, doc, kwargs) + end +end + +class IfRule < Rule + prop :cond, _Any + prop :then, _Array(_Any) + + def evaluate(this, doc, kwargs) + if cond.evaluate(this, doc, kwargs) + self.then.all? do |rule| + rule.evaluate(this, doc, kwargs) + end + else + true + end + end +end + +class ElseIfRule < Rule + prop :cond, _Any + prop :then, _Array(_Any) +end + +class ElseRule < Rule + prop :then, _Array(_Any) +end + +class KeyValueRule < Rule + prop :key, _String + prop :value, _Any + + def evaluate(this, _doc, _kwargs) + return value == this if key == "$this" + + this.match_attribute?(key, value) + rescue Exception => e + raise ArgumentError, "Failed to match attribute #{key.inspect} with value #{value.inspect} on #{this.inspect}: #{e}" + end +end + +class MacroRule < Rule + prop :name, _String + prop :kwargs, _Hash(_String, _Any), default: -> { {} } + + def evaluate(this, doc, kwargs) + doc.defs.fetch(name).evaluate(this, doc, kwargs) + end +end + +class ScopeRule < Rule + prop :key, _String + prop :rules, _Array(_Any) + + def evaluate(this, doc, kwargs) + rules.all? do |rule| + rule.evaluate(this, doc, kwargs) + end + end +end + +class VarRef < Rule + prop :name, _String + + def evaluate(_this, _doc, kwargs) + kwargs.fetch(name) + end +end + +PolicySet.new( + description: "A set of policies", + defs: { + "githubAttestation" => Macro.new( + name: "githubAttestation", + kwargs: { + "owner" => "String", + "repo" => "String" + }, + body: [ + ScopeRule.new(key: "attestation", rules: [ + KeyValueRule.new(key: "x509.Issuer", value: "https://token.actions.githubusercontent.com") + ]) + ] + ) + }, + policies_by_package_type: { + "rubygem" => [ + Policy.new( + name: "bundler", + rules: [ + KeyValueRule.new(key: "version", value: ">= 7"), + KeyValueRule.new(key: "platform", value: "ruby"), + KeyValueRule.new(key: "source", value: "https://rubygems.org"), + MacroRule.new(name: "githubAttestation", kwargs: { "owner" => "rubygems", "repo" => "bundler" }) + ] + ) + ] + } +) + +class HashPattern < Literal::Struct + prop :pairs, _Array(_Tuple(_String, _Any)), :positional + + def ===(other) + return unless other.is_a?(Hash) + + other = other.dup + pairs.each do |key, value| + return false unless other.key?(key) + return false unless value === other.delete(key) + end + other.empty? + end + + def self.===(other) + super || other.is_a?(Hash) + end +end + +ToPattern = lambda { |object, indent = ""| + case object + in nil | true | false + "#{indent}#{object.inspect}" + in String + "#{indent}#{object.dump}" + in Array[*args] + "#{indent}[\n#{args.map { ToPattern[_1, "#{indent} "] }.join(",\n#{indent} ")}\n#{indent}]" + in Hash[**args] + "#{indent}{\n#{args.map do |k, v| + "#{indent}#{k}: #{ToPattern[v, "#{indent} "].strip}" + end.join(",\n")}\n#{indent}}" + in Object + if object.respond_to?(:deconstruct_keys) + "#{indent}#{object.class.name}[#{ToPattern[object.deconstruct_keys(nil), "#{indent} "].strip[1..-2]}]" + elsif object.respond_to?(:deconstruct) + "#{indent}#{object.class.name}#{ToPattern[object.deconstruct, "#{indent} "].strip}" + else + object.class.name + end + end +} + +ToSexp = lambda { |object| + case object + in true | false | nil | Integer | Float | Symbol | String + object + in Array[*args] + args.map { ToSexp[_1] } + in Hash[**args] + args.transform_values { ToSexp[_1] } + in Object + if object.respond_to?(:deconstruct_keys) + [object.class.name, object.deconstruct_keys(nil).transform_values { ToSexp[_1] }.transform_keys(&ToSexp)] + elsif object.respond_to?(:deconstruct) + [object.class.name, object.deconstruct.map { ToSexp[_1] }] + else + raise + end + end +} + +class RubygemSubject < Literal::Struct + prop :name, _String + prop :version, Gem::Version + prop :platform, _Union(Gem::Platform, Gem::Platform::RUBY) + prop :source, _String + + prop :attestation, _Array(Sigstore::SBundle) + + def match_attribute?(key, value) + case key + when "name" + value === name + when "version" + Gem::Requirement.create(value).satisfied_by?(version) + when "platform" + value => Array[pl] + + Gem::Platform.new(pl) === platform + when "source" + source == value + when /\Aattestation\.x509\.(.+)/ + else + raise ArgumentError, "Unknown attribute: #{key}" + end + end +end + +class Policy + def self.from_kdl(node) + name = nil + effect = "allow" + + node.arguments.each do |arg| + raise if name + + name = arg.value || raise + end + + node.properties.each do |key, value| + case key + in "effect" + effect = value.value || raise + end + end + + rules = node.children.map do |child| + Rule.from_kdl(child) + end + + new( + name: name, + effect: effect, + rules: rules + ) + end +end + +class PolicySet + def self.from_kdl(doc) + Literal.check(actual: doc, expected: KDL::Document) + + description = nil + defs = {} + policies_by_package_type = Hash.new { |h, k| h[k] = [] } + + doc.nodes.each do |node| + case node.name + when "description" + raise if description + + description = node.arguments[0].value + when "def" + raise if defs[node.arguments[0].value] + + defs[node.arguments[0].value] = Macro.from_kdl(node) + else + policies_by_package_type[node.name] << + Policy.from_kdl(node) + end + end + + new( + description: description, + defs: defs, + policies_by_package_type: policies_by_package_type + ) + end +end + +class Compiler + def initialize + @source = +"# frozen_string_literal: true\ndef call(subject)\n" + end + + def compile(node) + visit(node) + @source + + # Module.new do + # module_eval source + # end + end + + def visit(node) + send("visit_#{node.class.name.gsub("::", "_")}", node) + end + + def visit_PolicySet(node) + node.policies_by_package_type.each do |package_type, policies| + @package_type = package_type + @source << " # Policies for #{package_type}\n" + @source << " if subject.name == #{package_type.inspect}; while true\n" + policies.each do |policy| + visit(policy) + end + @source << " break; end\n" + @source << " end\n" + end + @source << "end\n" + end + + def visit_Policy(node) + @source << " # Policy #{node.name}\n" + @source << " # Effect: #{node.effect}\n" + node.rules.each do |rule| + visit(rule) + end + end + + def visit_KeyValueRule(node) + @source << " break unless subject[#{node.key.inspect}] === #{node.value.inspect}\n" + end +end + +require "test/unit/autorunner" + +class T < Test::Unit::TestCase + def test_parse_doc + doc = KDL.parse_document <<~KDL + rubygem "bundler" { + version "~> 7" + } + KDL + + set = PolicySet.from_kdl(doc) + assert_nothing_raised do + set => PolicySet[ + description: nil, + defs: {}, + policies_by_package_type: policies + ] + policies["rubygem"] => [ + Policy[ + name: "bundler", + effect: "allow", + rules: [ + KeyValueRule[ + key: "version", + value: ["~> 7"] + ] + ] + ] + ] + end + + subject = RubygemSubject.new( + name: "bundler", + version: Gem::Version.new("7.0.0"), + platform: Gem::Platform.new("ruby"), + source: "https://rubygems.org", + attestation: [] + ) + + set.authorize(subject) => { + allow: [Policy[name: "bundler"]] + } + end + + def test_parse_doc_scope + doc = KDL.parse_document <<~KDL + rubygem "bundler" { + platform { + $this "ruby" + $this "ruby" + } + } + KDL + + set = PolicySet.from_kdl(doc) + assert_nothing_raised do + set => PolicySet[ + description: nil, + defs: {}, + policies_by_package_type: policies + ] + policies["rubygem"] => [ + Policy[ + name: "bundler", + effect: "allow", + rules: [ + ScopeRule[key: "platform", rules: [ + KeyValueRule[ + key: "platform", value: ["ruby"] + ], + KeyValueRule[ + key: "platform", value: ["ruby"] + ]]] + ] + ] + ] + end + subject = RubygemSubject.new( + name: "bundler", + version: Gem::Version.new("7.0.0"), + platform: Gem::Platform.new("ruby"), + source: "https://rubygems.org", + attestation: [] + ) + + set.authorize(subject) => { + allow: [Policy[name: "bundler"]] + } + end + + def test_parse_big + doc = KDL.parse_document(File.read("policy-schema.kdl")) + + set = PolicySet.from_kdl(doc) + + assert_equal ["PolicySet", + { description: "A policy for RubyGems", + defs: { "githubAttestation" => + ["Macro", + { name: "githubAttestation", + kwargs: { "owner" => ["KDL::Value::String", ["string", nil, nil]], + "repository" => ["KDL::Value::String", + ["string", nil, nil]], + "workflow" => ["KDL::Value::String", ["string", nil, nil]], + "environment" => ["KDL::Value::String", + ["string?", nil, nil]] }, + body: [["ScopeRule", + { key: "attestation", + rules: [["KeyValueRule", { key: "attestation.x509.Issuer", value: ["https://token.actions.githubusercontent.com"] }], + ["KeyValueRule", + { key: "attestation.x509.Repository", + value: [["VarRef", { name: "repository" }]] }], + ["KeyValueRule", + { key: "attestation.x509.Workflow", + value: [["VarRef", { name: "workflow" }]] }], + ["IfRule", + { cond: ["VarRef", { name: "environment" }], + then: [["KeyValueRule", + { key: "attestation.x509.Environment", + value: [["VarRef", + { name: "environment" }]] }]] }], + ["ElseIfRule", + { cond: ["VarRef", { name: "environment" }], + then: [["KeyValueRule", + { key: "attestation.x509.Environment", + value: [["VarRef", + { name: "environment" }]] }]] }], + ["ElseRule", + { then: [["KeyValueRule", + { key: "attestation.x509.Environment", + value: ["production"] }]] }], + ["ScopeRule", + { key: "$oneOf", + rules: [["KeyValueRule", { key: "attestation.$oneOf.messageSignature", value: [] }], + ["ScopeRule", + { key: "$all", + rules: [["KeyValueRule", { key: "attestation.$oneOf.$all.dsseEnvelope.payloadType", value: ["application/vnd.in-toto+json"] }], + ["ScopeRule", + { key: "dsseEnvelope.payload", + rules: [["KeyValueRule", { key: "attestation.$oneOf.$all.dsseEnvelope.payload._type", value: ["https://in-toto.io/Statement/v1"] }], + ["KeyValueRule", + { key: "attestation.$oneOf.$all.dsseEnvelope.payload.predicateType", + value: ["https://slsa.dev/provenance/v1"] }], + ["KeyValueRule", + { + key: "attestation.$oneOf.$all.dsseEnvelope.payload.predicate.buildDefinition.externalParameters.workflow.repository", value: [[ + "VarRef", { name: "repository" } + ]] + }]] }]] }]] }]] }]] }] }, + policies_by_package_type: { "rubygem" => + [["Policy", + { name: "bundler", + effect: "allow", + rules: [["KeyValueRule", { key: "version", value: [">= 7"] }], + ["KeyValueRule", { key: "platform", value: ["ruby"] }], + ["KeyValueRule", + { key: "source", value: ["https://rubygems.org"] }], + ["MacroCall", + { name: "githubAttestation", + kwargs: { "owner" => "rubygems", "repo" => "bundler", + "workflow" => ".github/workflows/release.yml" } }]] }]] } }], ToSexp[set] + + subject = RubygemSubject.new( + name: "bundler", + version: Gem::Version.new("7.0.0"), + platform: Gem::Platform.new("ruby"), + source: "https://rubygems.org", + attestation: [] + ) + + set.authorize(subject) => { + allow: [Policy[name: "bundler"]] + } + end +end diff --git a/policy3.rb b/policy3.rb new file mode 100755 index 0000000..14bfb53 --- /dev/null +++ b/policy3.rb @@ -0,0 +1,552 @@ +#! /usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "literal" +require "paramesan" +require "test/unit/autorunner" +require "kdl" +require "json" + +class KDL::Node + def deconstruct + [name, arguments, properties, children] + end +end + +class KDL::Value + def deconstruct + [value, format, type] + end + + def deconstruct_keys(_keys) + { value:, format:, type: } + end +end + +module ASTNode + def to_ast + [self.class.name, *to_h.values.map do |value| + if value.respond_to?(:to_ast) + value.to_ast + elsif value.respond_to?(:map) && !value.is_a?(Hash) + value.map(&:to_ast) + elsif value.is_a?(Hash) + value.transform_values do |v| + if v.respond_to?(:to_ast) + v.to_ast + else + v + end + end.to_a + else + value + end + end] + end + + def evaluate(context) + raise NotImplementedError, "#{self.class}#evaluate not implemented" + end + + def to_s + raise NotImplementedError, "#{self.class}#to_s not implemented" + end +end + +class LVal < Literal::Struct + include ASTNode + class This < LVal + def evaluate(ctx) + ctx.this + end + + def to_s + "$this" + end + end + + class PropertyAccess < LVal + prop :lhs, LVal + prop :name, String + + def evaluate(ctx) + l = lhs.evaluate(ctx) + l.fetch(name) + end + + def to_s + "#{lhs}.#{name}" + end + end +end + +class RVal < Literal::Struct + include ASTNode + + class Variable < RVal + prop :name, String + + def to_s + "(var)#{name.inspect}" + end + + def evaluate(ctx) + ctx.vars.fetch(name) + end + end + + class Literal < RVal + prop :value, _Any + + def to_s + value.inspect + end + + def evaluate(_) + value + end + end + + class Presence < RVal + end + + def self.from_kdl(node) + case node + in KDL::Value[value, nil, nil] + Literal.new(value:) + in KDL::Value::String[name, nil, "var"] + Variable.new(name:) + in KDL::Value::String[pattern, nil, "regex"] + Literal.new(value: Regexp.new(pattern)) + end + end +end + +class Rule < Literal::Struct + include ASTNode + + class Scope < Rule + prop :lhs, LVal + prop :rules, _Array(Rule) + + def evaluate(ctx) + this = ctx.this + ctx.this = lhs.evaluate(ctx) + rules.each do |rule| + rule.evaluate(ctx) + end + ensure + ctx.this = this + end + end + + class KeyValue < Rule + prop :key, LVal + prop :value, RVal + + def evaluate(ctx) + return if value.evaluate(ctx) === key.evaluate(ctx) + + ctx.add_failure(self) + end + + def to_s + "#{key} #{value}" + end + end + + class Condition < Rule + prop :if_then, _Array(_Tuple(LVal, Rule)) + prop :else, _Array(Rule) + end + + class Call < Rule + prop :name, String + prop :props, _Hash(String, RVal) + + def evaluate(ctx) + vars = ctx.vars.dup + ctx.vars.merge!(props.transform_values { _1.evaluate(ctx) }) + macro = ctx.policy_set.defs.fetch(name) + macro.evaluate(ctx) + ensure + ctx.vars.replace(vars) + end + end + + def self.from_kdl(node) + case node + in [/\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*\z/ => name, [KDL::Value => v], {}, rules] + split_name = name.split(".") + lhs = split_name.reduce(LVal::This.new) do |acc, part| + LVal::PropertyAccess.new(lhs: acc, name: part) + end + KeyValue.new(key: lhs, value: RVal.from_kdl(v)) + in [/\A([a-zA-Z_][a-zA-Z0-9_]*\.)*\$[a-zA-Z_][a-zA-Z0-9_]*\z/ => name, [KDL::Value => v], {}, rules] + split_name = name.split(".") + op = split_name.pop.delete_prefix!("$") + lhs = split_name.reduce(LVal::This.new) do |acc, part| + LVal::PropertyAccess.new(lhs: acc, name: part) + end + Operator.new(lhs:, op:, rhs: RVal.from_kdl(v)) + in [/\A([a-zA-Z_][a-zA-Z0-9_]*\.)*%[a-zA-Z_][a-zA-Z0-9_]*\z/ => name, [], props, rules] + split_name = name.split(".") + name = split_name.pop.delete_prefix!("%") + lhs = split_name.reduce(LVal::This.new) do |acc, part| + LVal::PropertyAccess.new(lhs: acc, name: part) + end + Scope.new(lhs:, rules: [ + Call.new(name:, props: props.transform_values { RVal.from_kdl(_1) }) + ]) + in [/\A%(.+)/, [], props, []] + Call.new(name: ::Regexp.last_match(1), props: props.transform_values { RVal.from_kdl(_1) }) + in [/\A\$.+\z/ => op, [lhs, rhs], {}, []] + Operator.new(lhs: RVal.from_kdl(lhs), op: op.delete_prefix("$"), rhs: RVal.from_kdl(rhs)) + in [name, [], {}, rules] unless rules.empty? + lhs = name.split(".").reduce(LVal::This.new) do |acc, part| + LVal::PropertyAccess.new(lhs: acc, name: part) + end + Scope.new(lhs:, rules: rules.map { Rule.from_kdl(_1) }) + end + end + + def deconstruct_in_ctx(ctx) + { + **to_h.transform_values { |value| value.respond_to?(:evaluate) ? value.evaluate(ctx) : value } + } + end + + class Operator < Rule + prop :op, String + prop :lhs, _Union(LVal, RVal) + prop :rhs, RVal + + def evaluate(ctx) + l = lhs.evaluate(ctx) + r = rhs.evaluate(ctx) + result = + case op + when "gt" + l > r + when "gte" + l >= r + when "lt" + l < r + when "lte" + l <= r + when "plus" + l + r + when "sub" + l - r + else + raise "Unknown operator: #{op}" + end + return result if result + + ctx.add_failure(self) + rescue StandardError => e + raise e, "#{inspect} #{e.message}" + end + + def to_s + if lhs.is_a?(LVal) + "#{lhs}.#{op} #{rhs}" + else + "#{op} #{lhs} #{rhs}" + end + end + end + + class SetVariable < Rule + prop :name, String + prop :value, Rule + + def evaluate(ctx) + ctx.vars[name] = value.evaluate(ctx) + end + end +end + +class Policy < Literal::Struct + include ASTNode + prop :type, String + prop :name, String + + prop :rules, _Array(Rule) + + def self.from_kdl(node) + node => [type, [KDL::Value::String[name, _, _]], {}, rules] + new(type:, name:, rules: rules.map { Rule.from_kdl(_1) }) + end + + def evaluate(ctx) + return unless type === ctx.this["type"] && name === ctx.this["name"] + + rules.each_with_object(ctx.inside_policy(self)) do |rule, c| + rule.evaluate(c) + end + end +end + +class Def < Literal::Struct + include ASTNode + + prop :name, String + prop :props, _Hash(String, _Any) + prop :rules, _Array(Rule) + + def self.from_kdl(node) + node => ["def", [KDL::Value::String[name, nil, nil]], props, rules] + props = props.transform_values do |type| + case type + in KDL::Value::String[type, nil, nil] + type + end + end + rules = rules.map do |rule| + case rule + in ["$var", [KDL::Value::String[/\A([a-zA-Z][a-zA-Z0-9_]*)\z/, nil, nil]], {}, [rhs]] + Rule::SetVariable.new(name: ::Regexp.last_match(1), value: Rule.from_kdl(rhs)) + else + Rule.from_kdl(rule) + end + end + new(name:, props:, rules:) + end + + def evaluate(ctx) + props.each do |name, type| + case type + when "integer" + unless ctx.vars[name].is_a?(Integer) + raise TypeError, "Expected #{name} to be an integer, got #{ctx.vars[name].class}" + end + when "integer?" + if ctx.vars.key?(name) && !ctx.vars[name].is_a?(Integer) + raise TypeError, "Expected #{name} to be an integer or nil, got #{ctx.vars[name].class}" + end + else + raise TypeError, "Unknown type: #{type.inspect}" + end + end + + rules.each do |rule| + rule.evaluate(ctx) + end + end +end + +class PolicySet < Literal::Struct + include ASTNode + prop :description, _String? + prop :defs, _Hash(String, _Any) + prop :policies, _Array(Policy) + + def self.from_kdl(doc) + description = nil + defs = {} + policies = [] + + doc.nodes.each do |node| + case node + in ["description", [KDL::Value::String[description, _, _]], {}, []] + # noop + in ["def", [KDL::Value::String[name, _, _]], _, _] + defs[name] = Def.from_kdl(node) + else + policies << Policy.from_kdl(node) + end + end + + new(description:, defs:, policies:) + end + + def evaluate(subject) + ctx = Context.new(policy_set: self, this: subject, vars: {}) + policies.each do |policy| + policy.evaluate(ctx) + end + ctx.result + end + + class Context < Literal::Struct + prop :policy_set, PolicySet + prop :this, _JSONData + prop :vars, _Hash(String, _Any) + + prop :matching_policies, _Hash(Policy, _Hash(String, _Any)), default: -> { {} } + + def result + failures = matching_policies.reject { |_, z| z.empty? } + [matching_policies.any? && failures.empty?, failures] + end + + def inside_policy(policy) + matching_policies[policy] = {} + self + end + + def add_failure(rule) + matching_policies.to_a.last.last[rule.to_s] = rule.deconstruct_in_ctx(self) + end + end +end + +class KDLParser + def self.parse(input) + parse_document(Input.new(input)) + end + + class Pos + def initialize(str, start, finish) + @str = str + @start = start + @finish = finish + end + end + + class Input + attr_accessor :pos + + def initialize(str) + @str = str + @pos = 0 + @length = str.length + end + + def eof? + @pos >= @length + end + + def skip_whitespace + @pos += 1 while @pos < @length && @str[@pos].match?(/\s/) + end + + def eat_identifer + start = @pos + @pos += 1 while @pos < @length && @str[@pos].match?(/\w/) + @str[start...@pos] + end + end + + def initialize(input) + @input = input + end + + class << self + def parse_document(input) + description = nil + defs = {} + policies = [] + + loop do + break if input.eof? + + input.skip_whitespace + + if desc = parse_description(input) + description = desc + elsif _def = parse_def(input) + defs[_def.name] = _def + else + policies << parse_policy(input) + end + end + + PolicySet.new(description:, defs:, policies:) + end + + def parse_description(input) + pos = input.pos + if input.eat_identifer == "description" + parse_string(input) + else + ( + input.pos = pos + nil + ) + end + end + + def parse_def(input) + input.pos + return unless input.skip_whitespace + return unless input.skip_whitespace == "{" + + parse_hash(input) + end + + def parse_policy(input) + input.pos + return unless input.eat_identifer + return unless input.skip_whitespace == "{" + + parse_hash(input) + end + end +end + +class T < Test::Unit::TestCase + include Paramesan + + param_test [ + [%(description "foo"), ["PolicySet", "foo", [], []]], + [%(rubygem "bundler" {}), ["PolicySet", nil, [], [["Policy", "rubygem", "bundler", []]]]], + [%(rubygem "bundler" { version.major.$gt "7" }), + ["PolicySet", nil, [], [ + ["Policy", "rubygem", "bundler", [ + ["Rule::Operator", + "gt", + ["LVal::PropertyAccess", ["LVal::PropertyAccess", ["LVal::This"], "version"], "major"], + ["RVal::Literal", "7"]] + ]] + ]]] + ] do |input, expected| + doc = KDL.parse_document(input) + policy_set = PolicySet.from_kdl(doc) + assert_equal(expected, policy_set.to_ast) + end + + File.read("policy-tests.kdl").then do |content| + doc = KDL.parse_document(content) + doc.nodes.each do |node| + node => ["test", [KDL::Value::String[name, _, _]], {}, body] + test name do + policy_set = document = nil + + body.each do |child| + case child + in ["document", [], {}, nodes] + document = KDL::Document.new(nodes) + policy_set = PolicySet.from_kdl(document) + in ["ast", [KDL::Value::String[ast, _, _]], {}, _] + assert_equal(eval(ast), policy_set.to_ast) + in ["example", [], {}, body] + subject = {} + match = nil + determining_rules = [] + + body.each do |rule| + case rule + in ["subject", [KDL::Value::String[json, _, nil]], {}, []] + subject = JSON.parse(json) + in ["match", [KDL::Value::Boolean[match, _, _]], {}, determining_rules] + # noop + end + end + + actual, = assert_nothing_raised { policy_set.evaluate(subject) } + + require "pp" + assert_equal(match, actual, { policy_set: policy_set.to_ast, subject:, determining_rules: }.pretty_inspect) + # assert_equal(determining_rules, failures.transform_values(&:to_s).to_a, + # { policy_set: policy_set, subject:, match: }.pretty_inspect) + end + end + + assert_equal(policy_set.to_ast, KDLParser.parse(document.to_s)) + end + end + end +end diff --git a/test/.gitignore b/test/.gitignore index e67cc72..66c0e22 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -1,3 +1,4 @@ /tuf-conformance/ /sigstore-conformance/ /conformance_invocations +/cedar-integration-tests/ diff --git a/test/sigstore/cedar_integration_test.rb b/test/sigstore/cedar_integration_test.rb new file mode 100644 index 0000000..a5780b9 --- /dev/null +++ b/test/sigstore/cedar_integration_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "test_helper" +require "sigstore/cedar" + +class Sigstore::CedarIntegrationTest < Test::Unit::TestCase + class IntegrationJSON < Data.define(:policies, :entities, :schema, :should_validate, :requests) + def self.from_json(json) + new( + policies: json.fetch("policies"), + entities: json.fetch("entities"), + schema: json.fetch("schema"), + should_validate: json.fetch("shouldValidate"), + requests: json.fetch("requests").map { |r| Request.from_json(r) } + ) + end + end + + class Request < Data.define(:description, :principal, :action, :resource, :context, :validateRequest, :decision, + :reason, :errors) + def self.from_json(json) + new( + description: json.fetch("description"), + principal: json.fetch("principal"), + action: json.fetch("action"), + resource: json.fetch("resource"), + context: json.fetch("context"), + validateRequest: json.fetch("validateRequest", nil), + decision: json.fetch("decision"), + reason: json.fetch("reason"), + errors: json.fetch("errors") + ) + end + + def authorization_request + Sigstore::Cedar::AuthorizationRequest.from_json({ + "principal" => principal, + "action" => action, + "resource" => resource, + "context" => context + }) + end + end + + def do_tests(policies, entities, requests) + policy_set = nil + assert_nothing_raised(policies) do + policy_set = Sigstore::Cedar::PolicySet.parse(policies) + end + entities = Sigstore::Cedar::Authorizer::Entities.new(JSON.parse(entities).map do |e| + Sigstore::Cedar::Entity.from_json(e) + end) + + authorizer = Sigstore::Cedar::Authorizer.new(policy_set:, entities:) + + requests.each do |req| + actual = nil + assert_nothing_raised(policies) do + actual = authorizer.authorize(req.authorization_request) + end + determining_policies = req.reason.map do |r| + if r =~ /\Apolicy(\d+)\z/ + policy_set.static_policies[::Regexp.last_match(1).to_i] + else + r + end + end + error_conditions = req.errors.to_h do |e| + if e =~ /\Apolicy(\d+)\z/ + policy = policy_set.static_policies[::Regexp.last_match(1).to_i] + [policy, actual.error_conditions[policy]] + else + [e, nil] + end + end + expected = Sigstore::Cedar::AuthorizationResponse.new( + verb: req.decision, determining_policies:, error_conditions: + ) + assert_equal(expected, actual, "#{req.description}\n\n#{policies}\n\n#{JSON.pretty_generate(req.to_h)}") + end + end + + base = File.expand_path("../cedar-integration-tests", __dir__) + Dir[File.join(base, "tests/**/*.json")].each do |file| + test "integration test #{file}" do + contents = JSON.parse(File.read(file)) + contents = IntegrationJSON.from_json(contents) + + do_tests( + File.read(File.join(base, contents.policies)), + File.read(File.join(base, contents.entities)), + contents.requests + ) + end + end + + require "rubygems/package" + fs = {} + + File.open(File.expand_path("../cedar-integration-tests/corpus-tests.tar.gz", __dir__), "rb") do |f| + Zlib::GzipReader.wrap(f) do |gz| + Gem::Package::TarReader.new(gz) do |tar| + tar.each do |entry| + fs[entry.full_name] = entry.read.force_encoding(Encoding::UTF_8) + end + end + end + end + + fs.each do |name, content| + next unless name.match?(%r{/[0-9a-f]{40}\.json\z}) + + test "corpus test #{name}" do + contents = JSON.parse(content) + contents = IntegrationJSON.from_json(contents) + + do_tests( + fs.fetch(contents.policies), + fs.fetch(contents.entities), + contents.requests + ) + end + end + + test "condition" do + str = 'when { resource.name == "foo" }' + parser = Sigstore::Cedar::PolicyParser.new(str) + res = parser.parse_condition + + assert_predicate parser.instance_variable_get(:@scanner).rest, :empty? + refute_nil res + + str = "when { !context.authenticated }" + parser = Sigstore::Cedar::PolicyParser.new(str) + res = parser.parse_condition + + assert_predicate parser.instance_variable_get(:@scanner).rest, :empty? + refute_nil res + assert_equal "when{!context.authenticated}\n", res.pretty_inspect + end + + test "uid hash" do + uid1 = Sigstore::Cedar::Entity::UID.new(type: "foo", id: "bar") + uid2 = Sigstore::Cedar::Entity::UID.new(type: "foo", id: "baz") + uid3 = Sigstore::Cedar::Entity::UID.new(type: "foo", id: "bar") + + h = { + uid1 => 1, + uid2 => 2 + } + + assert_equal 1, h[uid1] + assert_equal 2, h[uid2] + assert_equal 1, h[uid3] + end + + test "string like" do + [ + ["foo", "foo", true], + [" ", " ", true], + [" ", " ", false], + ["foo", "f*", true], + [" ", "* *", true], + ["\0", "\0", true], + ["\0", "*", true], + ["\u0000\u0000 ", "\u0000* ** *", true] + ].each do |lhs, rhs, expected| + like = Sigstore::Cedar::Policy::JsonExpr::BinaryOp.new( + op: Sigstore::Cedar::Policy::JsonExpr::BinaryOp::Op::Like, + lhs: Sigstore::Cedar::Policy::JsonExpr::Value.new(value: lhs), + rhs: Sigstore::Cedar::Policy::JsonExpr::Value.new(value: rhs) + ) + + result = like.evaluate(nil, nil) + assert_equal expected, result, "#{like.pretty_inspect} should be #{expected}" + end + end +end diff --git a/thunks.rb b/thunks.rb new file mode 100644 index 0000000..00d251d --- /dev/null +++ b/thunks.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "literal" +require "test/unit/autorunner" +require "kdl" + +class Sentence < Literal::Struct + prop :text, _String +end + +class Document < Literal::Struct + prop :title, _String + prop :author, _String + prop :paragraphs, _Array(Sentence) +end + +class SentenceTransformer + def self.transform(hash, _context) + text = nil + + hash.each do |key, value| + case key + in "text" + raise "Duplicate key: #{key}" if text + raise "Bad type: #{value}" unless value.is_a?(String) + + text = value + else + raise "Unknown key: #{key}" + end + end + + Sentence.new(text: text) + end +end + +class SentenceKDLTransformer + def self.transform(node, _context) + raise "Single value expected" unless node.arguments.size == 1 + raise "No props expected" unless node.properties.empty? + + case node.name + in "text" + raise "Single value expected" unless node.arguments.size == 1 + raise "No props expected" unless node.properties.empty? + unless (value = node.arguments.first).is_a?(KDL::Value::String) + raise "Bad type: #{value}" + end + + Sentence.new(text: value.value) + end + end +end + +class DocumentTransformer + def self.transform(hash, context) + title = author = nil + paragraphs = nil + + hash.each do |key, value| + case key + in "title" + # only needed if source can have duplicate keys (e.g. KDL or XML, and not JSON) + raise "Duplicate key: #{key}" if title + raise "Bad type: #{value}" unless value.is_a?(String) + + title = value + in "author" + raise "Duplicate key: #{key}" if author + raise "Bad type: #{value}" unless value.is_a?(String) + + author = value + in "paragraphs" # NOTE: would work differently if the repeated values came in 1-by-1 + raise "Duplicate key: #{key}" if paragraphs + raise "Bad type: #{value}" unless value.is_a?(Array) + + paragraphs = value.map do |v| + raise "Bad type: #{v}" unless v.is_a?(Hash) + + SentenceTransformer.transform(v, context) + end + else + raise "Unknown key: #{key}" + end + end + + Document.new(title: title, author: author, paragraphs: paragraphs) + end +end + +class DocumentKDLTransformer + def self.transform(doc, context) + title = author = nil + paragraphs = [] + + doc.nodes.each do |node| + case node.name + in "title" + # only needed if source can have duplicate keys (e.g. KDL or XML, and not JSON) + raise "Duplicate key: #{key}" if title + raise "Single value expected" unless node.arguments.size == 1 + raise "No props expected" unless node.properties.empty? + unless (value = node.arguments.first).is_a?(KDL::Value::String) + raise "Bad type: #{value}" + end + + title = value.value + in "author" + raise "Duplicate key: #{key}" if author + raise "Single value expected" unless node.arguments.size == 1 + raise "No props expected" unless node.properties.empty? + unless (value = node.arguments.first).is_a?(KDL::Value::String) + raise "Bad type: #{value}" + end + + author = value.value + in "paragraph" # NOTE: would work differently if the repeated values came in 1-by-1 + raise "No arguments expected" unless node.arguments.empty? + raise "No props expected" unless node.properties.empty? + + node.children.each do |child| + paragraphs << SentenceKDLTransformer.transform(child, context) + end + else + raise "Unknown key: #{key}" + end + end + + Document.new(title: title, author: author, paragraphs: paragraphs) + end +end + +class T < Test::Unit::TestCase + def test1 + source = { + "title" => "The Old", + "author" => "John Doe", + "paragraphs" => [ + { "text" => "Once upon a time" }, + { "text" => "There" } + ] + } + + DocumentTransformer.transform(source, nil) => Document[ + title: "The Old", + author: "John Doe", + paragraphs: [ + Sentence[text: "Once upon a time"], + Sentence[text: "There"] + ] + ] + end + + def test2 + source = KDL.parse_document <<~KDL + title "The Old" + author "John Doe" + paragraph { + text "Once upon a time" + } + paragraph { + text "There" + } + KDL + + DocumentKDLTransformer.transform(source, nil) => Document[ + title: "The Old", + author: "John Doe", + paragraphs: [ + Sentence[text: "Once upon a time"], + Sentence[text: "There"] + ] + ] + end +end