Automating API endpoint vunerability scanning using Capistrano, Swagger, and Intruder.io
For the past year, our team has been using [Intruder.io] as a vulnerability scanning tool.
I’m not going to go into why vunerability scanning is important, if you’ve found this you already know that, so I’ll just leave it in Intruder’s words:
Attackers move fast, you need to be faster. Use Intruder for real-time discovery and prioritization of attack surface weaknesses, so you can focus on the fixes that matter.
Intruder.io scans in a few different ways:
- Infrastructure Scans: Examining your network for potential vulnerabilities.
- Standard Endpoint Scans: Checking for common vulnerabilities (e.g., unprotected WordPress files).
- API Endpoint Scans: Scanning specific endpoints as defined in your Swagger files.
The first two scan types require minimal configuration beyond allowlisting IP addresses. However, for API endpoint scanning, we needed to upload our Swagger files to Intruder.io to enable it to evaluate our endpoints effectively.
Automating Swagger File Uploads with Capistrano
To streamline this process, we created a Capistrano task that syncs our Swagger files with Intruder.io each time we deploy to specific environments. After syncing, the task triggers a scan on our API endpoints, helping us ensure there are no unexpected vulnerabilities in our deployments.
Note: This setup runs on a QA environment that is deployed frequently, with a nightly automated deployment, so our scans stay current.
Below is the Capistrano task we created. We’re sharing it in case it’s helpful for other Ruby on Rails teams (or any team using Capistrano). While this isn’t packaged as a gem due to custom configuration needs, it could be easily adapted for general use.
Configuration
In your Capistrano setup, define the following variables:
intruder_io_token
: API token for authentication with Intruder.io.intruder_io_base_url
: Base URL of your API.intruder_io_authenticated_id
andintruder_io_unauthenticated_id
: Authentication IDs for secure and public endpoints, which you must set up within Intruder.io.intruder_io_target_id
: The ID of your service as defined in Intruder.io.
If you use multiple Swagger files, edit the code to manually include each one, you’ll also need their schema ID numbers from Swagger itself.
Code
This code defines two tasks: one for syncing Swagger files and one for triggering a scan.
# frozen_string_literal: true
require "mime/types"
require "net/http"
require "securerandom"
def create_multipart_body(file_path, params, boundary)
file_content = File.read(file_path)
filename = File.basename(file_path)
mime_type = MIME::Types.type_for(file_path).first.content_type
multipart_body = []
# File part of the request
multipart_body << "--#{boundary}"
multipart_body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\""
multipart_body << "Content-Type: #{mime_type}"
multipart_body << ""
multipart_body << file_content
# Additional parameters
params.each do |key, value|
multipart_body << "--#{boundary}"
multipart_body << "Content-Disposition: form-data; name=\"#{key}\""
multipart_body << ""
multipart_body << value
end
multipart_body << "--#{boundary}--"
multipart_body.join("\r\n")
end
def patch_file(url, file_path, params)
boundary = SecureRandom.hex
uri = URI.parse(url)
request = Net::HTTP::Patch.new(uri)
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
request["Authorization"] = fetch(:intruder_io_token)
request.body = create_multipart_body(file_path, params, boundary)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.request(request)
end
namespace :intruder do
desc "Upload Swagger files to Intruder.io"
task :sync_schema do
next unless fetch(:intruder_io_token)
commit = fetch(:current_revision, `git rev-parse --short HEAD`.chomp)
schema_map = {
schema_id_1: { "base_url": fetch(:intruder_io_base_url), "name": "Internal (#{commit})", "target_authentication_id": fetch(:intruder_io_authenticated_id), "file": "swagger/users/swagger.json" },
schema_id_2: { "base_url": fetch(:intruder_io_base_url), "name": "Oauth (#{commit})", "target_authentication_id": fetch(:intruder_io_authenticated_id), "file": "swagger/oauth/swagger.json" },
schema_id_3: { "base_url": fetch(:intruder_io_base_url), "name": "Public (#{commit})", "target_authentication_id": fetch(:intruder_io_unauthenticated_id), "file": "swagger/public/swagger.json" },
# ...etc for all Swagger files...
}
schema_map.each do |schema_id, data|
url = "https://api.intruder.io/v1/targets/#{fetch(:intruder_io_target_id)}/api_schemas/#{schema_id}/"
puts patch_file(url, data[:file], data).body
end
end
desc "Trigger a QA environment scan in Intruder.io"
task :start_qa_scan do
next unless fetch(:intruder_io_token) && fetch(:stage) == :qa
url = URI("https://api.intruder.io/v1/scans/")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["accept"] = "application/json"
request["content-type"] = "application/json"
request["Authorization"] = fetch(:intruder_io_token)
request.body = { target_addresses: [fetch(:intruder_io_target_name)] }.to_json
puts http.request(request).body
end
end
To finalize the setup, add the following to your config/deploy.rb:
after "deploy:finished", "intruder:sync_schema"
after "intruder:sync_schema", "intruder:start_qa_scan"