Save virus scan status to blob metadata (#3816)

Migration du statut de l'analyse anti-virus vers les objets `ActiveStorage` (plutôt que d'être liés à chaque modèle).
This commit is contained in:
Pierre de La Morinerie 2019-05-02 16:52:56 +02:00 committed by GitHub
commit 7b835f01fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 181 additions and 131 deletions

View file

@ -1,20 +0,0 @@
class AntiVirusJob < ApplicationJob
include ActiveStorage::Downloading
attr_reader :blob
def perform(virus_scan)
@blob = ActiveStorage::Blob.find_by(key: virus_scan.blob_key)
if @blob.present?
download_blob_to_tempfile do |file|
if ClamavService.safe_file?(file.path)
status = VirusScan.statuses.fetch(:safe)
else
status = VirusScan.statuses.fetch(:infected)
end
virus_scan.update(scanned_at: Time.zone.now, status: status)
end
end
end
end

View file

@ -0,0 +1,10 @@
class VirusScannerJob < ApplicationJob
def perform(blob)
metadata = extract_metadata_via_virus_scanner(blob)
blob.update!(metadata: blob.metadata.merge(metadata))
end
def extract_metadata_via_virus_scanner(blob)
ActiveStorage::VirusScanner.new(blob).metadata
end
end

View file

@ -0,0 +1,46 @@
class ActiveStorage::VirusScanner
include ActiveStorage::Downloading
def initialize(blob)
@blob = blob
end
attr_reader :blob
PENDING = 'pending'
INFECTED = 'infected'
SAFE = 'safe'
def pending?
blob.metadata[:virus_scan_result] == PENDING
end
def infected?
blob.metadata[:virus_scan_result] == INFECTED
end
def safe?
blob.metadata[:virus_scan_result] == SAFE
end
def analyzed?
blob.metadata[:virus_scan_result].present?
end
def analyze_later
if !analyzed?
blob.update!(metadata: blob.metadata.merge(virus_scan_result: PENDING))
VirusScannerJob.perform_later(blob)
end
end
def metadata
download_blob_to_tempfile do |file|
if ClamavService.safe_file?(file.path)
{ virus_scan_result: SAFE, scanned_at: Time.zone.now }
else
{ virus_scan_result: INFECTED, scanned_at: Time.zone.now }
end
end
end
end

View file

@ -1,6 +1,5 @@
class Avis < ApplicationRecord
include EmailSanitizableConcern
include VirusScanConcern
belongs_to :dossier, touch: true
belongs_to :gestionnaire
@ -22,9 +21,6 @@ class Avis < ApplicationRecord
scope :by_latest, -> { order(updated_at: :desc) }
scope :updated_since?, -> (date) { where('avis.updated_at > ?', date) }
after_commit :create_avis_virus_scan
after_initialize { add_virus_scan_on(self.piece_justificative_file) }
# The form allows subtmitting avis requests to several emails at once,
# hence this virtual attribute.
attr_accessor :emails
@ -41,6 +37,27 @@ class Avis < ApplicationRecord
Avis.find_by(id: avis_id)&.email == email
end
# FIXME remove this after migrating virus_scan to blob metadata
def virus_scan
VirusScan.find_by(blob_key: piece_justificative_file.blob.key)
end
def virus_scan_safe?
virus_scan&.safe? || piece_justificative_file.virus_scanner.safe?
end
def virus_scan_infected?
virus_scan&.infected? || piece_justificative_file.virus_scanner.infected?
end
def virus_scan_pending?
virus_scan&.pending? || piece_justificative_file.virus_scanner.pending?
end
def virus_scan_no_scan?
virus_scan.blank? && !piece_justificative_file.virus_scanner.analyzed?
end
private
def notify_gestionnaire
@ -54,8 +71,4 @@ class Avis < ApplicationRecord
self.email = nil
end
end
def create_avis_virus_scan
create_virus_scan(self.piece_justificative_file)
end
end

View file

@ -1,6 +1,4 @@
class Champs::PieceJustificativeChamp < Champ
after_commit :create_virus_scan
PIECE_JUSTIFICATIVE_FILE_MAX_SIZE = 200.megabytes
PIECE_JUSTIFICATIVE_FILE_ACCEPTED_FORMATS = [
@ -48,20 +46,26 @@ class Champs::PieceJustificativeChamp < Champ
errors
end
# FIXME remove this after migrating virus_scan to blob metadata
def virus_scan_safe?
virus_scan&.safe? || piece_justificative_file.virus_scanner.safe?
end
def virus_scan_infected?
virus_scan&.infected? || piece_justificative_file.virus_scanner.infected?
end
def virus_scan_pending?
virus_scan&.pending? || piece_justificative_file.virus_scanner.pending?
end
def virus_scan_no_scan?
virus_scan.blank? && !piece_justificative_file.virus_scanner.analyzed?
end
def for_api
if piece_justificative_file.attached? && (virus_scan&.safe? || virus_scan&.pending?)
if piece_justificative_file.attached? && (virus_scan_safe? || virus_scan_pending?)
Rails.application.routes.url_helpers.url_for(piece_justificative_file)
end
end
private
def create_virus_scan
if self.piece_justificative_file&.attachment&.blob.present?
VirusScan.where(champ: self).where.not(blob_key: self.piece_justificative_file.blob.key).delete_all
VirusScan.find_or_create_by!(champ: self, blob_key: self.piece_justificative_file.blob.key) do |virus_scan|
virus_scan.status = VirusScan.statuses.fetch(:pending)
end
end
end
end

View file

@ -1,21 +0,0 @@
module VirusScanConcern
extend ActiveSupport::Concern
attr_reader :attachment_attribute
def add_virus_scan_on(piece_justificative)
@attachment_attribute = piece_justificative
end
def virus_scan
VirusScan.find_by(blob_key: self.attachment_attribute.blob.key)
end
def create_virus_scan(piece_justificative)
if piece_justificative&.attachment&.blob.present?
VirusScan.find_or_create_by!(blob_key: piece_justificative.blob.key) do |virus_scan|
virus_scan.status = VirusScan.statuses.fetch(:pending)
end
end
end
end

View file

@ -6,12 +6,4 @@ class VirusScan < ApplicationRecord
safe: 'safe',
infected: 'infected'
}
validates :champ_id, uniqueness: { scope: :blob_key }
after_create :perform_scan
def perform_scan
AntiVirusJob.perform_later(self)
end
end

View file

@ -1,21 +0,0 @@
- pj = champ.piece_justificative_file
.pj-link
- if champ.virus_scan.blank? || champ.virus_scan.safe?
= link_to url_for(pj), target: '_blank', rel: 'noopener', title: "Télécharger la pièce jointe" do
%span.icon.attachment
= pj.filename.to_s
- if champ.virus_scan.blank?
(ce fichier na pas été analysé par notre antivirus, téléchargez-le avec précaution)
- else
= pj.filename.to_s
- if champ.virus_scan.pending?
(analyse antivirus en cours
= link_to "rafraichir", request.path
)
- elsif champ.virus_scan.infected?
- if user_can_upload
(virus détecté, merci denvoyer un autre fichier)
- else
(virus détecté, le téléchargement de ce fichier est bloqué)

View file

@ -1,5 +1,5 @@
- pj = champ.piece_justificative_file
- if pj.attached?
= render partial: "shared/champs/piece_justificative/pj_link", locals: { champ: champ, user_can_upload: false }
= render partial: "shared/piece_jointe/pj_link", locals: { object: champ, pj: pj, user_can_upload: false }
- else
Pièce justificative non fournie

View file

@ -9,7 +9,7 @@
- if pj.attached?
.piece-justificative-actions{ id: "piece_justificative_#{champ.id}" }
.piece-justificative-action
= render partial: "shared/champs/piece_justificative/pj_link", locals: { champ: champ, user_can_upload: true }
= render partial: "shared/piece_jointe/pj_link", locals: { object: champ, pj: champ.piece_justificative_file, user_can_upload: true }
.piece-justificative-action
- if champ.private?
= link_to 'Supprimer', gestionnaire_champ_purge_champ_piece_justificative_path(procedure_id: champ.dossier.procedure_id, dossier_id: champ.dossier_id, champ_id: champ.id), remote: true, method: :delete, class: 'button small danger'

View file

@ -1,19 +1,19 @@
- if pj.attached?
.pj-link
- if object.virus_scan.blank? || object.virus_scan.safe?
= link_to url_for(pj), target: '_blank', title: "Télécharger la pièce jointe" do
- if object.virus_scan_safe? || object.virus_scan_no_scan?
= link_to url_for(pj), target: '_blank', rel: 'noopener', title: "Télécharger la pièce jointe" do
%span.icon.attachment
= pj.filename.to_s
- if object.virus_scan.blank?
- if object.virus_scan_no_scan?
(ce fichier na pas été analysé par notre antivirus, téléchargez-le avec précaution)
- else
= pj.filename.to_s
- if object.virus_scan.pending?
- if object.virus_scan_pending?
(analyse antivirus en cours
= link_to "rafraichir", request.path
)
- elsif object.virus_scan.infected?
- elsif object.virus_scan_infected?
- if user_can_upload
(virus détecté, merci denvoyer un autre fichier)
- else

View file

@ -1 +1,29 @@
ActiveStorage::Service.url_expires_in = 1.hour
# We want to run the virus scan on every ActiveStorage attachment,
# regardless of its type (user-uploaded document, instructeur-uploaded attestation, form template, etc.)
#
# To do this, the best place to work on is the ActiveStorage::Attachment
# objects themselves.
#
# We have to monkey patch ActiveStorage in order to always run an analyzer.
# The way analyzable blob interface work is by running the first accepted analyzer.
# This is not what we want for the virus scan. Using analyzer interface is still beneficial
# as it takes care of downloading the blob.
ActiveStorage::Attachment.class_eval do
after_create_commit :virus_scan
private
def virus_scan
ActiveStorage::VirusScanner.new(blob).analyze_later
end
end
ActiveStorage::Attached::One.class_eval do
def virus_scanner
if attached?
ActiveStorage::VirusScanner.new(attachment.blob)
end
end
end

View file

@ -0,0 +1,22 @@
namespace :after_party do
desc 'Deployment task: migrate_virus_scans'
task migrate_virus_scans: :environment do
puts "Running deploy task 'migrate_virus_scans'"
virus_scans = VirusScan.all
progress = ProgressReport.new(virus_scans.count)
virus_scans.find_each do |virus_scan|
blob = ActiveStorage::Blob.find_by(key: virus_scan.blob_key)
if blob
metadata = { virus_scan_result: virus_scan.status, scanned_at: virus_scan.scanned_at }
blob.update_column(:metadata, blob.metadata.merge(metadata))
end
progress.inc
end
progress.finish
# Update task as completed. If you remove the line below, the task will
# run with every deploy (or every time you call after_party:run).
AfterParty::TaskRecord.create version: '20190425102459'
end
end

View file

@ -167,8 +167,8 @@ feature 'The user' do
expect(page).to have_text('analyse antivirus en cours')
# Mark file as scanned and clean
virus_scan = VirusScan.last
virus_scan.update(scanned_at: Time.zone.now, status: VirusScan.statuses.fetch(:safe))
attachment = ActiveStorage::Attachment.last
attachment.blob.update(metadata: attachment.blob.metadata.merge(scanned_at: Time.zone.now, virus_scan_result: ActiveStorage::VirusScanner::SAFE))
within '.piece-justificative' do
click_on 'rafraichir'
end

View file

@ -1,12 +1,11 @@
RSpec.describe AntiVirusJob, type: :job do
RSpec.describe VirusScannerJob, type: :job do
let(:champ) do
champ = create(:champ, :piece_justificative)
champ.piece_justificative_file.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
champ
end
let(:virus_scan) { create(:virus_scan, status: VirusScan.statuses.fetch(:pending), champ: champ, blob_key: champ.piece_justificative_file.blob.key) }
subject { AntiVirusJob.new.perform(virus_scan) }
subject { VirusScannerJob.new.perform(champ.piece_justificative_file.blob) }
context "when no virus is found" do
let(:virus_found?) { true }
@ -16,7 +15,7 @@ RSpec.describe AntiVirusJob, type: :job do
subject
end
it { expect(virus_scan.reload.status).to eq(VirusScan.statuses.fetch(:safe)) }
it { expect(champ.piece_justificative_file.virus_scanner.safe?).to be_truthy }
end
context "when a virus is found" do
@ -27,6 +26,6 @@ RSpec.describe AntiVirusJob, type: :job do
subject
end
it { expect(virus_scan.reload.status).to eq(VirusScan.statuses.fetch(:infected)) }
it { expect(champ.piece_justificative_file.virus_scanner.infected?).to be_truthy }
end
end

View file

@ -383,20 +383,19 @@ describe Champ do
let(:type_de_champ) { create(:type_de_champ_piece_justificative) }
context 'and there is a blob' do
before { champ.piece_justificative_file.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain") }
before do
champ.piece_justificative_file.attach(io: StringIO.new("toto"), filename: "toto.txt", content_type: "text/plain")
champ.save
end
it { expect { champ.save }.to change(VirusScan, :count).by(1) }
it { expect(champ.piece_justificative_file.virus_scanner.analyzed?).to be_truthy }
end
context 'and there is no blob' do
it { expect { champ.save }.to_not change(VirusScan, :count) }
end
end
before { champ.save }
context 'when type_champ is not type_de_champ_piece_justificative' do
let(:type_de_champ) { create(:type_de_champ_textarea) }
it { expect { champ.save }.to_not change(VirusScan, :count) }
it { expect(champ.piece_justificative_file.virus_scanner).to be_nil }
end
end
end

View file

@ -1,23 +1,24 @@
describe Champs::PieceJustificativeChamp do
describe '#for_api' do
let(:champ_pj) { create(:champ_piece_justificative) }
let(:metadata) { champ_pj.piece_justificative_file.blob.metadata }
before { champ_pj.virus_scan.update(status: status) }
before { champ_pj.piece_justificative_file.blob.update(metadata: metadata.merge(virus_scan_result: status)) }
subject { champ_pj.for_api }
context 'when file is safe' do
let(:status) { 'safe' }
let(:status) { ActiveStorage::VirusScanner::SAFE }
it { is_expected.to include("/rails/active_storage/blobs/") }
end
context 'when file is not scanned' do
let(:status) { 'pending' }
let(:status) { ActiveStorage::VirusScanner::PENDING }
it { is_expected.to include("/rails/active_storage/blobs/") }
end
context 'when file is infected' do
let(:status) { 'infected' }
let(:status) { ActiveStorage::VirusScanner::INFECTED }
it { is_expected.to be_nil }
end
end

View file

@ -10,7 +10,7 @@ describe ChampSerializer do
before do
champ.piece_justificative_file.attach({ filename: __FILE__, io: File.open(__FILE__) })
champ.reload.virus_scan.safe!
champ.piece_justificative_file.virus_scanner.analyze_later
end
after { champ.piece_justificative_file.purge }

View file

@ -1,19 +1,17 @@
require 'rails_helper'
describe 'shared/champs/piece_justificative/_pj_link.html.haml', type: :view do
let(:champ) { create(:champ, :piece_justificative, :with_piece_justificative_file) }
let(:virus_scan) { nil }
describe 'shared/piece_jointe/_pj_link.html.haml', type: :view do
let(:champ) { create(:champ_piece_justificative) }
let(:virus_scan_result) { nil }
before do
if virus_scan
champ.update(virus_scan: virus_scan)
end
champ.piece_justificative_file.blob.update(metadata: champ.piece_justificative_file.blob.metadata.merge(virus_scan_result: virus_scan_result))
end
subject { render 'shared/champs/piece_justificative/pj_link', champ: champ, user_can_upload: false }
subject { render 'shared/piece_jointe/pj_link', object: champ, pj: champ.piece_justificative_file, user_can_upload: false }
context 'when there is no anti-virus scan' do
let(:virus_scan) { nil }
let(:virus_scan_result) { nil }
it 'allows to download the file' do
expect(subject).to have_link(champ.piece_justificative_file.filename.to_s)
@ -22,7 +20,7 @@ describe 'shared/champs/piece_justificative/_pj_link.html.haml', type: :view do
end
context 'when the anti-virus scan is pending' do
let(:virus_scan) { create(:virus_scan, :pending) }
let(:virus_scan_result) { ActiveStorage::VirusScanner::PENDING }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)
@ -32,7 +30,7 @@ describe 'shared/champs/piece_justificative/_pj_link.html.haml', type: :view do
end
context 'when the file is scanned and safe' do
let(:virus_scan) { create(:virus_scan, :safe) }
let(:virus_scan_result) { ActiveStorage::VirusScanner::SAFE }
it 'allows to download the file' do
expect(subject).to have_link(champ.piece_justificative_file.filename.to_s)
@ -40,7 +38,7 @@ describe 'shared/champs/piece_justificative/_pj_link.html.haml', type: :view do
end
context 'when the file is scanned and infected' do
let(:virus_scan) { create(:virus_scan, :infected) }
let(:virus_scan_result) { ActiveStorage::VirusScanner::INFECTED }
it 'displays the filename, but doesnt allow to download the file' do
expect(subject).to have_text(champ.piece_justificative_file.filename.to_s)