Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lib/http_capability_gateway/gateway.ex
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,17 @@ defmodule HttpCapabilityGateway.Gateway do
remote_ip: conn.remote_ip |> :inet.ntoa() |> to_string()
)

# Persist to the audit ledger so probes for unsupported verbs
# (PROPFIND/MKCOL/REPORT/garbage) leave a forensic trail. This was
# missing from the audit stream — the most security-relevant path
# (unknown verb against an undeclared route) was the one not being
# recorded. The verb string is passed as-is (no atom creation);
# VeriSimDB stores it verbatim. The "policy_ref" carries a
# discriminator so the audit reader can distinguish this case from
# a legitimate deny.
trust_level = Map.get(conn.assigns, :trust_level, :untrusted)
VeriSimDB.audit_deny(path, conn.method, trust_level, "unknown_method:#{conn.method}")

conn
|> put_resp_content_type("application/json")
|> send_resp(405, Jason.encode!(%{error: "Method Not Allowed"}))
Expand Down Expand Up @@ -387,6 +398,13 @@ defmodule HttpCapabilityGateway.Gateway do
duration_us = System.monotonic_time() - start_time
log_decision(request_id, path, verb, trust_level, :no_match, nil, duration_us)

# Persist to the audit ledger as well. The no-match path is
# security-relevant (a probe for an undeclared route) and was
# previously logged but not persisted. The "policy_ref"
# discriminator lets the audit reader filter no-match denials
# from explicit-rule denials.
VeriSimDB.audit_deny(path, to_string(verb), trust_level, "no_match")

stealth_profiles = Application.get_env(:http_capability_gateway, :stealth_profiles, %{})
stealth_enabled? = stealth_profiles != %{}

Expand Down
128 changes: 128 additions & 0 deletions test/gateway_audit_paths_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
defmodule HttpCapabilityGateway.GatewayAuditPathsTest do
@moduledoc """
Regression tests for audit-ledger persistence on the no-match and
unknown-method denial paths. Both paths were previously logged via
Logger but not persisted via VeriSimDB. Audit issue #31 (priority 4).
"""

use ExUnit.Case, async: false
import Plug.Conn
import Plug.Test

alias HttpCapabilityGateway.{Gateway, PolicyCompiler, VeriSimDB}

setup_all do
# Start the VeriSimDB GenServer if it isn't already (so the casts in
# the gateway have a live mailbox to land in). The buffer ETS table
# is created on init/1, which is what we assert against.
case Process.whereis(VeriSimDB) do
nil ->
{:ok, _pid} = VeriSimDB.start_link([])
:ok

_pid ->
:ok
end

HttpCapabilityGateway.RateLimiter.init([])
HttpCapabilityGateway.K9Contract.init()
:ok
end

setup do
policy = %{
"dsl_version" => "1",
"governance" => %{
"global_verbs" => ["GET"],
"routes" => [%{"path" => "/api/known", "verbs" => ["GET"]}]
}
}

{:ok, table} = PolicyCompiler.compile(policy, delete_old: false)
Application.put_env(:http_capability_gateway, :policy_table, table)
Application.put_env(:http_capability_gateway, :stealth_profiles, %{})
:ok
end

# Helper: drain the VeriSimDB buffer ETS table and return all rows
# that match this test's request_id, so we can assert on what was cast.
defp read_buffer do
case :ets.whereis(:capgw_verisimdb_buffer) do
:undefined ->
[]

_tid ->
:ets.tab2list(:capgw_verisimdb_buffer)
end
end

defp wait_for_cast_to_settle do
# GenServer.cast is async; flush by doing a sync call on the same pid.
_ = :sys.get_state(VeriSimDB)
:ok
end

defp clear_buffer do
case :ets.whereis(:capgw_verisimdb_buffer) do
:undefined -> :ok
_ -> :ets.delete_all_objects(:capgw_verisimdb_buffer)
end

:ok
end

describe "no-match denial path" do
test "persists a deny entry to the audit ledger" do
clear_buffer()

conn = conn(:delete, "/api/totally-undeclared") |> Gateway.call([])
assert conn.status == 403
assert conn.halted

wait_for_cast_to_settle()

entries = read_buffer()

# The buffer stores {id, entry_map}; the entry_map carries the
# action, path, verb, trust, and policy_ref discriminator.
matching =
Enum.filter(entries, fn {_id, entry} ->
entry[:action] == :deny and
entry[:path] == "/api/totally-undeclared" and
entry[:policy_ref] == "no_match"
end)

assert length(matching) >= 1,
"expected at least one no_match deny entry; buffer was: #{inspect(entries)}"
end
end

describe "unknown-method denial path" do
test "persists a deny entry with unknown_method discriminator" do
clear_buffer()

# Use a method outside the @valid_methods allowlist. PROPFIND is the
# canonical WebDAV verb the gateway must reject; previously this
# only produced a Logger.warning with no audit row.
conn = conn("PROPFIND", "/api/known") |> Gateway.call([])
assert conn.status == 405
assert conn.halted

wait_for_cast_to_settle()

entries = read_buffer()

matching =
Enum.filter(entries, fn {_id, entry} ->
entry[:action] == :deny and
is_binary(entry[:policy_ref]) and
String.starts_with?(entry[:policy_ref], "unknown_method:")
end)

assert length(matching) >= 1,
"expected at least one unknown_method deny entry; buffer was: #{inspect(entries)}"
end
end
end
Loading