Skip to content

Commit

Permalink
Add security event log (#4367)
Browse files Browse the repository at this point in the history
  • Loading branch information
segiddins committed Feb 9, 2024
1 parent a3467f8 commit fafb028
Show file tree
Hide file tree
Showing 139 changed files with 2,379 additions and 61 deletions.
33 changes: 33 additions & 0 deletions app/avo/fields/event_additional_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
class EventAdditionalField < Avo::Fields::BaseField
def nested_field
return unless @model
additional_type = @model.additional_type
if additional_type.nil?
return JsonViewerField.new(id, **@args)
.hydrate(model:, resource:, action:, view:, panel_name:, user:)
end

NestedField.new(id, **@args) do
additional_type.attribute_types.each do |attribute_name, type|
case type
when Types::GlobalId
field attribute_name.to_sym, as: :global_id, show_on: :index
when ActiveModel::Type::String
field attribute_name.to_sym, as: :text, show_on: :index
when ActiveModel::Type::Boolean
field attribute_name.to_sym, as: :boolean, show_on: :index
else
field attribute_name.to_sym, as: :json_viewer, hide_on: :index
end
end
end.hydrate(model:, resource:, action:, view:, panel_name:, user:)
end

methods = %i[fill_field value update_using to_permitted_param component_for_view visible get_fields]
methods.each do |method|
define_method(method, &lambda do |*args, **kwargs|
nf = nested_field
nf ? nf.send(method, *args, **kwargs) : super(*args, **kwargs)
end)
end
end
15 changes: 15 additions & 0 deletions app/avo/fields/global_id_field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class GlobalIdField < Avo::Fields::BelongsToField
include SemanticLogger::Loggable

delegate(*%i[values_for_type custom?], to: :@nil)

def value
super.find
rescue ActiveRecord::RecordNotFound
nil
end

def view_component_name = "BelongsToField"

def is_polymorphic? = true # rubocop:disable Naming/PredicateName
end
2 changes: 1 addition & 1 deletion app/avo/fields/nested_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class NestedField < Avo::Fields::BaseField

def initialize(name, stacked: true, **args, &block)
@items_holder = Avo::ItemsHolder.new
hide_on [:index]
hide_on :index
super(name, stacked:, **args, &nil)
instance_exec(&block) if block
end
Expand Down
28 changes: 28 additions & 0 deletions app/avo/resources/events_rubygem_event_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class EventsRubygemEventResource < Avo::BaseResource
self.title = :cache_key
self.includes = %i[rubygem ip_address geoip_info]
self.model_class = ::Events::RubygemEvent

field :id, as: :id, hide_on: :index
field :created_at, as: :date_time

field :trace_id, as: :text, format_using: proc {
if value.present?
link_to(
view == :index ? "🔗" : value,
"https://app.datadoghq.com/logs?query=#{{
:@traceid => value,
from_ts: (model.created_at - 12.hours).to_i * 1000,
to_ts: (model.created_at + 12.hours).to_i * 1000
}.to_query}",
{ target: :_blank, rel: :noopener }
)
end
}

field :tag, as: :text
field :rubygem, as: :belongs_to
field :ip_address, as: :belongs_to
field :geoip_info, as: :belongs_to
field :additional, as: :event_additional, show_on: :index
end
28 changes: 28 additions & 0 deletions app/avo/resources/events_user_event_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class EventsUserEventResource < Avo::BaseResource
self.title = :cache_key
self.includes = %i[user ip_address geoip_info]
self.model_class = ::Events::UserEvent

field :id, as: :id, hide_on: :index

field :created_at, as: :date_time
field :trace_id, as: :text, format_using: proc {
if value.present?
link_to(
view == :index ? "🔗" : value,
"https://app.datadoghq.com/logs?query=#{{
:@traceid => value,
from_ts: (model.created_at - 12.hours).to_i * 1000,
to_ts: (model.created_at + 12.hours).to_i * 1000
}.to_query}",
{ target: :_blank, rel: :noopener }
)
end
}

field :tag, as: :text
field :user, as: :belongs_to
field :ip_address, as: :belongs_to
field :geoip_info, as: :belongs_to
field :additional, as: :event_additional, show_on: :index
end
20 changes: 20 additions & 0 deletions app/avo/resources/ip_address_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class IpAddressResource < Avo::BaseResource
self.title = :ip_address
self.includes = []

self.hide_from_global_search = true
self.search_query = lambda {
scope.where("ip_address <<= inet ?", params[:q])
}

field :id, as: :id

field :ip_address, as: :text
field :hashed_ip_address, as: :textarea
field :geoip_info, as: :json_viewer

tabs style: :pills do
field :user_events, as: :has_many
field :rubygem_events, as: :has_many
end
end
1 change: 1 addition & 0 deletions app/avo/resources/user_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,6 @@ class DeletedFilter < ScopeBooleanFilter; end
field :webauthn_verification, as: :has_one

field :audits, as: :has_many
field :events, as: :has_many
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= index_field_wrapper **field_wrapper_args do %>
<%= @field.value %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class Avo::Fields::EventAdditionalField::IndexComponent < Avo::Fields::IndexComponent
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= index_field_wrapper **field_wrapper_args do %>
<% if @field.value.present? %>
<pre><%= content_tag :code, YAML.dump(JSON.load(@field.value).compact).sub(/\A---(\R| {})/, "") %></pre>
<% end %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class Avo::Fields::JsonViewerField::IndexComponent < Avo::Fields::IndexComponent
end
13 changes: 13 additions & 0 deletions app/components/avo/fields/nested_field/index_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<%= index_field_wrapper **field_wrapper_args do %>
<% if @field.value.present? %>
<% resource = field_wrapper_args.fetch(:resource) or raise "missing resource for #{self}" %>
<table><tbody>
<% field.get_fields.each do |f| %>
<tr class="!tw-py-0" >
<td><%= f.name %></td>
<%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, resource:, flush: true) %>
</tr>
<% end %>
</tbody></table>
<% end %>
<% end %>
4 changes: 4 additions & 0 deletions app/components/avo/fields/nested_field/index_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class Avo::Fields::NestedField::IndexComponent < Avo::Fields::IndexComponent
end
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<%= field_wrapper **field_wrapper_args, data: {} do %>
<div class="divide-y">
<% field.get_fields.each do |f| %>
<%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f) %>
<%= render f.hydrate(view:, model: field.value, resource:).component_for_view(view).new(field: f, resource:) %>
<% end %>
</div>
<% end %>
2 changes: 1 addition & 1 deletion app/controllers/api/v1/rubygems_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def show
def create
return render_api_key_forbidden unless @api_key.can_push_rubygem?

gemcutter = Pusher.new(@api_key, request.body, request.remote_ip)
gemcutter = Pusher.new(@api_key, request.body, request:)
enqueue_web_hook_jobs(gemcutter.version) if gemcutter.process
render plain: response_with_mfa_warning(gemcutter.message), status: gemcutter.code
rescue StandardError => e
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/avo/events_rubygem_events_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::EventsRubygemEventsController < Avo::ResourcesController
end
4 changes: 4 additions & 0 deletions app/controllers/avo/events_user_events_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::EventsUserEventsController < Avo::ResourcesController
end
4 changes: 4 additions & 0 deletions app/controllers/avo/ip_addresses_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This controller has been generated to enable Rails' resource routes.
# More information on https://docs.avohq.io/2.0/controllers.html
class Avo::IpAddressesController < Avo::ResourcesController
end
5 changes: 5 additions & 0 deletions app/controllers/profiles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ def adoptions
@ownership_requests = current_user.ownership_requests.includes(:rubygem)
end

def security_events
@security_events = current_user.events.order(id: :desc).page(params[:page]).per(50)
render Profiles::SecurityEventsView.new(security_events: @security_events)
end

private

def params_user
Expand Down
15 changes: 11 additions & 4 deletions app/controllers/rubygems_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
class RubygemsController < ApplicationController
include LatestVersion
before_action :set_reserved_gem, only: :show, if: :reserved?
before_action :find_rubygem, only: :show, unless: :reserved?
before_action :latest_version, only: :show, unless: :reserved?
before_action :find_versioned_links, only: :show, unless: :reserved?
before_action :set_reserved_gem, only: %i[show security_events], if: :reserved?
before_action :find_rubygem, only: %i[show security_events], unless: :reserved?
before_action :latest_version, only: %i[show], unless: :reserved?
before_action :find_versioned_links, only: %i[show], unless: :reserved?
before_action :set_page, only: :index
before_action :redirect_to_signin, unless: :signed_in?, only: %i[security_events]
before_action :render_forbidden, unless: :owner?, only: %i[security_events]

def index
respond_to do |format|
Expand Down Expand Up @@ -33,6 +35,11 @@ def show
end
end

def security_events
@security_events = @rubygem.events.order(id: :desc).page(params[:page]).per(50)
render Rubygems::SecurityEventsView.new(rubygem: @rubygem, security_events: @security_events)
end

private

def reserved?
Expand Down
12 changes: 7 additions & 5 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def create

render "multifactor_auths/prompt"
else
do_login
do_login(two_factor_label: nil, two_factor_method: nil, authentication_method: "password")
end
end

Expand All @@ -39,15 +39,15 @@ def webauthn_create

record_mfa_login_duration(mfa_type: "webauthn")

do_login
do_login(two_factor_label: user_webauthn_credential.nickname, two_factor_method: "webauthn", authentication_method: "password")
end

def webauthn_full_create
return login_failure(@webauthn_error) unless webauthn_credential_verified?

@user = user_webauthn_credential.user

do_login
do_login(two_factor_label: user_webauthn_credential.nickname, two_factor_method: nil, authentication_method: "webauthn")
end

def otp_create
Expand All @@ -56,7 +56,7 @@ def otp_create
if login_conditions_met?
record_mfa_login_duration(mfa_type: "otp")

do_login
do_login(two_factor_label: "OTP", two_factor_method: "otp", authentication_method: "password")
elsif !session_active?
login_failure(t("multifactor_auths.session_expired"))
else
Expand Down Expand Up @@ -106,10 +106,12 @@ def verify_password_params
params.require(:verify_password).permit(:password)
end

def do_login
def do_login(two_factor_label:, two_factor_method:, authentication_method:)
sign_in(@user) do |status|
if status.success?
StatsD.increment "login.success"
current_user.record_event!(Events::UserEvent::LOGIN_SUCCESS, request:,
two_factor_method:, two_factor_label:, authentication_method:)
set_login_flash
redirect_to(url_after_create)
else
Expand Down
5 changes: 5 additions & 0 deletions app/helpers/rubygems_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ def rubygem_adoptions_link(rubygem)
rubygem_adoptions_path(rubygem.slug), class: "gem__link t-list__item"
end

def rubygem_security_events_link(rubygem)
link_to "Security Events",
security_events_rubygem_path(rubygem.slug), class: "gem__link t-list__item"
end

def links_to_owners(rubygem)
rubygem.owners.sort_by(&:id).inject("") { |link, owner| link << link_to_user(owner) }.html_safe
end
Expand Down
16 changes: 16 additions & 0 deletions app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,20 @@ class ApplicationMailer < ActionMailer::Base
include SemanticLogger::Loggable

layout "mailer"

after_deliver :record_delivery

def record_delivery
message.to_addrs&.each do |address|
next unless (user = User.find_by_email(address))

user.record_event!(Events::UserEvent::EMAIL_SENT,
to: address,
from: message.from_addrs&.first,
subject: message.subject,
message_id: message.message_id,
action: action_name,
mailer: mailer_name)
end
end
end
20 changes: 19 additions & 1 deletion app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class ApiKey < ApplicationRecord
has_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :nullify

before_validation :set_owner_from_user
after_create :record_create_event
after_update :record_expire_event, if: :saved_change_to_expires_at?

validates :name, :hashed_key, presence: true
validate :exclusive_show_dashboard_scope, if: :can_show_dashboard?
Expand Down Expand Up @@ -100,7 +102,7 @@ def expired?
end

def expire!
touch(:expires_at)
update!(expires_at: Time.current)
end

private
Expand Down Expand Up @@ -134,4 +136,20 @@ def not_expired?
def set_owner_from_user
self.owner ||= user
end

def record_create_event
case owner
when User
user.record_event!(Events::UserEvent::API_KEY_CREATED,
name:, scopes: enabled_scopes, gem: rubygem&.name, mfa:, api_key_gid: to_gid)
end
end

def record_expire_event
case owner
when User
user.record_event!(Events::UserEvent::API_KEY_DELETED,
name:, api_key_gid: to_gid)
end
end
end
Loading

0 comments on commit fafb028

Please sign in to comment.