
- Depending on context, I've gone for either "denylist" or "disallow" here. "Disallow" for things in sentences, or actions, and "denylist" for list of things.
396 lines
12 KiB
Ruby
396 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "cask/denylist"
|
|
require "cask/checkable"
|
|
require "cask/download"
|
|
require "digest"
|
|
require "utils/curl"
|
|
require "utils/git"
|
|
require "utils/notability"
|
|
|
|
module Cask
|
|
class Audit
|
|
include Checkable
|
|
extend Predicable
|
|
|
|
attr_reader :cask, :commit_range, :download
|
|
|
|
attr_predicate :appcast?
|
|
|
|
def initialize(cask, appcast: false, download: false,
|
|
token_conflicts: false, online: false, strict: false,
|
|
new_cask: false, commit_range: nil, command: SystemCommand)
|
|
@cask = cask
|
|
@appcast = appcast
|
|
@download = download
|
|
@online = online
|
|
@strict = strict
|
|
@new_cask = new_cask
|
|
@commit_range = commit_range
|
|
@token_conflicts = token_conflicts
|
|
@command = command
|
|
end
|
|
|
|
def run!
|
|
check_denylist
|
|
check_required_stanzas
|
|
check_version
|
|
check_sha256
|
|
check_url
|
|
check_generic_artifacts
|
|
check_token_conflicts
|
|
check_download
|
|
check_https_availability
|
|
check_single_pre_postflight
|
|
check_single_uninstall_zap
|
|
check_untrusted_pkg
|
|
check_hosting_with_appcast
|
|
check_latest_with_appcast
|
|
check_latest_with_auto_updates
|
|
check_stanza_requires_uninstall
|
|
check_appcast_contains_version
|
|
check_github_repository
|
|
check_gitlab_repository
|
|
check_bitbucket_repository
|
|
self
|
|
rescue => e
|
|
odebug "#{e.message}\n#{e.backtrace.join("\n")}"
|
|
add_error "exception while auditing #{cask}: #{e.message}"
|
|
self
|
|
end
|
|
|
|
def success?
|
|
!(errors? || warnings?)
|
|
end
|
|
|
|
def summary_header
|
|
"audit for #{cask}"
|
|
end
|
|
|
|
private
|
|
|
|
def check_untrusted_pkg
|
|
odebug "Auditing pkg stanza: allow_untrusted"
|
|
|
|
return if @cask.sourcefile_path.nil?
|
|
|
|
tap = @cask.tap
|
|
return if tap.nil?
|
|
return if tap.user != "Homebrew"
|
|
|
|
return unless cask.artifacts.any? { |k| k.is_a?(Artifact::Pkg) && k.stanza_options.key?(:allow_untrusted) }
|
|
|
|
add_warning "allow_untrusted is not permitted in official Homebrew Cask taps"
|
|
end
|
|
|
|
def check_stanza_requires_uninstall
|
|
odebug "Auditing stanzas which require an uninstall"
|
|
|
|
return if cask.artifacts.none? { |k| k.is_a?(Artifact::Pkg) || k.is_a?(Artifact::Installer) }
|
|
return if cask.artifacts.any? { |k| k.is_a?(Artifact::Uninstall) }
|
|
|
|
add_warning "installer and pkg stanzas require an uninstall stanza"
|
|
end
|
|
|
|
def check_single_pre_postflight
|
|
odebug "Auditing preflight and postflight stanzas"
|
|
|
|
if cask.artifacts.count { |k| k.is_a?(Artifact::PreflightBlock) && k.directives.key?(:preflight) } > 1
|
|
add_warning "only a single preflight stanza is allowed"
|
|
end
|
|
|
|
count = cask.artifacts.count do |k|
|
|
k.is_a?(Artifact::PostflightBlock) &&
|
|
k.directives.key?(:postflight)
|
|
end
|
|
return unless count > 1
|
|
|
|
add_warning "only a single postflight stanza is allowed"
|
|
end
|
|
|
|
def check_single_uninstall_zap
|
|
odebug "Auditing single uninstall_* and zap stanzas"
|
|
|
|
if cask.artifacts.count { |k| k.is_a?(Artifact::Uninstall) } > 1
|
|
add_warning "only a single uninstall stanza is allowed"
|
|
end
|
|
|
|
count = cask.artifacts.count do |k|
|
|
k.is_a?(Artifact::PreflightBlock) &&
|
|
k.directives.key?(:uninstall_preflight)
|
|
end
|
|
|
|
add_warning "only a single uninstall_preflight stanza is allowed" if count > 1
|
|
|
|
count = cask.artifacts.count do |k|
|
|
k.is_a?(Artifact::PostflightBlock) &&
|
|
k.directives.key?(:uninstall_postflight)
|
|
end
|
|
|
|
add_warning "only a single uninstall_postflight stanza is allowed" if count > 1
|
|
|
|
return unless cask.artifacts.count { |k| k.is_a?(Artifact::Zap) } > 1
|
|
|
|
add_warning "only a single zap stanza is allowed"
|
|
end
|
|
|
|
def check_required_stanzas
|
|
odebug "Auditing required stanzas"
|
|
[:version, :sha256, :url, :homepage].each do |sym|
|
|
add_error "a #{sym} stanza is required" unless cask.send(sym)
|
|
end
|
|
add_error "at least one name stanza is required" if cask.name.empty?
|
|
# TODO: specific DSL knowledge should not be spread around in various files like this
|
|
installable_artifacts = cask.artifacts.reject { |k| [:uninstall, :zap].include?(k) }
|
|
add_error "at least one activatable artifact stanza is required" if installable_artifacts.empty?
|
|
end
|
|
|
|
def check_version
|
|
return unless cask.version
|
|
|
|
check_no_string_version_latest
|
|
check_no_file_separator_in_version
|
|
end
|
|
|
|
def check_no_string_version_latest
|
|
odebug "Verifying version :latest does not appear as a string ('latest')"
|
|
return unless cask.version.raw_version == "latest"
|
|
|
|
add_error "you should use version :latest instead of version 'latest'"
|
|
end
|
|
|
|
def check_no_file_separator_in_version
|
|
odebug "Verifying version does not contain '#{File::SEPARATOR}'"
|
|
return unless cask.version.raw_version.is_a?(String)
|
|
return unless cask.version.raw_version.include?(File::SEPARATOR)
|
|
|
|
add_error "version should not contain '#{File::SEPARATOR}'"
|
|
end
|
|
|
|
def check_sha256
|
|
return unless cask.sha256
|
|
|
|
check_sha256_no_check_if_latest
|
|
check_sha256_actually_256
|
|
check_sha256_invalid
|
|
end
|
|
|
|
def check_sha256_no_check_if_latest
|
|
odebug "Verifying sha256 :no_check with version :latest"
|
|
return unless cask.version.latest?
|
|
return if cask.sha256 == :no_check
|
|
|
|
add_error "you should use sha256 :no_check when version is :latest"
|
|
end
|
|
|
|
def check_sha256_actually_256(sha256: cask.sha256, stanza: "sha256")
|
|
odebug "Verifying #{stanza} string is a legal SHA-256 digest"
|
|
return unless sha256.is_a?(String)
|
|
return if sha256.length == 64 && sha256[/^[0-9a-f]+$/i]
|
|
|
|
add_error "#{stanza} string must be of 64 hexadecimal characters"
|
|
end
|
|
|
|
def check_sha256_invalid(sha256: cask.sha256, stanza: "sha256")
|
|
odebug "Verifying #{stanza} is not a known invalid value"
|
|
empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
return unless sha256 == empty_sha256
|
|
|
|
add_error "cannot use the sha256 for an empty string in #{stanza}: #{empty_sha256}"
|
|
end
|
|
|
|
def check_latest_with_appcast
|
|
return unless cask.version.latest?
|
|
return unless cask.appcast
|
|
|
|
add_warning "Casks with an appcast should not use version :latest"
|
|
end
|
|
|
|
def check_latest_with_auto_updates
|
|
return unless cask.version.latest?
|
|
return unless cask.auto_updates
|
|
|
|
add_warning "Casks with `version :latest` should not use `auto_updates`"
|
|
end
|
|
|
|
def check_hosting_with_appcast
|
|
return if cask.appcast
|
|
|
|
add_appcast = "please add an appcast. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/appcast.md"
|
|
|
|
case cask.url.to_s
|
|
when %r{github.com/([^/]+)/([^/]+)/releases/download/(\S+)}
|
|
return if cask.version.latest?
|
|
|
|
add_warning "Download uses GitHub releases, #{add_appcast}"
|
|
when %r{sourceforge.net/(\S+)}
|
|
return if cask.version.latest?
|
|
|
|
add_warning "Download is hosted on SourceForge, #{add_appcast}"
|
|
when %r{dl.devmate.com/(\S+)}
|
|
add_warning "Download is hosted on DevMate, #{add_appcast}"
|
|
when %r{rink.hockeyapp.net/(\S+)}
|
|
add_warning "Download is hosted on HockeyApp, #{add_appcast}"
|
|
end
|
|
end
|
|
|
|
def check_url
|
|
return unless cask.url
|
|
|
|
check_download_url_format
|
|
end
|
|
|
|
def check_download_url_format
|
|
odebug "Auditing URL format"
|
|
if bad_sourceforge_url?
|
|
add_warning "SourceForge URL format incorrect. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls"
|
|
elsif bad_osdn_url?
|
|
add_warning "OSDN URL format incorrect. See https://github.com/Homebrew/homebrew-cask/blob/master/doc/cask_language_reference/stanzas/url.md#sourceforgeosdn-urls"
|
|
end
|
|
end
|
|
|
|
def bad_url_format?(regex, valid_formats_array)
|
|
return false unless cask.url.to_s.match?(regex)
|
|
|
|
valid_formats_array.none? { |format| cask.url.to_s =~ format }
|
|
end
|
|
|
|
def bad_sourceforge_url?
|
|
bad_url_format?(/sourceforge/,
|
|
[
|
|
%r{\Ahttps://sourceforge\.net/projects/[^/]+/files/latest/download\Z},
|
|
%r{\Ahttps://downloads\.sourceforge\.net/(?!(project|sourceforge)/)},
|
|
])
|
|
end
|
|
|
|
def bad_osdn_url?
|
|
bad_url_format?(/osd/, [%r{\Ahttps?://([^/]+.)?dl\.osdn\.jp/}])
|
|
end
|
|
|
|
def check_generic_artifacts
|
|
cask.artifacts.select { |a| a.is_a?(Artifact::Artifact) }.each do |artifact|
|
|
unless artifact.target.absolute?
|
|
add_error "target must be absolute path for #{artifact.class.english_name} #{artifact.source}"
|
|
end
|
|
end
|
|
end
|
|
|
|
def check_token_conflicts
|
|
return unless @token_conflicts
|
|
return unless core_formula_names.include?(cask.token)
|
|
|
|
add_warning "possible duplicate, cask token conflicts with Homebrew core formula: #{core_formula_url}"
|
|
end
|
|
|
|
def core_tap
|
|
@core_tap ||= CoreTap.instance
|
|
end
|
|
|
|
def core_formula_names
|
|
core_tap.formula_names
|
|
end
|
|
|
|
def core_formula_url
|
|
"#{core_tap.default_remote}/blob/master/Formula/#{cask.token}.rb"
|
|
end
|
|
|
|
def check_download
|
|
return unless download && cask.url
|
|
|
|
odebug "Auditing download"
|
|
downloaded_path = download.perform
|
|
Verify.all(cask, downloaded_path)
|
|
rescue => e
|
|
add_error "download not possible: #{e.message}"
|
|
end
|
|
|
|
def check_appcast_contains_version
|
|
return unless appcast?
|
|
return if cask.appcast.to_s.empty?
|
|
return if cask.appcast.must_contain == :no_check
|
|
|
|
appcast_stanza = cask.appcast.to_s
|
|
appcast_contents, = curl_output("--compressed", "--user-agent", HOMEBREW_USER_AGENT_FAKE_SAFARI, "--location",
|
|
"--globoff", "--max-time", "5", appcast_stanza)
|
|
version_stanza = cask.version.to_s
|
|
adjusted_version_stanza = if cask.appcast.configuration.blank?
|
|
version_stanza.match(/^[[:alnum:].]+/)[0]
|
|
else
|
|
cask.appcast.must_contain
|
|
end
|
|
return if appcast_contents.include? adjusted_version_stanza
|
|
|
|
add_warning "appcast at URL '#{appcast_stanza}' does not contain"\
|
|
" the version number '#{adjusted_version_stanza}':\n#{appcast_contents}"
|
|
rescue
|
|
add_error "appcast at URL '#{appcast_stanza}' offline or looping"
|
|
end
|
|
|
|
def check_github_repository
|
|
user, repo = get_repo_data(%r{https?://github\.com/([^/]+)/([^/]+)/?.*})
|
|
return if user.nil?
|
|
|
|
odebug "Auditing GitHub repo"
|
|
|
|
error = SharedAudits.github(user, repo)
|
|
add_error error if error
|
|
end
|
|
|
|
def check_gitlab_repository
|
|
user, repo = get_repo_data(%r{https?://gitlab\.com/([^/]+)/([^/]+)/?.*})
|
|
return if user.nil?
|
|
|
|
odebug "Auditing GitLab repo"
|
|
|
|
error = SharedAudits.gitlab(user, repo)
|
|
add_error error if error
|
|
end
|
|
|
|
def check_bitbucket_repository
|
|
user, repo = get_repo_data(%r{https?://bitbucket\.org/([^/]+)/([^/]+)/?.*})
|
|
return if user.nil?
|
|
|
|
odebug "Auditing Bitbucket repo"
|
|
|
|
error = SharedAudits.bitbucket(user, repo)
|
|
add_error error if error
|
|
end
|
|
|
|
def get_repo_data(regex)
|
|
return unless @online
|
|
return unless @new_cask
|
|
|
|
_, user, repo = *regex.match(cask.url.to_s)
|
|
_, user, repo = *regex.match(cask.homepage) unless user
|
|
_, user, repo = *regex.match(cask.appcast.to_s) unless user
|
|
return if !user || !repo
|
|
|
|
repo.gsub!(/.git$/, "")
|
|
|
|
[user, repo]
|
|
end
|
|
|
|
def check_denylist
|
|
return if cask.tap&.user != "Homebrew"
|
|
return unless reason = Denylist.reason(cask.token)
|
|
|
|
add_error "#{cask.token} is not allowed: #{reason}"
|
|
end
|
|
|
|
def check_https_availability
|
|
return unless download
|
|
|
|
if !cask.url.blank? && !cask.url.using
|
|
check_url_for_https_availability(cask.url, user_agents: [cask.url.user_agent])
|
|
end
|
|
check_url_for_https_availability(cask.appcast) unless cask.appcast.blank?
|
|
check_url_for_https_availability(cask.homepage, user_agents: [:browser]) unless cask.homepage.blank?
|
|
end
|
|
|
|
def check_url_for_https_availability(url_to_check, user_agents: [:default])
|
|
problem = curl_check_http_content(url_to_check.to_s, user_agents: user_agents)
|
|
add_error problem if problem
|
|
end
|
|
end
|
|
end
|