Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/avo/actions/repair_attestation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class Avo::Actions::RepairAttestation < Avo::Actions::ApplicationAction
self.name = "Repair attestation"
self.visible = lambda {
current_user.team_member?("rubygems-org") && view == :show
}
self.message = lambda {
if resource.record.repairable?
"This attestation has invalid data that needs repair. Proceed with repair?"
else
"This attestation appears valid. No repairs are expected."
end
}
self.confirm_button_label = "Repair attestation"

class ActionHandler < Avo::Actions::ActionHandler
def handle_record(attestation)
changes = attestation.repair!

if changes
succeed "Attestation repaired: #{changes.join(', ')}"
else
succeed "No repair was needed"
end
end
end
end
8 changes: 8 additions & 0 deletions app/avo/resources/attestation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ class Avo::Resources::Attestation < Avo::BaseResource
self.title = :id
self.includes = [:version]

def actions
action Avo::Actions::RepairAttestation
end

def fields
field :id, as: :id

field :valid, as: :boolean, only_on: %i[index show] do
record.valid_bundle?
end

field :version, as: :belongs_to
field :media_type, as: :text
field :body, as: :json_viewer
Expand Down
8 changes: 8 additions & 0 deletions app/models/attestation.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Attestation < ApplicationRecord
include AttestationBundleRepair

belongs_to :version

validates :body, :media_type, presence: true
Expand All @@ -10,6 +12,12 @@ def sigstore_bundle
)
end

def valid_bundle?
sigstore_bundle.present?
rescue Sigstore::Error
false
end

def display_data # rubocop:disable Metrics/MethodLength
bundle = sigstore_bundle
leaf_certificate = bundle.leaf_certificate
Expand Down
64 changes: 64 additions & 0 deletions app/models/concerns/attestation_bundle_repair.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module AttestationBundleRepair
extend ActiveSupport::Concern

def repairable?
verification = body["verificationMaterial"]
return false if verification.blank?

missing_kind_version?(verification) || double_encoded_certificate?(verification)
end

def repair!
verification = body["verificationMaterial"]
return false if verification.blank?

new_body = body.deep_dup
new_verification = new_body["verificationMaterial"]
changes = []

repair_kind_version!(new_verification, changes) if missing_kind_version?(verification)
repair_certificate!(new_verification, changes) if double_encoded_certificate?(verification)

return false if changes.empty?

update!(body: new_body)
changes
end

private

def missing_kind_version?(verification)
verification["tlogEntries"]&.any? { |entry| !entry.key?("kindVersion") }
end

def double_encoded_certificate?(verification)
return false unless (raw_bytes = verification.dig("certificate", "rawBytes"))

decoded = Base64.strict_decode64(raw_bytes)
decoded.start_with?("-----BEGIN CERTIFICATE-----")
rescue ArgumentError
false
end

def repair_kind_version!(verification, changes)
verification["tlogEntries"]&.each_with_index do |entry, idx|
next if entry.key?("kindVersion")
entry["kindVersion"] = { "kind" => "dsse", "version" => "0.0.1" }
changes << "Added missing kindVersion to tlogEntry #{idx}"
end
end

def repair_certificate!(verification, changes)
raw_bytes = verification.dig("certificate", "rawBytes")
return unless raw_bytes

decoded = Base64.strict_decode64(raw_bytes)
return unless decoded.start_with?("-----BEGIN CERTIFICATE-----")

cert = OpenSSL::X509::Certificate.new(decoded)
verification["certificate"]["rawBytes"] = Base64.strict_encode64(cert.to_der)
changes << "Converted double-encoded PEM certificate to DER"
rescue ArgumentError, OpenSSL::X509::CertificateError => e
Rails.logger.warn("Failed to repair certificate: #{e.message}")
end
end
64 changes: 64 additions & 0 deletions test/models/attestation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,68 @@ class AttestationTest < ActiveSupport::TestCase
should belong_to(:version)
should validate_presence_of(:media_type)
should validate_presence_of(:body)

context "#repairable?" do
setup do
@version = create(:version)
end

should "be repairable when verificationMaterial is missing" do
attestation = Attestation.new(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {}
)

refute_predicate attestation, :repairable?
end

should "be repairable when kindVersion is missing from tlog entry" do
attestation = Attestation.new(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "logIndex" => 123 }],
"certificate" => { "rawBytes" => Base64.strict_encode64("DER data") }
}
}
)

assert_predicate attestation, :repairable?
end

should "be repairable when certificate is double-encoded PEM" do
pem_cert = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKHBfpN...\n-----END CERTIFICATE-----\n"
double_encoded = Base64.strict_encode64(pem_cert)

attestation = Attestation.new(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "kindVersion" => { "kind" => "dsse", "version" => "0.0.1" } }],
"certificate" => { "rawBytes" => double_encoded }
}
}
)

assert_predicate attestation, :repairable?
end

should "be not repairable when bundle has no known issues" do
attestation = Attestation.new(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "kindVersion" => { "kind" => "dsse", "version" => "0.0.1" } }],
"certificate" => { "rawBytes" => Base64.strict_encode64("DER data") }
}
}
)

refute_predicate attestation, :repairable?
end
end
end
177 changes: 177 additions & 0 deletions test/unit/avo/actions/repair_attestation_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
require "test_helper"

class RepairAttestationTest < ActiveSupport::TestCase
make_my_diffs_pretty!

setup do
@view_context = mock
@avo = mock
@view_context.stubs(:avo).returns(@avo)
@avo.stubs(:resources_audit_path).returns("resources_audit_path")
Avo::Current.stubs(:view_context).returns(@view_context)
@admin = create(:admin_github_user, :is_admin)
@version = create(:version)
end

test "repairs attestation with missing kindVersion" do
attestation = Attestation.create!(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "logIndex" => 123 }],
"certificate" => { "rawBytes" => Base64.strict_encode64("DER data") }
}
}
)

assert_predicate attestation, :repairable?

action = Avo::Actions::RepairAttestation.new
action.handle(
fields: { comment: "Repairing invalid attestation" },
current_user: @admin,
resource: nil,
records: [attestation],
query: nil
)

attestation.reload

refute_predicate attestation, :repairable?
assert_equal(
{ "kind" => "dsse", "version" => "0.0.1" },
attestation.body.dig("verificationMaterial", "tlogEntries", 0, "kindVersion")
)
end

test "repairs attestation with double-encoded PEM certificate" do
key = OpenSSL::PKey::RSA.new(2048)
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = OpenSSL::X509::Name.parse("/CN=Test")
cert.issuer = cert.subject
cert.public_key = key.public_key
cert.not_before = Time.current
cert.not_after = Time.current + 3600
cert.sign(key, OpenSSL::Digest.new("SHA256"))

pem_cert = cert.to_pem
double_encoded = Base64.strict_encode64(pem_cert)

attestation = Attestation.create!(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "kindVersion" => { "kind" => "dsse", "version" => "0.0.1" } }],
"certificate" => { "rawBytes" => double_encoded }
}
}
)

assert_predicate attestation, :repairable?

action = Avo::Actions::RepairAttestation.new
action.handle(
fields: { comment: "Repairing invalid attestation" },
current_user: @admin,
resource: nil,
records: [attestation],
query: nil
)

attestation.reload

refute_predicate attestation, :repairable?

raw_bytes = attestation.body.dig("verificationMaterial", "certificate", "rawBytes")
decoded = Base64.strict_decode64(raw_bytes)

refute decoded.start_with?("-----BEGIN CERTIFICATE-----")
end

test "does nothing for attestation without known issues" do
attestation = Attestation.create!(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "kindVersion" => { "kind" => "dsse", "version" => "0.0.1" } }],
"certificate" => { "rawBytes" => Base64.strict_encode64("DER data") }
}
}
)

refute_predicate attestation, :repairable?
original_body = attestation.body.deep_dup

action = Avo::Actions::RepairAttestation.new
action.handle(
fields: { comment: "Attempting repair on valid attestation" },
current_user: @admin,
resource: nil,
records: [attestation],
query: nil
)

attestation.reload

assert_equal original_body, attestation.body
end

test "repairs both missing kindVersion and double-encoded certificate simultaneously" do
key = OpenSSL::PKey::RSA.new(2048)
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = OpenSSL::X509::Name.parse("/CN=Test")
cert.issuer = cert.subject
cert.public_key = key.public_key
cert.not_before = Time.current
cert.not_after = Time.current + 3600
cert.sign(key, OpenSSL::Digest.new("SHA256"))

pem_cert = cert.to_pem
double_encoded = Base64.strict_encode64(pem_cert)

attestation = Attestation.create!(
version: @version,
media_type: "application/vnd.dev.sigstore.bundle.v0.3+json",
body: {
"verificationMaterial" => {
"tlogEntries" => [{ "logIndex" => 123 }],
"certificate" => { "rawBytes" => double_encoded }
}
}
)

assert_predicate attestation, :repairable?

action = Avo::Actions::RepairAttestation.new
action.handle(
fields: { comment: "Repairing attestation with both issues" },
current_user: @admin,
resource: nil,
records: [attestation],
query: nil
)

attestation.reload

refute_predicate attestation, :repairable?

# Verify kindVersion was added
assert_equal(
{ "kind" => "dsse", "version" => "0.0.1" },
attestation.body.dig("verificationMaterial", "tlogEntries", 0, "kindVersion")
)

# Verify certificate was converted to DER
raw_bytes = attestation.body.dig("verificationMaterial", "certificate", "rawBytes")
decoded = Base64.strict_decode64(raw_bytes)

refute decoded.start_with?("-----BEGIN CERTIFICATE-----")
end
end
Loading