Security

The Safety Gate That Passed Everything, Including a 404

A content filter reported clean for months. It was reading nothing. Four ways a check silently inverts into an approval, and how to make failure loud.

A publishing pipeline had a check that ran after every deploy: fetch the published page, grep it for forbidden terms, and retract the post if anything matched. It reported clean every time. It was also, for its entire life, incapable of reporting anything else. The page fetch had been silently returning nothing, and grep against nothing matches nothing, and no match was wired to mean "safe."

The bug is not the fetch. The bug is that "found no problems" and "could not look" produced the same output, and only one of them was true.

The shape of a gate that inverts

A check has three possible outcomes and most implementations only encode two. It can find a problem, find no problem, or fail to look. The third one is where safety leaks out, because the easiest way to write the check makes "failed to look" indistinguishable from "found no problem."

Consider the shell version, which is how these usually start:

curl -s "$URL" | grep -iEf patterns.txt && echo "BLOCKED"

Every failure in that line resolves to silence. A 404 gives curl an empty body and exit 0 because -s only silences progress. A DNS failure gives an empty body. An empty pattern file makes grep match nothing. A pipeline exit status reflects only the last command, so curl failing entirely still leaves grep to report "no match." Four different breakages, one indistinguishable outcome, and that outcome is the one that lets the post through.

The third outcome most gates forget match no match no answer Found it Clean No answer Block Publish Block // most gates // send this row up

This shape is not specific to content filters. A health check that treats a connection error as "no errors reported." A linter invoked on a glob that matches nothing. A security scanner whose ruleset failed to download. In each case the machinery is fine, the report is green, and the green means the opposite of what the dashboard implies.

Four ways the same check failed

The check in question failed in four independent ways, which is worth stating plainly because each one alone would have been enough, and each one alone would have been hard to notice.

The check was reading a different document

The original check fetched pages through a helper that converted HTML to markdown before returning it. That was convenient for reading and fatal for matching, because the patterns were written against markup. Attributes, meta tags, and structured data blocks do not survive the conversion. The check ran, found nothing, and had never seen the fields most likely to carry a stray identifier.

The lesson generalizes past this one helper. A filter is written against a representation. If anything between the source and the filter rewrites that representation, the filter is now checking a document that does not exist anywhere in production. Fetch the bytes your users get.

The noise was so loud nobody read the signal

The same check reported seven or eight matches on every page, always the same ones: a publisher ID embedded in the ad script, present on every page of the site by design.

A check that cries wolf on every run stops being read. Worse, it trains the operator to add a blanket suppression, and blanket suppressions are where real matches go to die.

The instinct is to narrow the search area to the article body. That was measured and it was wrong in the more dangerous direction. Ads render inside the article element too, so narrowing dropped only half the false positives, while newly excluding the title, meta description, and structured data, which are derived from the author's own words and are exactly where an identifier would land.

The fix is to remove noise rather than to shrink scope. Strip the script, style, iframe, and noscript blocks, unwrap ad container tags without deleting their text, and keep the structured data by extracting it before the script removal and appending it back afterward. Measured on the same pages: seven matches before, four with the narrowed scope, zero with noise removal and full coverage.

The pattern file was allowed to be empty

If the denylist fails to load, the pattern set is empty, and an empty pattern set matches nothing. That is the same "clean" as a genuinely clean page.

This one is cheap to close and almost never done:

PAT="$(grep -vE '^\s*#|^\s*$' denylist.txt)"
[ -n "$PAT" ] || { echo "empty denylist, cannot verify"; exit 1; }

Any input the check depends on deserves the same treatment. If the thing that defines "bad" can go missing, its absence must be an error and not a verdict.

Nobody ever tested that the gate could fail

The deepest problem is that none of this was hypothetical. It was discoverable in about a minute by pointing the check at a URL that does not exist and observing that it reported clean.

Gates get tested in the passing direction. Someone writes a post, runs the check, sees it pass, and ships. The failing direction is rarely exercised because producing a genuine violation feels like work and slightly dangerous. So the branch that matters, the one that must stop a release, is the only branch that never runs.

There is a reason this is systematic rather than careless. Producing a genuine violation to test with means writing text that contains a real secret, which nobody wants to commit. Pointing the check at a broken target means deliberately breaking something. Both feel like manufacturing a problem, and the check appears to work, so the work gets deferred forever.

Two habits fix this permanently, and both avoid the discomfort.

Test the gate against a known-bad fixture in CI. A small file containing one term from your denylist, plus an assertion that the check exits non-zero on it. The fixture never ships, never touches a real page, and fails loudly if someone breaks the pattern loading or the exit code. If committing a real term bothers you, use a sentinel entry that exists in the denylist purely for this purpose.

Test the gate against a known-unreachable target. Point it at a URL guaranteed to 404 and assert it reports failure, not cleanliness. That one assertion would have caught the original bug on day one, and it takes a single line.

Both tests share a property worth naming: they exercise the gate without any real content. The reason failing-direction tests get skipped is usually that people imagine needing a realistic violation, and a fixture plus a bad URL removes that requirement entirely.

Making the failure loud

Rewriting the check to separate the three outcomes is mostly about refusing to let a pipeline collapse them:

BAD=0
for L in "${LANGS[@]}"; do
  HTML="$(curl -fsSL "$BASE/$L/$SLUG")" \
    || { echo "fetch failed: $L" >&2; BAD=1; continue; }
  [ -n "$HTML" ] || { echo "empty body: $L" >&2; BAD=1; continue; }
  CLEANED="$(printf '%s' "$HTML" | strip_noise)" \
    || { echo "cleanup failed: $L" >&2; BAD=1; continue; }
  if printf '%s\n' "$CLEANED" | grep -qiEf <(printf '%s\n' "$PAT"); then
    echo "match: $L" >&2; BAD=1
  fi
done
[ "$BAD" -eq 0 ] || retract

Every step that can fail assigns the same failure variable that a real match assigns. curl -f makes an HTTP error an exit code rather than an empty body. Each stage runs in its own command substitution, so a failure is catchable instead of being swallowed by a pipeline exit status. There is no path from "something went wrong" to "clean."

The general rule that falls out of this: a check should return "verified safe," never "found no evidence of harm." Those sound alike and behave in opposite ways when the machinery breaks. The first requires the check to have actually run, looked at the right document, and had a definition of bad to compare against. The second is what an empty string gives you, for free, forever.

One caveat about fail-closed, since it is not free. A gate that blocks on every transient error will block on network blips, and if blocking is expensive the team will route around it. Two things keep that manageable. Retry the transient parts, such as the fetch, before declaring failure, so a single dropped connection does not stop a release. And make the blocked state cheap to recover from by preserving the work and reporting exactly which stage failed. Fail-closed is sustainable when the cost of a false block is a minute of confusion, and unsustainable when it is an hour of reconstruction.

A short audit you can run today

Take any automated gate you own and ask four questions.

What does it do if the thing it inspects is missing or unreachable? If the answer is "passes," you have this bug.

Is it inspecting the same representation your users receive, or something a helper reformatted along the way?

What happens if its configuration, denylist, or ruleset fails to load? Empty rules must be an error.

When did it last fail on purpose? If the answer is "never," you do not know that it can.

The pipeline in this story now fails on all four counts in the safe direction, and it cost less than an afternoon. The expensive part was not the fix. It was the months of green checkmarks that meant nothing.

Related posts