Skip to content

Commit 91d4932

Browse files
committed
security: harden endpoints, CI/CD, and add security-focused tests
- Fix error information leakage in handler rescue clause (no longer exposes inspect(error) to clients) - Convert all Logger calls to structured metadata to prevent log injection - Extract shared OriginValidation plug and add to SSE transport (was missing, only StreamableHTTP had it) - Add SecurityHeaders plug (X-Content-Type-Options, X-Frame-Options, Cache-Control) to both transports - Set explicit 1MB body size limit on Plug.Parsers (was implicit 8MB) - Truncate method names in error responses to 200 chars - Add Sobelow security scanning, dependency audit, and coverage enforcement (85% threshold) to CI pipeline - Add sobelow, mix_audit, stream_data dependencies - Add property-based protocol fuzzing tests with StreamData - Add security tests for error leakage, malformed inputs, long methods - Add origin validation and security header tests for both transports
1 parent 48f9540 commit 91d4932

File tree

15 files changed

+701
-51
lines changed

15 files changed

+701
-51
lines changed

.github/workflows/ci.yml

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,65 @@ jobs:
121121
- run: mix deps.get
122122
- run: mix deps.compile
123123
- run: mix compile
124-
- run: mix test
124+
125+
- name: Run tests with coverage
126+
run: mix coveralls --raise --threshold 85
127+
env:
128+
MIX_ENV: test
129+
130+
sobelow:
131+
name: Security Scan (Sobelow)
132+
runs-on: ubuntu-latest
133+
steps:
134+
- uses: actions/checkout@v6
135+
136+
- uses: erlef/setup-beam@v1
137+
with:
138+
elixir-version: ${{ env.ELIXIR_VERSION }}
139+
otp-version: ${{ env.OTP_VERSION }}
140+
141+
- name: Cache deps & build
142+
uses: actions/cache@v5
143+
with:
144+
path: |
145+
deps
146+
_build
147+
key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}
148+
restore-keys: |
149+
${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
150+
151+
- run: mix deps.get
152+
- run: mix sobelow --config --exit
153+
154+
audit:
155+
name: Dependency Audit
156+
runs-on: ubuntu-latest
157+
steps:
158+
- uses: actions/checkout@v6
159+
160+
- uses: erlef/setup-beam@v1
161+
with:
162+
elixir-version: ${{ env.ELIXIR_VERSION }}
163+
otp-version: ${{ env.OTP_VERSION }}
164+
165+
- name: Cache deps
166+
uses: actions/cache@v5
167+
with:
168+
path: deps
169+
key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('mix.lock') }}
170+
restore-keys: |
171+
${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
172+
173+
- run: mix deps.get
174+
175+
- name: Check for vulnerable dependencies
176+
run: mix deps.audit
177+
178+
- name: Check for retired packages
179+
run: mix hex.audit
180+
181+
- name: Check for unused dependencies
182+
run: mix deps.unlock --check-unused
125183

126184
dialyzer:
127185
name: Dialyzer
@@ -168,7 +226,7 @@ jobs:
168226
publish:
169227
name: Publish to Hex
170228
runs-on: ubuntu-latest
171-
needs: [compile, format, credo, test, dialyzer]
229+
needs: [compile, format, credo, test, dialyzer, sobelow, audit]
172230
if: startsWith(github.ref, 'refs/tags/v')
173231
steps:
174232
- uses: actions/checkout@v6

.sobelow-conf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[
2+
verbose: false,
3+
private: true,
4+
skip: false,
5+
router: "",
6+
exit: "low",
7+
format: "txt",
8+
out: "",
9+
threshold: "low",
10+
# False positives for this library:
11+
# - Traversal.FileModule: compile-time DSL macros read developer-defined view paths, not user input
12+
# - DOS.StringToAtom: compile-time DSL converts developer-defined schema/URI template keys, not user input
13+
ignore: ["DOS.StringToAtom", "Traversal.FileModule"],
14+
ignore_files: ["test/"]
15+
]

lib/conduit_mcp/handler.ex

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ defmodule ConduitMcp.Handler do
5151
id = Map.get(request, "id")
5252
params = Map.get(request, "params", %{})
5353

54-
Logger.debug("Handling method: #{method}")
54+
Logger.debug("Handling method", method: method)
5555

5656
case method do
5757
"initialize" ->
@@ -91,30 +91,38 @@ defmodule ConduitMcp.Handler do
9191
handle_unsubscribe(id, params, server_module, conn)
9292

9393
_ ->
94-
Protocol.error_response(id, Protocol.method_not_found(), "Method not found: #{method}")
94+
Protocol.error_response(
95+
id,
96+
Protocol.method_not_found(),
97+
"Method not found: #{String.slice(to_string(method), 0, 200)}"
98+
)
9599
end
96100
rescue
97101
error ->
98-
Logger.error("Error handling method: #{inspect(error)}")
102+
Logger.error("Error handling method",
103+
error: Exception.message(error),
104+
method: Map.get(request, "method"),
105+
request_id: Map.get(request, "id")
106+
)
99107

100108
Protocol.error_response(
101109
Map.get(request, "id"),
102110
Protocol.internal_error(),
103-
"Internal server error: #{inspect(error)}"
111+
"Internal server error"
104112
)
105113
end
106114

107115
defp handle_notification(notification, _server_module) do
108116
method = Map.get(notification, "method")
109-
Logger.debug("Handling notification: #{method}")
117+
Logger.debug("Handling notification", method: method)
110118

111119
case method do
112120
"notifications/initialized" ->
113121
Logger.info("Client initialized")
114122
:ok
115123

116124
_ ->
117-
Logger.warning("Unknown notification: #{method}")
125+
Logger.warning("Unknown notification", method: method)
118126
:ok
119127
end
120128
end
@@ -124,8 +132,8 @@ defmodule ConduitMcp.Handler do
124132
client_info = Map.get(params, "clientInfo", %{})
125133
_capabilities = Map.get(params, "capabilities", %{})
126134

127-
Logger.info("Initializing connection with client: #{inspect(client_info)}")
128-
Logger.debug("Protocol version: #{client_version}")
135+
Logger.info("Initializing connection with client", client_info: client_info)
136+
Logger.debug("Protocol version", version: client_version)
129137

130138
negotiated_version = Protocol.negotiate_version(client_version)
131139

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule ConduitMcp.Plugs.OriginValidation do
2+
@moduledoc """
3+
Plug that validates the `Origin` request header against an allowlist.
4+
5+
Reads the allowlist from `conn.private[:allowed_origins]`. Behavior:
6+
7+
- `nil` or `"*"` — no restriction, all origins allowed
8+
- A list of strings — only those origins are allowed
9+
- OPTIONS requests always pass (CORS preflight)
10+
- Requests without an `Origin` header pass (browser-less clients don't send it)
11+
- Disallowed origins receive a 403 JSON error response
12+
"""
13+
14+
@behaviour Plug
15+
import Plug.Conn
16+
require Logger
17+
18+
@impl true
19+
def init(opts), do: opts
20+
21+
@impl true
22+
def call(conn, _opts) do
23+
allowed_origins = conn.private[:allowed_origins]
24+
25+
cond do
26+
is_nil(allowed_origins) or allowed_origins == "*" ->
27+
conn
28+
29+
conn.method == "OPTIONS" ->
30+
conn
31+
32+
true ->
33+
origin = get_req_header(conn, "origin") |> List.first()
34+
35+
cond do
36+
is_nil(origin) ->
37+
conn
38+
39+
is_list(allowed_origins) and origin in allowed_origins ->
40+
conn
41+
42+
true ->
43+
Logger.warning("Blocked request from disallowed origin", origin: origin)
44+
45+
conn
46+
|> put_resp_content_type("application/json")
47+
|> send_resp(403, JSON.encode!(%{"error" => "Origin not allowed"}))
48+
|> halt()
49+
end
50+
end
51+
end
52+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule ConduitMcp.Plugs.SecurityHeaders do
2+
@moduledoc """
3+
Plug that adds standard security response headers to all responses.
4+
5+
Sets the following headers:
6+
- `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing
7+
- `X-Frame-Options: DENY` — prevents clickjacking via iframes
8+
- `Cache-Control: no-store` — prevents caching of API responses
9+
10+
`Strict-Transport-Security` is intentionally omitted because this library
11+
may run behind a reverse proxy that handles TLS. Add it in your own plug
12+
pipeline if needed.
13+
"""
14+
15+
@behaviour Plug
16+
import Plug.Conn
17+
18+
@impl true
19+
def init(opts), do: opts
20+
21+
@impl true
22+
def call(conn, _opts) do
23+
conn
24+
|> put_resp_header("x-content-type-options", "nosniff")
25+
|> put_resp_header("x-frame-options", "DENY")
26+
|> put_resp_header("cache-control", "no-store")
27+
end
28+
end

lib/conduit_mcp/transport/sse.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ defmodule ConduitMcp.Transport.SSE do
4040
alias ConduitMcp.Handler
4141

4242
plug(Plug.Logger)
43+
plug(ConduitMcp.Plugs.SecurityHeaders)
44+
plug(ConduitMcp.Plugs.OriginValidation)
4345
plug(:add_cors_headers)
4446
plug(:match)
45-
plug(Plug.Parsers, parsers: [:json], json_decoder: JSON)
47+
plug(Plug.Parsers, parsers: [:json], json_decoder: JSON, length: 1_000_000)
4648
plug(:maybe_authenticate)
4749
plug(:maybe_rate_limit)
4850
plug(:maybe_message_rate_limit)
@@ -128,9 +130,11 @@ defmodule ConduitMcp.Transport.SSE do
128130

129131
server_name = Keyword.get(opts, :server_name) || Keyword.get(endpoint_config, :name)
130132
server_version = Keyword.get(opts, :server_version) || Keyword.get(endpoint_config, :version)
133+
allowed_origins = Keyword.get(opts, :allowed_origins)
131134

132135
conn
133136
|> Plug.Conn.put_private(:server_module, server_module)
137+
|> Plug.Conn.put_private(:allowed_origins, allowed_origins)
134138
|> Plug.Conn.put_private(:cors_origin, cors_origin)
135139
|> Plug.Conn.put_private(:cors_methods, cors_methods)
136140
|> Plug.Conn.put_private(:cors_headers, cors_headers)
@@ -183,7 +187,7 @@ defmodule ConduitMcp.Transport.SSE do
183187

184188
case conn.body_params do
185189
params when is_map(params) ->
186-
Logger.debug("Received request: #{inspect(params)}")
190+
Logger.debug("Received request", method: params["method"], id: params["id"])
187191

188192
response = Handler.handle_request(params, server_module, conn)
189193

lib/conduit_mcp/transport/streamable_http.ex

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -54,52 +54,17 @@ defmodule ConduitMcp.Transport.StreamableHTTP do
5454
alias ConduitMcp.Session
5555

5656
plug(Plug.Logger)
57-
plug(:validate_origin)
57+
plug(ConduitMcp.Plugs.SecurityHeaders)
58+
plug(ConduitMcp.Plugs.OriginValidation)
5859
plug(:add_cors_headers)
5960
plug(:match)
60-
plug(Plug.Parsers, parsers: [:json], json_decoder: JSON)
61+
plug(Plug.Parsers, parsers: [:json], json_decoder: JSON, length: 1_000_000)
6162
plug(:maybe_authenticate)
6263
plug(:maybe_rate_limit)
6364
plug(:maybe_message_rate_limit)
6465
plug(:validate_session)
6566
plug(:dispatch)
6667

67-
defp validate_origin(conn, _opts) do
68-
allowed_origins = conn.private[:allowed_origins]
69-
70-
cond do
71-
# No origin restriction configured, or explicitly set to "*"
72-
is_nil(allowed_origins) or allowed_origins == "*" ->
73-
conn
74-
75-
# OPTIONS requests skip origin validation (CORS preflight)
76-
conn.method == "OPTIONS" ->
77-
conn
78-
79-
true ->
80-
origin = get_req_header(conn, "origin") |> List.first()
81-
82-
cond do
83-
# No Origin header — allow (browser-less clients don't send it)
84-
is_nil(origin) ->
85-
conn
86-
87-
# Origin matches allowed list
88-
is_list(allowed_origins) and origin in allowed_origins ->
89-
conn
90-
91-
# Origin doesn't match
92-
true ->
93-
Logger.warning("Blocked request from disallowed origin: #{origin}")
94-
95-
conn
96-
|> put_resp_content_type("application/json")
97-
|> send_resp(403, JSON.encode!(%{"error" => "Origin not allowed"}))
98-
|> halt()
99-
end
100-
end
101-
end
102-
10368
defp add_cors_headers(conn, _opts) do
10469
# Get CORS settings from private (set in call/2)
10570
cors_origin = conn.private[:cors_origin] || "*"
@@ -285,7 +250,7 @@ defmodule ConduitMcp.Transport.StreamableHTTP do
285250

286251
case conn.body_params do
287252
params when is_map(params) ->
288-
Logger.debug("Received request: #{inspect(params)}")
253+
Logger.debug("Received request", method: params["method"], id: params["id"])
289254

290255
response = Handler.handle_request(params, server_module, conn)
291256

mix.exs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ defmodule ConduitMcp.MixProject do
7171
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
7272
{:excoveralls, "~> 0.18", only: :test, runtime: false},
7373

74+
# Security & audit
75+
{:sobelow, "~> 0.13", only: [:dev, :test], runtime: false},
76+
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
77+
78+
# Property-based testing
79+
{:stream_data, "~> 1.1", only: :test, runtime: false},
80+
7481
# Benchmarking
7582
{:benchee, "~> 1.3", only: :dev, runtime: false},
7683
{:benchee_html, "~> 1.0", only: :dev, runtime: false}

mix.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
2525
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
2626
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
27+
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
2728
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
2829
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
2930
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
@@ -33,12 +34,16 @@
3334
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
3435
"prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
3536
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
37+
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
3638
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
3739
"statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
40+
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
3841
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
3942
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
4043
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
4144
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
4245
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
4346
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
47+
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
48+
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
4449
}

0 commit comments

Comments
 (0)