diff --git a/.gitignore b/.gitignore index db77a9c..fe5455a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ clickguard-*.tar # misc .DS_Store +/clickguard +/test/fixtures/* diff --git a/lib/clickguard.ex b/lib/clickguard.ex index 4318a97..5431ac8 100644 --- a/lib/clickguard.ex +++ b/lib/clickguard.ex @@ -63,11 +63,8 @@ defmodule Clickguard do def run(path, opts \\ []) do {:ok, events} = parse(File.stream!(path), opts) {:ok, findings} = detect(events, opts) - - {:ok, - Scorer.score( - findings, - actor_totals(events) - )} + {:ok, Scorer.score(findings, actor_totals(events))} + rescue + e in File.Error -> {:error, e.reason} end end diff --git a/lib/clickguard/cli.ex b/lib/clickguard/cli.ex new file mode 100644 index 0000000..fc47234 --- /dev/null +++ b/lib/clickguard/cli.ex @@ -0,0 +1,26 @@ +defmodule Clickguard.CLI do + @moduledoc """ + Handle the command line parsing and the dispatch to + `Clickguard.run/1` and `Clickguard.Reporter.Text` that + end up generating a formatted output. + """ + alias Clickguard.Reporter.Text + + def main(args) do + case OptionParser.parse(args, strict: []) do + {_flags, [input_file], _} -> + case Clickguard.run(input_file) do + {:ok, scores} -> + IO.write(Text.format(scores)) + + {:error, reason} -> + IO.puts(:stderr, "Error: " <> to_string(reason)) + System.halt(1) + end + + _ -> + IO.puts(:stderr, "Usage: clickguard input_log") + System.halt(1) + end + end +end diff --git a/lib/clickguard/detector.ex b/lib/clickguard/detector.ex index 412d479..a5621e1 100644 --- a/lib/clickguard/detector.ex +++ b/lib/clickguard/detector.ex @@ -5,8 +5,8 @@ defmodule Clickguard.Detector do Implementations: * `Clickguard.Detector.FreqIp` - IPs with >N requests per minute - * `Clickguard.Detector.Referer` - empty or suspicious referers (planned) - * `Clickguard.Detector.UserAgent` - match known datacenter/headless UAs (planned) + * `Clickguard.Detector.Referer` - empty or suspicious referers + * `Clickguard.Detector.UserAgent` - match known datacenter/headless UAs ## Implementing a detector diff --git a/lib/clickguard/reporter.ex b/lib/clickguard/reporter.ex new file mode 100644 index 0000000..7f7b3f2 --- /dev/null +++ b/lib/clickguard/reporter.ex @@ -0,0 +1,23 @@ +defmodule Clickguard.Reporter do + @moduledoc """ + Behaviour for reporters. + + Implementations: + + * `Clickguard.Reporter.Text` - plain text without styling, best for text files and terminal output. + + ## Implementing a reporter + + defmodule MyReporter do + @behaviour Clickguard.Reporter + + @impl true + def format(scores) do + # ... + end + end + """ + alias Clickguard.Score + + @callback format(scores :: [Score.t()]) :: iodata() +end diff --git a/lib/clickguard/reporter/text.ex b/lib/clickguard/reporter/text.ex new file mode 100644 index 0000000..39af500 --- /dev/null +++ b/lib/clickguard/reporter/text.ex @@ -0,0 +1,63 @@ +defmodule Clickguard.Reporter.Text do + @moduledoc """ + Generates plain text output from a list of `Clickguard.Score`. + + Output is sorted by `band`, from the most severe to the least. + """ + @behaviour Clickguard.Reporter + + @severity_rank %{high: 0, medium: 1, low: 2} + @band_rank %{fraud: 0, suspect: 1, clear: 2} + + @impl true + def format(scores) do + header = ["actor\tevents\tband\tscore\tsummary\tworst\n"] + + rows = + scores + |> Enum.sort_by(fn s -> {Map.get(@band_rank, s.band), -s.score} end) + |> Enum.map(fn score -> + {actor_type, actor} = score.actor + + [ + to_string(actor_type), + ":", + actor, + "\t", + Integer.to_string(score.total_events), + "\t", + to_string(score.band), + "\t", + Integer.to_string(score.score), + "\t", + severity_summary(score.rule_summary), + "\t", + worst_rule(score.rule_summary), + "\n" + ] + end) + + [header | rows] + end + + defp severity_summary(rule_summary) do + rule_summary + |> Enum.frequencies_by(fn {_rule, {severity, _count}} -> severity end) + |> Enum.sort_by(fn {severity, _count} -> Map.get(@severity_rank, severity, 99) end) + |> Enum.map(fn {severity, count} -> + [to_string(severity), ": ", Integer.to_string(count)] + end) + |> Enum.intersperse(", ") + end + + defp worst_rule(rule_summary) do + [{rule, {_severity, count}}] = + rule_summary + |> Enum.sort_by(fn {_rule, {severity, count}} -> + {Map.get(@severity_rank, severity, 99), -count} + end) + |> Enum.take(1) + + [":", to_string(rule), " (", Integer.to_string(count), ")"] + end +end diff --git a/lib/clickguard/scorer.ex b/lib/clickguard/scorer.ex index 7bfaa3e..9851afe 100644 --- a/lib/clickguard/scorer.ex +++ b/lib/clickguard/scorer.ex @@ -9,14 +9,16 @@ defmodule Clickguard.Scorer do def score(findings, total_by_actor) do findings |> Enum.group_by(fn %Finding{subject: subj, actor_type: a_type} -> {a_type, subj} end) - |> Enum.map(fn {actor, findings} -> - summary = rule_summary(findings) + |> Enum.map(fn {actor, actor_findings} -> + summary = rule_summary(actor_findings) {band, score} = score_actor(summary) {_type, value} = actor %Score{ actor: actor, - total_findings: length(findings), + total_findings: length(actor_findings), + # Keyed by IP string. Breaks when actor_type is :source or :session. + # Re-key total_by_actor by {actor_type, subject}. total_events: total_by_actor[value], rule_summary: summary, band: band, diff --git a/mix.exs b/mix.exs index b8b7a6f..c79b42e 100644 --- a/mix.exs +++ b/mix.exs @@ -8,6 +8,7 @@ defmodule Clickguard.MixProject do elixir: "~> 1.20", start_permanent: Mix.env() == :prod, deps: deps(), + escript: escript(), dialyzer: [ plt_file: {:no_warn, "priv/plts/project.plt"}, plt_add_apps: [:mix] @@ -16,6 +17,10 @@ defmodule Clickguard.MixProject do ] end + defp escript do + [main_module: Clickguard.CLI] + end + defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] diff --git a/test/clickguard/reporter/text_test.exs b/test/clickguard/reporter/text_test.exs new file mode 100644 index 0000000..dec61a7 --- /dev/null +++ b/test/clickguard/reporter/text_test.exs @@ -0,0 +1,262 @@ +defmodule Clickguard.Reporter.TextTest do + use ExUnit.Case, async: true + + alias Clickguard.Reporter.Text + alias Clickguard.Score + + describe "format/1 - sort order" do + test "lines are correctly sorted by band" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{low_detection: {:low, 1}}, + band: :clear, + score: 1 + }, + %Score{ + actor: {:ip, "127.0.0.2"}, + total_findings: 1, + total_events: 1, + rule_summary: %{medium_detection: {:medium, 1}}, + band: :suspect, + score: 3 + }, + %Score{ + actor: {:ip, "127.0.0.3"}, + total_findings: 1, + total_events: 1, + rule_summary: %{high_detection: {:high, 1}}, + band: :fraud, + score: 16 + } + ] + + assert [_header, l1, l2, l3] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "fraud") + assert String.contains?(l2, "suspect") + assert String.contains?(l3, "clear") + end + + test "lines are correctly sorted by score inside bands" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{high_detection: {:high, 1}}, + band: :fraud, + score: 16 + }, + %Score{ + actor: {:ip, "127.0.0.2"}, + total_findings: 2, + total_events: 2, + rule_summary: %{high_detection: {:high, 1}, another_high_detection: {:high, 1}}, + band: :fraud, + score: 32 + } + ] + + assert [_header, l1, l2] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "32") + assert String.contains?(l2, "16") + end + end + + describe "format/1 - severity summary" do + test "includes only present severities" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 2, + total_events: 2, + rule_summary: %{high_detection: {:high, 1}, low_detection: {:low, 1}}, + band: :fraud, + score: 17 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "high: 1") + assert String.contains?(l1, "low: 1") + refute String.contains?(l1, "medium: 0") + end + + test "severities are correctly sorted" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 3, + total_events: 3, + rule_summary: %{ + high_detection: {:high, 1}, + low_detection: {:low, 1}, + medium_detection: {:medium, 1} + }, + band: :fraud, + score: 20 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "high: 1, medium: 1, low: 1") + end + + test "single severity is correctly displayed" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{ + medium_detection: {:medium, 1} + }, + band: :fraud, + score: 3 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "\tmedium: 1\t") + end + end + + describe "format/1 - worst rule selection" do + test "single :medium finding outranks 1000 :low" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1001, + total_events: 10_010, + rule_summary: %{ + medium_detection: {:medium, 1}, + low_detection: {:low, 1000} + }, + band: :fraud, + score: 1003 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "\t:medium_detection (1)") + end + + test "within the same severity, higher rule_finding wins" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1001, + total_events: 10_010, + rule_summary: %{ + medium_detection_one: {:medium, 1}, + medium_detection_two: {:medium, 1000} + }, + band: :fraud, + score: 3003 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "\t:medium_detection_two (1000)") + end + end + + describe "format/1 - edge cases" do + test "empty scores list returns just the header" do + assert Text.format([]) |> IO.iodata_to_binary() |> String.split("\n", trim: true) == + ["actor\tevents\tband\tscore\tsummary\tworst"] + end + + test "actor with only hygiene rules is formatted correctly" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{ + low_detection: {:low, 1} + }, + band: :clear, + score: 0 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.contains?(l1, "clear") + end + end + + describe "format/1 - output structure" do + test "header is the first row" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{low_detection: {:low, 1}}, + band: :clear, + score: 1 + }, + %Score{ + actor: {:ip, "127.0.0.2"}, + total_findings: 1, + total_events: 1, + rule_summary: %{medium_detection: {:medium, 1}}, + band: :suspect, + score: 3 + }, + %Score{ + actor: {:ip, "127.0.0.3"}, + total_findings: 1, + total_events: 1, + rule_summary: %{high_detection: {:high, 1}}, + band: :fraud, + score: 16 + } + ] + + assert [header | _rest] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert header == "actor\tevents\tband\tscore\tsummary\tworst" + end + + test "actor is well-formatted" do + scores = [ + %Score{ + actor: {:ip, "127.0.0.1"}, + total_findings: 1, + total_events: 1, + rule_summary: %{ + low_detection: {:low, 1} + }, + band: :clear, + score: 0 + } + ] + + assert [_header, l1] = + Text.format(scores) |> IO.iodata_to_binary() |> String.split("\n", trim: true) + + assert String.starts_with?(l1, "ip:127.0.0.1") + end + end +end