Skip to content

Commit 112239f

Browse files
committed
feat(audit): persist no-match + unknown-method denials to VeriSimDB (audit #31, P4)
Two denial paths were previously logged via Logger but NOT persisted to the VeriSimDB audit ledger: 1. no-match (PolicyCompiler.lookup returns {:error, :no_match}) 2. unknown HTTP method (safe_verb/1 returns nil; PROPFIND/MKCOL/garbage) Both are the most security-relevant paths — probes for undeclared routes or unsupported verbs are exactly the events a forensic reader most wants to replay — yet were the ones missing from the audit stream. Adds VeriSimDB.audit_deny/4 calls on both paths with a "policy_ref" discriminator ("no_match" or "unknown_method:<METHOD>") so an audit reader can distinguish these from explicit-rule denials. Test coverage: * /api/totally-undeclared DELETE -> 403 + a no_match audit entry * PROPFIND /api/known -> 405 + an unknown_method:PROPFIND audit entry * Both tests drain the VeriSimDB ETS buffer and assert on the cast having landed (sys.get_state used to flush the cast queue). Refs: #31 (self-audit, priority 4)
1 parent 4cda8d7 commit 112239f

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

lib/http_capability_gateway/gateway.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,17 @@ defmodule HttpCapabilityGateway.Gateway do
307307
remote_ip: conn.remote_ip |> :inet.ntoa() |> to_string()
308308
)
309309

310+
# Persist to the audit ledger so probes for unsupported verbs
311+
# (PROPFIND/MKCOL/REPORT/garbage) leave a forensic trail. This was
312+
# missing from the audit stream — the most security-relevant path
313+
# (unknown verb against an undeclared route) was the one not being
314+
# recorded. The verb string is passed as-is (no atom creation);
315+
# VeriSimDB stores it verbatim. The "policy_ref" carries a
316+
# discriminator so the audit reader can distinguish this case from
317+
# a legitimate deny.
318+
trust_level = Map.get(conn.assigns, :trust_level, :untrusted)
319+
VeriSimDB.audit_deny(path, conn.method, trust_level, "unknown_method:#{conn.method}")
320+
310321
conn
311322
|> put_resp_content_type("application/json")
312323
|> send_resp(405, Jason.encode!(%{error: "Method Not Allowed"}))
@@ -387,6 +398,13 @@ defmodule HttpCapabilityGateway.Gateway do
387398
duration_us = System.monotonic_time() - start_time
388399
log_decision(request_id, path, verb, trust_level, :no_match, nil, duration_us)
389400

401+
# Persist to the audit ledger as well. The no-match path is
402+
# security-relevant (a probe for an undeclared route) and was
403+
# previously logged but not persisted. The "policy_ref"
404+
# discriminator lets the audit reader filter no-match denials
405+
# from explicit-rule denials.
406+
VeriSimDB.audit_deny(path, to_string(verb), trust_level, "no_match")
407+
390408
stealth_profiles = Application.get_env(:http_capability_gateway, :stealth_profiles, %{})
391409
stealth_enabled? = stealth_profiles != %{}
392410

test/gateway_audit_paths_test.exs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule HttpCapabilityGateway.GatewayAuditPathsTest do
4+
@moduledoc """
5+
Regression tests for audit-ledger persistence on the no-match and
6+
unknown-method denial paths. Both paths were previously logged via
7+
Logger but not persisted via VeriSimDB. Audit issue #31 (priority 4).
8+
"""
9+
10+
use ExUnit.Case, async: false
11+
import Plug.Conn
12+
import Plug.Test
13+
14+
alias HttpCapabilityGateway.{Gateway, PolicyCompiler, VeriSimDB}
15+
16+
setup_all do
17+
# Start the VeriSimDB GenServer if it isn't already (so the casts in
18+
# the gateway have a live mailbox to land in). The buffer ETS table
19+
# is created on init/1, which is what we assert against.
20+
case Process.whereis(VeriSimDB) do
21+
nil ->
22+
{:ok, _pid} = VeriSimDB.start_link([])
23+
:ok
24+
25+
_pid ->
26+
:ok
27+
end
28+
29+
HttpCapabilityGateway.RateLimiter.init([])
30+
HttpCapabilityGateway.K9Contract.init()
31+
:ok
32+
end
33+
34+
setup do
35+
policy = %{
36+
"dsl_version" => "1",
37+
"governance" => %{
38+
"global_verbs" => ["GET"],
39+
"routes" => [%{"path" => "/api/known", "verbs" => ["GET"]}]
40+
}
41+
}
42+
43+
{:ok, table} = PolicyCompiler.compile(policy, delete_old: false)
44+
Application.put_env(:http_capability_gateway, :policy_table, table)
45+
Application.put_env(:http_capability_gateway, :stealth_profiles, %{})
46+
:ok
47+
end
48+
49+
# Helper: drain the VeriSimDB buffer ETS table and return all rows
50+
# that match this test's request_id, so we can assert on what was cast.
51+
defp read_buffer do
52+
case :ets.whereis(:capgw_verisimdb_buffer) do
53+
:undefined ->
54+
[]
55+
56+
_tid ->
57+
:ets.tab2list(:capgw_verisimdb_buffer)
58+
end
59+
end
60+
61+
defp wait_for_cast_to_settle do
62+
# GenServer.cast is async; flush by doing a sync call on the same pid.
63+
_ = :sys.get_state(VeriSimDB)
64+
:ok
65+
end
66+
67+
defp clear_buffer do
68+
case :ets.whereis(:capgw_verisimdb_buffer) do
69+
:undefined -> :ok
70+
_ -> :ets.delete_all_objects(:capgw_verisimdb_buffer)
71+
end
72+
73+
:ok
74+
end
75+
76+
describe "no-match denial path" do
77+
test "persists a deny entry to the audit ledger" do
78+
clear_buffer()
79+
80+
conn = conn(:delete, "/api/totally-undeclared") |> Gateway.call([])
81+
assert conn.status == 403
82+
assert conn.halted
83+
84+
wait_for_cast_to_settle()
85+
86+
entries = read_buffer()
87+
88+
# The buffer stores {id, entry_map}; the entry_map carries the
89+
# action, path, verb, trust, and policy_ref discriminator.
90+
matching =
91+
Enum.filter(entries, fn {_id, entry} ->
92+
entry[:action] == :deny and
93+
entry[:path] == "/api/totally-undeclared" and
94+
entry[:policy_ref] == "no_match"
95+
end)
96+
97+
assert length(matching) >= 1,
98+
"expected at least one no_match deny entry; buffer was: #{inspect(entries)}"
99+
end
100+
end
101+
102+
describe "unknown-method denial path" do
103+
test "persists a deny entry with unknown_method discriminator" do
104+
clear_buffer()
105+
106+
# Use a method outside the @valid_methods allowlist. PROPFIND is the
107+
# canonical WebDAV verb the gateway must reject; previously this
108+
# only produced a Logger.warning with no audit row.
109+
conn = conn("PROPFIND", "/api/known") |> Gateway.call([])
110+
assert conn.status == 405
111+
assert conn.halted
112+
113+
wait_for_cast_to_settle()
114+
115+
entries = read_buffer()
116+
117+
matching =
118+
Enum.filter(entries, fn {_id, entry} ->
119+
entry[:action] == :deny and
120+
is_binary(entry[:policy_ref]) and
121+
String.starts_with?(entry[:policy_ref], "unknown_method:")
122+
end)
123+
124+
assert length(matching) >= 1,
125+
"expected at least one unknown_method deny entry; buffer was: #{inspect(entries)}"
126+
end
127+
end
128+
end

0 commit comments

Comments
 (0)