Finds a struct, defined in a package's own lib/, that holds a secret-named field in
cleartext under inspect/1.
The defect this exists for
A crash of Feed or Socket printed a credential in cleartext, in OTP's own crash
report. Found live in four of five venue packages on 2026-09-07: both processes held
:credentials as a bare map for their entire lifetime — Feed to keep resigning
requests across reconnects and resubscribe sweeps, Socket to sign every authenticated
frame. OTP's default crash report prints a process's state in full on termination, and a
plain map prints every key it holds, secrets included — proven by crashing an equivalent
process holding %{api_key: "...", api_secret: "..."} as a bare state field and reading
the log back. A second path to the same leak was found the same way: a
FunctionClauseError's stacktrace prints the actual arguments a clause was called with,
so a bad call handed the same raw credential map to a function whose every clause failed
to match also printed it. Process.flag(:sensitive, true) was tried and ruled out — it
changes what :sys.get_state/1 and :dbg can see, not how a crash report or a
stacktrace is formatted.
Fixed identically in four repos, by wrapping the credential in a dedicated struct whose
Inspect implementation is derived with except: naming every secret field, at the
point the credential enters a long-lived process: dp_exchange_coinbase 4d00669
(api_key, api_secret), dp_exchange_webull 80eaf02 (app_key, app_secret,
access_token), dp_exchange_schwab 336cbd8 (access_token, refresh_token,
client_id, client_secret), dp_exchange_robinhood cfc4861 (api_key,
private_key). dp_exchange_gemini needed no fix: it signs and discards inside
stateless pipelines and holds no credential in process state at all. Four of five
independently getting this wrong is a contract problem, not four coincidences — the
same reason assertions 16, 17 and 18 exist: a defect found once in this family and
fixed only in the venue it was found in is a defect the next venue reintroduces.
What it checks, and how "redacted" is verified
For every struct defined in the package's own lib/ (a module that exports
__struct__/0, whose compiled :compile_info source path falls under lib_root), if
any of its own fields — not inherited, not a sibling module's — is named one of the
known secret names below, that field must not appear in inspect/1's rendering of a
struct instance carrying a distinctive value in exactly that field.
This is a behavioural check of the actual Inspect output, not a check for the
presence of @derive {Inspect, except: [...]}. A module is instantiated with
struct(module, [{secret_field, sentinel}]) — struct/2, not struct!/2, so
@enforce_keys on an unrelated field never blocks construction — and the rendered
inspect/1 output is searched for the sentinel. A hand-written
defimpl Inspect, for: MyStruct do ... end that never mentions @derive at all redacts
exactly as validly as the derived form every real fix in this family used, and this
check cannot tell the two apart because it never looks at the struct's source for either
— only at what the field actually prints. Every module under test is already loaded, by
construction: this check only ever runs from inside the same mix test invocation that
compiled the package being checked (via AdapterContract's generated assertion), so
struct/2 and inspect/1 here execute against the real, already-running module —
never against a fresh process, and never touching a network, the venue's own Rest or
Auth module, or anything the venue's own code would dial out through.
The secret-name list, and why each name is on it
Fifteen names, as atoms, matched against a struct field's own name exactly — never a
substring match, so a field called token_type or tokenizer_state is not caught by
token and is not meant to be:
api_key api_secret app_key app_secret secret password passphrase token
access_token refresh_token client_secret private_key signature authorization bearerTwelve of these (api_key, api_secret, secret, password, passphrase, token,
access_token, refresh_token, private_key, signature, authorization, bearer)
are DpExchange.Core.Notice's own @credential_keys — already the family's answer to
"what does a secret-shaped key look like," shipped for a different purpose (rejecting a
credential-shaped key from a Notice's :details) but the identical question: does
this name, on its own, mean "do not print this value." Reused rather than reinvented,
the same way LinkSafetyCheck reused UnwiredCheck's start_link/1,2/child_spec/1,2
exclusion rather than re-deriving it.
Three names extend that list, each because a real, shipped Credentials struct in this
family has a field by that name and Notice's list does not cover it:
app_key,app_secret—dp_exchange_webull'sCredentialsstruct (lib/dp_exchange/webull/credentials.ex). Webull's own commit message (80eaf02) is explicit thatapp_keyalone, held as a bare string onSocket(a separate field, never wrapped in this struct), is not confidential — it is sent as a plaintext header on every signed request the venue accepts. That is not a reason to leaveapp_keyoff this list: it is a reason this check only ever looks at struct fields, never at a plain map or a bare string field, so Webull's deliberately-unwrappedSocket.app_key— which is not a struct field at all — stays completely untouched by this assertion, exactly as intended, whileapp_keyinsideCredentials(where Webull's own author chose to redact it alongsideapp_secretfor consistency, even though it argued the field alone carries no confidentiality) still gets the same guarantee.app_secretis the actual HMAC-SHA1 signing key and is confidential without qualification.client_secret—dp_exchange_schwab'sCredentialsstruct (lib/dp_exchange/schwab/credentials.ex), the OAuth application secret paired withrefresh_token.
client_id is deliberately excluded, though Schwab's own struct redacts it too
(@derive {Inspect, except: [:access_token, :refresh_token, :client_id, :client_secret]}).
An OAuth client_id is, by the convention the spec itself follows, a public identifier
— it is embedded in redirect URLs and shipped inside public clients — and treating it as
inherently secret would be exactly the "too broad" failure mode that gets a check turned
off: a future venue's struct with a genuinely public client_id-shaped field would be
flagged for something that is not a leak. Schwab's own choice to redact it anyway is not
evidence the name itself is secret-shaped, only that redacting one field it did not have
to cost nothing there — and this check does not require the OPPOSITE of what a venue
chose (Schwab's own client_id redaction is still and always a pass, since this check
only asserts a lower bound on what must be hidden, never an upper one).
No struct across all five venues' actual lib/ collides with any of the twelve
Notice-derived names either — verified by reading every defstruct in all five
repos before this list was finalised, not assumed.
What this does not catch — stated so nobody over-trusts it
The original defect was a raw map, not a struct — this check would not have caught it
as it actually shipped. Feed/Socket, pre-fix, held Keyword.get(opts, :credentials)
directly in state: an opaque value handed in by the caller, never constructed as a
literal anywhere in the module's own compiled code. A struct-field check locks the fix
in; it does not, and structurally cannot, reach back to the shape of the bug before the
fix existed.
Two static, map-shaped versions of this check were considered and rejected before settling on the struct check as the enforceable maximum:
- "A process-behaviour module's compiled code contains a map literal with a
secret-named key." This does not reach the actual pre-fix defect at all — the
offending modules never constructed the credential map as a literal; they only
forwarded an externally-supplied one straight into state, so there was no map
literal in
Feed's orSocket's own source for this shape to find. It would, however, fire constantly on completely unrelated, entirely correct code: every venue'sAuthmodule builds a transient map or keyword list with a secret-named key to hand to an HTTP client or a signer (dp_exchange_coinbase'sAuth.jwt/2builds%{"sub" => api_key, ...}and pattern-matches%{api_key: api_key, api_secret: api_secret} = credentialsin its own function head, neither ever touching aGenServer's state) — a check that cannot tell "this secret is about to be signed and discarded" from "this secret is about to be stored for the process's lifetime" is not a defect signal, it is noise with the shape of one. - "A process-behaviour module reads
:credentialsfrom its start options and stores the result without first passing it through a wrapping call." Closer to the real shape, but only detectable by assuming every future fix uses a sibling module conventionally named*.Credentialswith awrap/1function — the exact naming convention four independent fixes happened to converge on, not something the contract requires. Encoding that assumption into a Core assertion is the same mistakeAdapterContract's own moduledoc already forbids in the other direction ("no assertion may name a socket, a channel string, a transport module or a polling interval" — mechanism leaking through the facade is the bug, whether the mechanism is a transport name or an implementation's own private helper-module convention).
Both fail the same test this family already applies to a candidate assertion: would it survive contact with real, correct code across all five venues without being disabled. Neither does. The struct-field check is the narrower thing that is enforceable; the raw-map shape is not, and pretending otherwise would manufacture coverage this contract does not actually have.
It also does not catch a struct assembled through apply/3 or Kernel.struct/2 at
runtime from a dynamically computed field list — no static-shaped concern applies here,
since this check already instantiates the struct at runtime rather than reading its
source, but a struct whose OWN fields are computed dynamically at the call site
(struct(Mod, some_runtime_list)) still has a fixed, statically-known field set via
Mod.__struct__/0, which is what this check actually reads — so this limitation does
not apply in practice; it is noted only for completeness.
Summary
Types
Functions
Renders run/2 violations as one line per finding, for a failure message.
Finds every struct under lib_root with a secret-named field that leaks under
inspect/1.
beam_dir is a directory of compiled .beam files (typically
Mix.Project.build_path() |> Path.join("lib/#{app}/ebin")), the same one
DpExchange.Core.LinkSafetyCheck.run/2 and DpExchange.Core.UnwiredCheck.run/3 read.
Only modules whose recorded :compile_info source path falls under lib_root are
analysed. Every module considered must already be loaded in the running node — true by
construction when this runs from inside AdapterContract's generated assertion, since
that only ever executes after mix test has compiled and loaded the package under test.