Skip to main content

Command Palette

Search for a command to run...

Debugging a False-Positive Hex Advisory in an Elixir CI Pipeline

Updated
7 min readView as Markdown
Debugging a False-Positive Hex Advisory in an Elixir CI Pipeline
A
I am a Software Engineer from Mumbai, India. In love with Functional programming ( precisely Elixir). Love to share the knowledge that I learn while developing things.

Introduction

Recently, while working on a personal project in Elixir/Phoenix, I was performing what looked like a routine dependency update in an Elixir/Phoenix project:

mix deps.update mint

The command was about Mint, but Hex surfaced a security advisory for a different package: Decimal 3.1.1.

Our CI pipeline then ran its dependency audit:

mix hex.audit

The audit reported CVE-2026-32686 against Decimal 3.1.1 and returned a non-zero exit code. CI stopped.

At first, the obvious interpretation was that our project contained a vulnerable Decimal release. That interpretation turned out to be wrong, but CI was still right to stop it.

Two commands, two different responsibilities

The first detail I had to understand was that mix deps.update mint and mix hex.audit were doing different jobs.

Mix documents that a targeted update unlocks the named dependency and the projects it depends on. But since Hex 2.5, dependency resolution commands also display advisories associated with resolved packages. Those warnings are informational; they do not fail the command by default.

mix hex.audit is different. It examines the complete lockfile and exits with a non-zero status when a locked package has an active advisory. Hex explicitly describes it as suitable for CI. Hex 2.5 announcement.

That means the Mint update did not necessarily introduce the Decimal problem. It merely caused Hex to inspect the resolved dependency set and surface the advisory. The audit then independently evaluated the complete lockfile and enforced our CI policy.

One repository-specific detail still needs the original lockfile diff to determine whether the Mint update changed Decimal at all, or whether Decimal 3.1.1 was already present. I do not want to invent that part of the story.

Was Decimal 3.1.1 actually vulnerable?

No.

The vulnerability itself was real. Older Decimal versions accepted numbers with enormous exponents. An untrusted decimal with an extremely large exponent could later cause arithmetic, formatting, rounding, or integer conversion to allocate huge amounts of memory. In an exposed application, that could exhaust memory and crash the BEAM VM.

The authoritative affected range is:

Decimal >= 0.1.0 and < 3.0.0
Fixed in Decimal 3.0.0

Decimal 3.0.0 introduced bounded defaults based on IEEE 754 decimal128 limits. The change is documented in the 3.0.0 release notes and its fix commit.

Our installed version, 3.1.1, was newer than the fixed boundary. The current EEF CNA record, OSV entry, and GitHub advisory all classify versions from 3.0.0 onward as unaffected.

So why did the scanner report 3.1.1?

A machine-readable contradiction

Security scanners usually do not inspect a dependency’s source code and independently prove that a vulnerability exists. They consume structured advisory data.

In this case, the data travelled roughly like this:

  1. The vulnerability was published as a CVE and GitHub security advisory.

  2. The Erlang Ecosystem Foundation published a machine-readable record to OSV.

  3. Hex imported the OSV data and associated its affected ranges with package releases.

  4. The Hex client compared those releases with our mix.lock.

  5. mix hex.audit returned a failure when it found a match.

The original CVE record contained two conflicting ideas.

It declared an explicit vulnerable range ending before 3.0.0, but it also set the record’s defaultStatus to affected. In machine-readable terms, that default could mean that every version outside the explicitly described range was affected too—including 3.1.1.

According to the EEF maintainer’s explanation of the incident, the error had previously remained hidden because their tooling did not support defaultStatus. A newer tooling release began honoring the field and consequently classified every version as affected.

The scanner’s result was wrong for our installed version, but it had not randomly invented the result. It had faithfully processed incorrect metadata.

On September 8, 2026, the record was corrected from text defaultStatus: affected to defaultStatus: unaffected. The public correction diff records that change.

Why I did not bypass CI immediately

A false positive is frustrating, especially when the installed version appears newer than the published fix. But CI did not have enough context to make that judgment.

Its job was simple: if a locked dependency was identified as vulnerable, stop and require investigation.

That is exactly what happened.

If the pipeline had silently continued whenever an advisory looked inconvenient, a future genuine vulnerability could have crossed the same boundary. Security checks are useful partly because they interrupt normal work and force an explicit decision.

The correct response was not “the scanner must be right,” but it was also not “the scanner is obviously wrong.” The correct response was to verify:

  • the version in mix.lock;

  • the advisory’s affected range;

  • the upstream fix and release notes;

  • the application’s exposure;

  • and whether the different advisory sources agreed.

The temporary exception

After confirming that Decimal 3.1.1 was outside the vulnerable range, we temporarily acknowledged the advisory in mix.exs:

hex: [
  ignore_advisories: ["CVE-2026-32686"]
]

Hex 2.5.1 introduced this configuration for advisories that do not affect a project or cannot yet be acted upon. Ignored findings do not make mix hex.audit exit unsuccessfully. Hex audit documentation.

This was a targeted exception, not a declaration that security advisories were unimportant. We continued running the audit and suppressed only the advisory we had investigated.

Even so, the exception carried risk.

ignore_advisories is scoped to an advisory identifier, not to a particular Decimal version. If a later dependency change had downgraded Decimal to a genuinely vulnerable 2.x release, the same exception could have hidden the finding. The ID can also match advisory aliases, so acknowledging the CVE may suppress the corresponding EEF and GHSA forms.

That is why an exception should have evidence, an owner, a removal condition, and, where practical, a constraint preventing an affected version from entering the lockfile.

What else could we have done?

Ignoring the advisory was not the only possible response.

We could have kept the branch blocked until the upstream record was corrected. We could have reported the contradictory metadata and waited for the corrected feed to reach Hex. If our project had been using Decimal below 3.0.0, the right response would have been to upgrade or add a compatible version constraint.

For a real vulnerability without an immediately usable update, another option would have been to remove or isolate the exposed code path, validate untrusted input before it reached Decimal, and document a formal risk acceptance.

What would not have helped was repeatedly updating an already-unaffected Decimal 3.1.1. The package version was not the problem; the advisory metadata was.

Removing the exception was part of the fix

Once the record was corrected, we removed the exception and reran the audit.

That removal mattered. A temporary security exception that remains indefinitely becomes a permanent blind spot. Hex even warns about ignore entries that no longer match a current finding, which is a useful prompt to clean them up.

Our removal condition was straightforward:

  1. The authoritative record had to classify 3.1.1 correctly.

  2. Our lockfile still had to contain an unaffected Decimal version.

  3. mix hex.audit had to pass without the exception.

After those conditions were satisfied, the exception no longer served a purpose.

What I learned

The biggest lesson was that a vulnerability finding is the start of an investigation, not the final verdict.

Machine-readable security data is enormously useful, but it can contain the same mistakes as any other software artifact. A single field can move through a CNA record, an OSV feed, a package registry and a CI scanner before appearing as a failed build.

The second lesson was that targeted dependency commands can reveal findings elsewhere in the resolved dependency graph. mix deps.update mint surfacing a Decimal advisory did not make it a Mint vulnerability.

The third lesson was that exceptions need lifecycle management. If an advisory must be acknowledged, the exception should be narrow, documented, reviewed and removed as soon as the reason for it disappears.

Finally, a failed CI run is not proof that a vulnerability exists in the installed code. It is proof that a security boundary detected something requiring a decision.

In this case, the scanner’s conclusion was wrong. CI was still right to stop.

If you have any questions, please leave a comment below. Thanks for reading 😊.