Current section
Files
Jump to
Current section
Files
lib/mix/tasks/reqord.audit.ex
defmodule Mix.Tasks.Reqord.Audit do
@moduledoc """
Audits cassette files for potential issues.
This task scans all cassette files and reports:
- Potential secrets or sensitive data that should be redacted
- Unused cassette entries (requires running tests first)
- Stale cassettes (old recorded dates)
## Usage
mix reqord.audit
mix reqord.audit --secrets-only
mix reqord.audit --unused-only
mix reqord.audit --stale-days 90
## Options
* `--secrets-only` - Only check for potential secrets
* `--unused-only` - Only check for unused cassettes (requires test coverage data)
* `--stale-days N` - Report cassettes older than N days (default: 365)
* `--dir PATH` - Cassette directory (default: test/support/cassettes)
"""
use Mix.Task
@shortdoc "Audit cassette files for secrets, unused entries, and staleness"
@cassette_dir "test/support/cassettes"
@default_stale_days 365
# Patterns that might indicate secrets
@secret_patterns [
~r/[a-zA-Z0-9]{32,}/,
# Long alphanumeric strings
~r/sk_[a-zA-Z0-9]+/,
# Stripe keys
~r/pk_[a-zA-Z0-9]+/,
# Stripe keys
~r/Bearer [a-zA-Z0-9._-]+/,
# Bearer tokens
~r/Basic [a-zA-Z0-9+\/=]+/,
# Basic auth
~r/ghp_[a-zA-Z0-9]{36}/,
# GitHub tokens
~r/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
# UUIDs (might be session IDs)
]
@impl Mix.Task
@spec run([String.t()]) :: no_return()
def run(args) do
{opts, _} =
OptionParser.parse!(args,
strict: [
secrets_only: :boolean,
unused_only: :boolean,
stale_days: :integer,
dir: :string
]
)
cassette_dir = opts[:dir] || @cassette_dir
stale_days = opts[:stale_days] || @default_stale_days
unless File.dir?(cassette_dir) do
Mix.Shell.IO.error("Cassette directory not found: #{cassette_dir}")
exit({:shutdown, 1})
end
Mix.Shell.IO.info("Auditing cassettes in #{cassette_dir}...\n")
cassettes = find_cassettes(cassette_dir)
if Enum.empty?(cassettes) do
Mix.Shell.IO.info("No cassettes found.")
exit({:shutdown, 0})
end
issues = %{
secrets: [],
unused: [],
stale: []
}
# Check secrets unless specifically excluded
issues =
if not (opts[:unused_only] == true) and not is_integer(opts[:stale_days]) do
%{issues | secrets: check_secrets(cassettes)}
else
issues
end
# Check unused unless specifically excluded
issues =
if not (opts[:secrets_only] == true) and not is_integer(opts[:stale_days]) do
%{issues | unused: check_unused(cassettes)}
else
issues
end
# Check stale unless specifically excluded
issues =
if not (opts[:secrets_only] == true) and not (opts[:unused_only] == true) do
%{issues | stale: check_stale(cassettes, stale_days)}
else
issues
end
report_issues(issues, opts)
end
defp find_cassettes(dir) do
Path.join(dir, "**/*.jsonl")
|> Path.wildcard()
|> Enum.map(fn path ->
{path, load_cassette(path)}
end)
end
defp load_cassette(path) do
path
|> File.stream!()
|> Stream.map(&String.trim/1)
|> Stream.reject(&(&1 == ""))
|> Stream.map(&Jason.decode!/1)
|> Enum.to_list()
rescue
_ -> []
end
defp check_secrets(cassettes) do
Enum.flat_map(cassettes, fn {path, entries} ->
Enum.with_index(entries, 1)
|> Enum.flat_map(fn {entry, line_num} ->
find_secrets_in_entry(entry, path, line_num)
end)
end)
end
defp find_secrets_in_entry(entry, path, line_num) do
issues = []
# Check request headers
req_headers = get_in(entry, ["req", "headers"]) || %{}
issues =
issues ++
Enum.flat_map(req_headers, fn {key, value} ->
if value != "<REDACTED>" and matches_secret_pattern?(value) do
[
%{
path: path,
line: line_num,
type: :secret,
location: "req.headers.#{key}",
value: truncate_secret(value)
}
]
else
[]
end
end)
# Check request URL for non-redacted secrets
req_url = get_in(entry, ["req", "url"]) || ""
issues =
if String.contains?(req_url, ["token=", "apikey=", "api_key="]) and
not String.contains?(req_url, "REDACTED") do
issues ++
[
%{
path: path,
line: line_num,
type: :secret,
location: "req.url",
value: truncate_secret(req_url)
}
]
else
issues
end
# Check response body for potential secrets
resp_body_b64 = get_in(entry, ["resp", "body_b64"])
issues =
if resp_body_b64 do
body = Base.decode64!(resp_body_b64)
if matches_secret_pattern?(body) do
issues ++
[
%{
path: path,
line: line_num,
type: :secret,
location: "resp.body",
value: "(response body contains potential secrets)"
}
]
else
issues
end
else
issues
end
issues
end
defp matches_secret_pattern?(text) do
Enum.any?(@secret_patterns, &Regex.match?(&1, text))
end
defp truncate_secret(value) do
if String.length(value) > 50 do
String.slice(value, 0..47) <> "..."
else
value
end
end
defp check_unused(_cassettes) do
# This would require instrumentation during test runs
# For now, just return empty list with a note
Mix.Shell.IO.info("Note: Unused cassette detection requires running tests with coverage.\n")
[]
end
defp check_stale(cassettes, stale_days) do
cutoff = DateTime.utc_now() |> DateTime.add(-stale_days, :day)
Enum.flat_map(cassettes, fn {path, _entries} ->
case File.stat(path) do
{:ok, stat} ->
mtime = stat.mtime |> NaiveDateTime.from_erl!() |> DateTime.from_naive!("Etc/UTC")
if DateTime.compare(mtime, cutoff) == :lt do
[
%{
path: path,
type: :stale,
age_days: DateTime.diff(DateTime.utc_now(), mtime, :day)
}
]
else
[]
end
_ ->
[]
end
end)
end
@spec report_issues(map(), keyword()) :: no_return()
defp report_issues(issues, opts) do
total = length(issues.secrets) + length(issues.unused) + length(issues.stale)
if total == 0 do
Mix.Shell.IO.info("✓ No issues found!")
exit({:shutdown, 0})
end
unless Enum.empty?(issues.secrets) or opts[:unused_only] or opts[:stale_days] do
Mix.Shell.IO.error("\n⚠ Potential Secrets Found (#{length(issues.secrets)}):\n")
Enum.each(issues.secrets, fn issue ->
Mix.Shell.IO.error(" #{issue.path}:#{issue.line}")
Mix.Shell.IO.error(" Location: #{issue.location}")
Mix.Shell.IO.error(" Value: #{issue.value}\n")
end)
end
unless Enum.empty?(issues.stale) or opts[:secrets_only] or opts[:unused_only] do
Mix.Shell.IO.info("\n⏰ Stale Cassettes (#{length(issues.stale)}):\n")
Enum.each(issues.stale, fn issue ->
Mix.Shell.IO.info(" #{issue.path} (#{issue.age_days} days old)")
end)
end
unless Enum.empty?(issues.unused) or opts[:secrets_only] or opts[:stale_days] do
Mix.Shell.IO.info("\n🗑 Unused Cassettes (#{length(issues.unused)}):\n")
Enum.each(issues.unused, fn issue ->
Mix.Shell.IO.info(" #{issue.path}")
end)
end
Mix.Shell.IO.info("\nTotal issues: #{total}")
if total > 0 do
exit({:shutdown, 1})
else
exit({:shutdown, 0})
end
end
end