class Dependabot::Terraform::FileParser

Constants

ARCHIVE_EXTENSIONS
DEFAULT_NAMESPACE
DEFAULT_REGISTRY
PROVIDER_SOURCE_ADDRESS

www.terraform.io/docs/language/providers/requirements.html#source-addresses

Public Instance Methods

parse() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 28
def parse
  dependency_set = DependencySet.new

  terraform_files.each do |file|
    modules = parsed_file(file).fetch("module", {})
    modules.each do |name, details|
      dependency_set << build_terraform_dependency(file, name, details)
    end

    parsed_file(file).fetch("terraform", []).each do |terraform|
      required_providers = terraform.fetch("required_providers", {})
      required_providers.each do |provider|
        provider.each do |name, details|
          dependency_set << build_provider_dependency(file, name, details)
        end
      end
    end
  end

  terragrunt_files.each do |file|
    modules = parsed_file(file).fetch("terraform", [])
    modules.each do |details|
      next unless details["source"]

      dependency_set << build_terragrunt_dependency(file, details)
    end
  end

  dependency_set.dependencies.sort_by(&:name)
end

Private Instance Methods

build_provider_dependency(file, name, details = {}) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 89
def build_provider_dependency(file, name, details = {})
  deprecated_provider_error(file) if deprecated_provider?(details)

  source_address = details.fetch("source", nil)
  version_req = details["version"]&.strip
  hostname, namespace, name = provider_source_from(source_address, name)
  dependency_name = source_address ? "#{namespace}/#{name}" : name

  Dependency.new(
    name: dependency_name,
    version: determine_version_for(hostname, namespace, name, version_req),
    package_manager: "terraform",
    requirements: [
      requirement: version_req,
      groups: [],
      file: file.name,
      source: {
        type: "provider",
        registry_hostname: hostname,
        module_identifier: "#{namespace}/#{name}"
      }
    ]
  )
end
build_terraform_dependency(file, name, details) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 61
def build_terraform_dependency(file, name, details)
  details = details.first

  source = source_from(details)
  dep_name = case source[:type]
             when "registry" then source[:module_identifier]
             when "provider" then details["source"]
             else name
             end
  version_req = details["version"]&.strip
  version =
    if source[:type] == "git" then version_from_ref(source[:ref])
    elsif version_req&.match?(/^\d/) then version_req
    end

  Dependency.new(
    name: dep_name,
    version: version,
    package_manager: "terraform",
    requirements: [
      requirement: version_req,
      groups: [],
      file: file.name,
      source: source
    ]
  )
end
build_terragrunt_dependency(file, details) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 129
def build_terragrunt_dependency(file, details)
  source = source_from(details)
  dep_name =
    if Source.from_url(source[:url])
      Source.from_url(source[:url]).repo
    else
      source[:url]
    end

  version = version_from_ref(source[:ref])

  Dependency.new(
    name: dep_name,
    version: version,
    package_manager: "terraform",
    requirements: [
      requirement: nil,
      groups: [],
      file: file.name,
      source: source
    ]
  )
end
check_required_files() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 351
def check_required_files
  return if [*terraform_files, *terragrunt_files].any?

  raise "No Terraform configuration file!"
end
deprecated_provider?(details) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 123
def deprecated_provider?(details)
  # The old syntax for terraform providers v0.12- looked like
  # "tls ~> 2.1" which gets parsed as a string instead of a hash
  details.is_a?(String)
end
deprecated_provider_error(file) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 114
def deprecated_provider_error(file)
  raise Dependabot::DependencyFileNotParseable.new(
    file.path,
    "This terraform provider syntax is now deprecated.\n"\
    "See https://www.terraform.io/docs/language/providers/requirements.html "\
    "for the new Terraform v0.13+ provider syntax."
  )
end
determine_version_for(hostname, namespace, name, constraint) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 357
def determine_version_for(hostname, namespace, name, constraint)
  return constraint if constraint&.match?(/\A\d/)

  lock_file_content.
    dig("provider", "#{hostname}/#{namespace}/#{name}", 0, "version")
end
get_proxied_source(raw_source) click to toggle source

rubocop:disable Metrics/PerceivedComplexity See www.terraform.io/docs/modules/sources.html#http-urls for details of how Terraform handle HTTP(S) sources for modules

# File lib/dependabot/terraform/file_parser.rb, line 234
def get_proxied_source(raw_source) # rubocop:disable Metrics/AbcSize
  return raw_source unless raw_source.start_with?("http")

  uri = URI.parse(raw_source.split(%r{(?<!:)//}).first)
  return raw_source if uri.path.end_with?(*ARCHIVE_EXTENSIONS)
  return raw_source if URI.parse(raw_source).query&.include?("archive=")

  url = raw_source.split(%r{(?<!:)//}).first + "?terraform-get=1"
  host = URI.parse(raw_source).host

  response = Excon.get(
    url,
    idempotent: true,
    **SharedHelpers.excon_defaults
  )
  raise PrivateSourceAuthenticationFailure, host if response.status == 401

  return response.headers["X-Terraform-Get"] if response.headers["X-Terraform-Get"]

  doc = Nokogiri::XML(response.body)
  doc.css("meta").find do |tag|
    tag.attributes&.fetch("name", nil)&.value == "terraform-get"
  end&.attributes&.fetch("content", nil)&.value
rescue Excon::Error::Socket, Excon::Error::Timeout => e
  raise PrivateSourceAuthenticationFailure, host if e.message.include?("no address for")

  raw_source
end
git_source_details_from(source_string) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 202
def git_source_details_from(source_string)
  git_url = source_string.strip.gsub(/^git::/, "")
  git_url = "https://" + git_url unless git_url.start_with?("git@") || git_url.include?("://")

  bare_uri =
    if git_url.include?("git@")
      git_url.split("git@").last.sub(":", "/")
    else
      git_url.sub(%r{.*?://}, "")
    end

  querystr = URI.parse("https://" + bare_uri).query
  git_url = git_url.gsub("?#{querystr}", "").split(%r{(?<!:)//}).first

  {
    type: "git",
    url: git_url,
    branch: nil,
    ref: CGI.parse(querystr.to_s)["ref"].first&.split(%r{(?<!:)//})&.first
  }
end
lock_file_content() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 364
def lock_file_content
  @lock_file_content ||=
    begin
      lock_file = dependency_files.find do |file|
        file.name == ".terraform.lock.hcl"
      end
      lock_file ? parsed_file(lock_file) : {}
    end
end
native_helpers_root() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 346
def native_helpers_root
  default_path = File.join(__dir__, "../../../helpers/install-dir")
  ENV.fetch("DEPENDABOT_NATIVE_HELPERS_PATH", default_path)
end
parsed_file(file) click to toggle source

Returns:

A Hash representing each module found in the specified file

E.g. {

"module" => {
  {
    "consul" => [
      {
        "source"=>"consul/aws",
        "version"=>"0.1.0"
      }
    ]
  }
},
"terragrunt"=>[
  {
    "include"=>[{ "path"=>"${find_in_parent_folders()}" }],
    "terraform"=>[{ "source" => "git::git@github.com:gruntwork-io/modules-example.git//consul?ref=v0.0.2" }]
  }
],

}

# File lib/dependabot/terraform/file_parser.rb, line 308
def parsed_file(file)
  @parsed_buildfile ||= {}
  @parsed_buildfile[file.name] ||= SharedHelpers.in_a_temporary_directory do
    File.write("tmp.tf", file.content)

    command = "#{terraform_hcl2_parser_path} < tmp.tf"
    start = Time.now
    stdout, stderr, process = Open3.capture3(command)
    time_taken = Time.now - start

    unless process.success?
      raise SharedHelpers::HelperSubprocessFailed.new(
        message: stderr,
        error_context: {
          command: command,
          time_taken: time_taken,
          process_exit_value: process.to_s
        }
      )
    end

    JSON.parse(stdout)
  end
rescue SharedHelpers::HelperSubprocessFailed => e
  msg = e.message.strip
  raise Dependabot::DependencyFileNotParseable.new(file.path, msg)
end
provider_source_from(source_address, name) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 172
def provider_source_from(source_address, name)
  matches = source_address&.match(PROVIDER_SOURCE_ADDRESS)
  [
    matches.try(:[], :hostname) || DEFAULT_REGISTRY,
    matches.try(:[], :namespace) || DEFAULT_NAMESPACE,
    matches.try(:[], :name) || name
  ]
end
registry_source_details_from(source_string) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 181
def registry_source_details_from(source_string)
  parts = source_string.split("//").first.split("/")

  if parts.count == 3
    {
      type: "registry",
      registry_hostname: "registry.terraform.io",
      module_identifier: source_string.split("//").first
    }
  elsif parts.count == 4
    {
      type: "registry",
      registry_hostname: parts.first,
      module_identifier: parts[1..3].join("/")
    }
  else
    msg = "Invalid registry source specified: '#{source_string}'"
    raise DependencyFileNotEvaluatable, msg
  end
end
source_from(details_hash) click to toggle source

Full docs at www.terraform.io/docs/modules/sources.html

# File lib/dependabot/terraform/file_parser.rb, line 154
def source_from(details_hash)
  raw_source = details_hash.fetch("source")
  bare_source = get_proxied_source(raw_source)

  source_details =
    case source_type(bare_source)
    when :http_archive, :path, :mercurial, :s3
      { type: source_type(bare_source).to_s, url: bare_source }
    when :github, :bitbucket, :git
      git_source_details_from(bare_source)
    when :registry
      registry_source_details_from(bare_source)
    end

  source_details[:proxy_url] = raw_source if raw_source != bare_source
  source_details
end
source_type(source_string) click to toggle source

rubocop:disable Metrics/PerceivedComplexity

# File lib/dependabot/terraform/file_parser.rb, line 265
def source_type(source_string)
  return :path if source_string.start_with?(".")
  return :github if source_string.include?("github.com")
  return :bitbucket if source_string.start_with?("bitbucket.org/")
  return :git if source_string.start_with?("git::")
  return :mercurial if source_string.start_with?("hg::")
  return :s3 if source_string.start_with?("s3::")

  raise "Unknown src: #{source_string}" if source_string.split("/").first.include?("::")

  return :registry unless source_string.start_with?("http")

  path_uri = URI.parse(source_string.split(%r{(?<!:)//}).first)
  query_uri = URI.parse(source_string)
  return :http_archive if path_uri.path.end_with?(*ARCHIVE_EXTENSIONS)
  return :http_archive if query_uri.query&.include?("archive=")

  raise "HTTP source, but not an archive!"
end
terraform_hcl2_parser_path() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 341
def terraform_hcl2_parser_path
  helper_bin_dir = File.join(native_helpers_root, "terraform/bin")
  Pathname.new(File.join(helper_bin_dir, "hcl2json")).cleanpath.to_path
end
terraform_parser_path() click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 336
def terraform_parser_path
  helper_bin_dir = File.join(native_helpers_root, "terraform/bin")
  Pathname.new(File.join(helper_bin_dir, "json2hcl")).cleanpath.to_path
end
version_from_ref(ref) click to toggle source
# File lib/dependabot/terraform/file_parser.rb, line 224
def version_from_ref(ref)
  version_regex = GitCommitChecker::VERSION_REGEX
  return unless ref&.match?(version_regex)

  ref.match(version_regex).named_captures.fetch("version")
end