Skip to main content
Engineering Practice

A guard that cannot fail proves nothing: self-testing controls

Every serious system carries controls that are supposed to stop bad things. Almost none of them have ever been shown to stop anything. A guard is not evidence until you have planted the violation it exists to catch and watched it fire.

A clean record is not evidence

Somewhere in your build pipeline is a check that has never once said no. The PII scanner has never flagged a field. The license gate has never blocked a dependency. The prompt-injection filter has never rejected a request. Everyone reads that history as a good sign. It is the single most common shape of a broken control, because the two explanations are indistinguishable from the outside: either nothing bad has happened, or the check stopped working and nobody noticed. The log looks identical either way.

This is the default state of controls, and the reason is structural. A control that fires is loud. It blocks a merge, pages someone, holds a release. A control that silently stops firing is quiet, and quiet is what a busy team rewards. A regex rewritten during a refactor that now matches nothing, a scanner whose credentials expired six weeks ago and whose failure path returns exit 0, a policy engine pointed at a rules file that was moved: all keep reporting success. The dashboards stay green. The audit sees a passing control. The exposure is total.

The fix is one idea, and it is not complicated. A control must be able to demonstrate, on demand and in production configuration, that it rejects the thing it claims to reject. Plant a violation. Watch the guard catch it. Record that it did. If the guard does not catch the planted violation, the guard is down, and the system should say so with the same volume it would use for a real breach.

You are probably here because

  • Something reached production that a documented control was supposed to have stopped.
  • An auditor asked when a control last fired, and the honest answer was "never" or "we would have to check."
  • A model or LLM feature ships behind guardrails nobody has red-teamed since the first week.
  • Your compliance evidence is a screenshot of a passing job, and you know what that is worth.

All four come back to the same missing property. The control has never been asked to prove it works, so nobody knows whether it does.

How a guard dies without telling anyone

The failure modes are boring and repeat across every stack we have worked in.

Exit-code laundering. A shell wrapper runs a scanner, pipes the output through grep or head, and returns the exit status of the last command in the pipe. The scanner crashes, the pipe succeeds, the job passes. This one bites in every language with a shell layer, and it survives code review because the happy path is correct. The variant that gets past even careful reviewers is a command substitution inside a conditional, where a non-zero status is swallowed before anything ever inspects it.

The empty rule set. A policy engine loads rules from a path. The path changes during a directory reorganization. The engine loads zero rules, evaluates every input against zero rules, and passes everything. Open Policy Agent will happily return an empty result set for a package that does not exist. Zero violations found is the same output as zero rules loaded, and only one of those is good news.

Expired credentials on a scanner that fails open. The secrets scanner calls a service. The token expired. The client library catches the exception, logs a warning at a level nobody watches, and returns an empty finding list. Every scan after that reports clean.

The regex that stopped matching. Someone made a pattern more precise to cut false positives. The new pattern matches nothing at all. The false-positive rate goes to zero, which everybody notices and enjoys, and the true-positive rate goes to zero with it, which nobody measures.

The control that only runs on the path nobody uses. The check is wired into the pull-request workflow. Releases go out through a hotfix path that skips it. The control is real, tested, and irrelevant.

Why a silent control was silent: our ranking by frequency

Nothing bad actually happened (the control is fine)
41
Runs on a path the real work bypasses
18
Fails open on error: exit code, timeout, empty result
16
Rule set, pattern, or config loaded empty
13
Credential or dependency expired, client swallows it
8
Disabled deliberately during an incident, never restored
4

Relative weights from what we find when auditing an existing control set. Editorial ranking, not a measured population statistic.

Read the top bar carefully, because it is the trap. Most of the time the control really is fine. That is exactly why the silent-failure cases survive so long. The base rate teaches everyone to interpret silence as health.

A control that has never rejected anything is not a clean record. It is an untested claim about the future, and it costs nothing to keep making.

Plant the violation

The mechanism that fixes this is small enough to build in an afternoon per control. Every guard ships with a self-test mode that constructs an input it must reject, runs the guard against that input using the same code path production uses, and fails loudly if the guard passes it.

Three properties make it real rather than theater. The synthetic violation goes through the production path, not a test harness. If the guard is a pre-commit hook, the self-test writes a file with a fake key in it and invokes the hook binary. If it is a policy service, the self-test posts a non-compliant document to the same endpoint the application calls. A unit test that imports the matcher function and asserts it matches proves the function works; it does not prove the wiring works, and the wiring is what breaks.

The self-test covers each rule, not the guard as a whole. A scanner with forty patterns needs forty planted violations, one per pattern, because pattern nineteen is the one that got rewritten. This sounds heavy and is not. The fixtures are three lines each, and generating them is mostly mechanical.

Failure of the self-test is treated as a live outage. Not a warning, not a ticket. If the PII scanner cannot catch a planted Social Security number, the scanner is not running, and everything it was gating is currently ungated. That is the same severity as the scanner having found something real.

Cost, honestly stated: for fifteen to twenty distinct controls, two to four engineering weeks to build the self-test layer and wire the alerting, then roughly a day a quarter of maintenance. Call it $30,000 to $60,000 for the first pass at a mid-market rate. Set that against one incident where the answer to "how did this get through" is "the control had been broken since March."

Fail closed, and prove which way you fail

The self-test answers whether the guard detects. A separate question is what the guard does when it cannot run at all, and that answer is usually written by accident. A timeout, an out-of-memory kill, an unreachable service, a malformed config: each has a path through the code, and in most systems that path returns success because the developer was thinking about detection logic rather than infrastructure failure.

Decide it deliberately per control, and write the decision down. A license scanner that cannot reach its database should probably block the build; the cost of a delayed release is small. A fraud check in a payment path that cannot reach its model service probably should not block every transaction; there the answer is a degraded mode with a lower auto-approval limit and a queue for human review. Both are defensible. What is not defensible is not knowing which one your system does.

Then test that too, with fault injection rather than reasoning. Point the guard at a black-holed address and see what the pipeline does. Set the timeout to one millisecond. Hand it a config file with a syntax error. Revoke its credential in a staging environment and watch. Each of these is a ten-minute experiment, and each has, in our experience, roughly even odds of finding a fail-open path nobody intended.

Where the frameworks already ask for this

None of this is a novel demand. The requirement is scattered across the frameworks your customers already cite, usually as one clause in a long document.

NIST SP 800-53 Rev. 5 carries it in the CA family. CA-7, Continuous Monitoring, requires ongoing control-effectiveness assessment rather than a point-in-time check. CA-2 covers control assessments, CA-8 covers penetration testing, and SI-6, Security Function Verification, is the most direct of all: verify the correct operation of security functions at defined transitional states or intervals, and take a defined action when verification fails. SI-6 is a small control that almost nobody implements literally, and implementing it literally is precisely the practice described here.

For anything model-shaped, SR 11-7, the Federal Reserve and OCC guidance on model risk management, has said since 2011 that validation must include outcomes analysis and that a validation function must be independent of the model's developers. Banks live with this. Firms outside regulated finance rarely apply it, and the logic transfers cleanly: whoever built the guard should not be the only party who has ever confirmed it works.

ISO/IEC 42001 pushes the same way for AI management systems, with clauses on monitoring, measurement, and internal audit that presume evidence rather than assertion. For LLM-specific surfaces, the OWASP Top 10 for LLM Applications names prompt injection, insecure output handling, and excessive agency as the categories worth planting test cases against, and MITRE ATLAS gives a technique taxonomy concrete enough to build fixtures from. If you run a guardrail on an LLM feature, ATLAS technique identifiers make a serviceable index for what your self-test suite should cover.

SI-6 has been sitting in 800-53 for years asking systems to verify their own security functions. Almost nobody does it. It is the cheapest control assurance available.

What this looks like on an LLM guardrail

LLM guardrails are the worst current case, because they are new, they are probabilistic, and they are almost never regression-tested. A team ships a system prompt plus an output filter, red-teams it for a week before launch, and then changes the model version four times over the next year without ever rerunning the red-team set.

The self-test discipline maps onto this directly. Keep a fixture file of adversarial inputs: direct injection attempts, indirect injection through retrieved documents, jailbreak phrasings that worked historically, prompts designed to pull training data or system-prompt contents, and inputs that should trigger each content policy. Run the whole set against the live endpoint on every model change, every prompt change, every retrieval-index change. Score it, store the score, and alarm on a drop.

Two details make the difference between a real suite and a checkbox. First, guardrails are probabilistic, so a single pass is noise. Run each fixture several times and track a rate rather than a boolean, the same way you would treat any measured behavior. Second, the fixture set decays. An adversarial prompt that a model provider has since specifically trained against will pass forever and tell you nothing, so retire fixtures that have not failed anything in a year and add new ones from current research. A guardrail suite that is 100% green for six straight months is usually a suite that has stopped asking hard questions.

Evidence beats assertion in every review

There is a commercial argument here that is separate from the engineering one. When a customer's security team, an auditor, or a government sponsor asks about a control, there are two possible answers.

The first is a policy document saying the control exists. That answer starts a long conversation in which the reviewer's job is to find out whether the document reflects reality.

The second is a timestamped record showing the control was exercised against a planted violation this morning, and every morning for the past two hundred days, with the one gap in April explained by a documented credential rotation. That answer ends the conversation. Reviewers have limited time and a strong instinct for which artifacts are load-bearing. A self-test history is load-bearing in a way a policy PDF never is.

This matters most in the reviews that gate revenue: a FedRAMP package where continuous monitoring is a standing obligation rather than a one-time submission, a customer's third-party risk questionnaire, a CMMC or 800-171 assessment where the assessor is looking for evidence of practice rather than existence of policy. In all of them, the team that can show a control firing is arguing from a different position than the team that can only show a control configured.

Control typePlanted violationCadenceWhat a failed self-test means
Secret and PII scannerFixture file carrying a synthetic key, token, or identifier per patternEvery pipeline runEvery scan since the last passing self-test is unverified
Policy engine (OPA, Sentinel, admission control)A resource that violates each rule, one at a timeEvery deploy, plus dailyRules may not be loaded at all; treat every recent admission as unchecked
Access control and authorizationA synthetic principal that must be denied a named resourceDailyAuthorization may be permitting broadly; page immediately
LLM guardrailAdversarial fixture set mapped to OWASP LLM categories and ATLAS techniquesEvery model, prompt, or index changeThe safety claim behind the feature is currently unsupported
Data quality gateA record violating each declared constraint, injected upstream of the gateEvery runBad rows may already be downstream; check consumers before fixing
Model performance monitorReplay of a labeled window with injected driftWeeklyDrift detection is blind; retraining triggers cannot be trusted

Strength of evidence a reviewer will accept for "this control works"

Independent assessment against the live system
Strongest
Self-test history: planted violation, production path, dated
Strong
Log of real violations the control actually rejected
Good
Unit tests on the detection function
Partial
Screenshot of a green pipeline job
Weak
Policy document stating the control exists
Weakest

Ordering reflects how assessors and customer security teams weight artifacts in our experience. Editorial ranking, not a survey.

The guard that watches the guards

One more layer, because self-tests suffer from the identical disease. A self-test that stops running is as silent as the control it was meant to verify.

The answer is an expectation of presence rather than of absence. The monitoring system should hold a list of every control that owes it a passing self-test and on what cadence, and it should alarm when a result is missing, not only when a result is bad. Missing is the dangerous state. A failing self-test at least tells you where you stand. A missing one looks exactly like a healthy system that had nothing to report.

Practically that is a small table: control identifier, expected interval, last passing timestamp, owner. A job reads it, compares against now, and pages on anything stale. Fifty lines of code, and it turns the whole control set from a collection of independent hopes into something with a heartbeat. Keep the table in the same repository as the controls, so adding a control without registering its self-test fails review.

Where to start on Monday

Do not attempt the whole control set. Pick the three controls whose silent failure would be worst: usually the one gating customer data, the one gating production deploys, and the one gating whatever your riskiest AI feature does. For each, write one planted violation and run it through the production path by hand. Not a unit test. The real path.

The first pass through this exercise finds something roughly half the time, and what it finds is rarely subtle. A regex matching nothing. A scanner authenticating against a deleted service account. A policy bundle that has not been rebuilt in eight months. Fix what you find, then automate the three checks you just performed by hand, then extend the pattern outward one control at a time. The value shows up on the first afternoon, which is what makes the practice stick.

Bottom line

A control is a claim about what your system will refuse to do. Claims are worth what the evidence behind them is worth. The evidence is not a configuration file, a policy document, or an unbroken run of green builds. It is a record showing that when the system was handed the exact thing the control exists to reject, the control rejected it, and that this was checked recently enough to matter. Build guards that test themselves, make a missing self-test as loud as a failing one, and the question "is this control actually working" stops being a matter of faith.

Frequently asked questions

Is this the same as testing my security controls in CI?

Related but not identical. Most CI tests exercise the detection function in isolation. A self-test exercises the deployed control through the same path production traffic takes, which is where the wiring, credentials, config loading, and error handling live. Those are the parts that break.

Won't planted violations pollute my logs and alerting?

Tag synthetic events at the source and filter them from incident routing while keeping them in the audit trail. The self-test result becomes its own signal. The one thing to avoid is a filter broad enough that a real violation resembling a fixture gets suppressed, so match on an explicit marker rather than on content.

How often should a self-test run?

Tie it to change rather than to the calendar where you can. Controls in a build pipeline should self-test every run, since that is free. Controls on always-on services need a daily floor plus a run on every deploy or config change. Guardrails on model-backed features should self-test on every model or prompt version change without exception.

Does this satisfy an auditor?

It is the strongest evidence class available short of an independent assessment, and it maps directly to NIST 800-53 CA-7 and SI-6. Assessors respond to it well because it shows operating effectiveness over time rather than design at a point in time. It does not replace an independent assessment where one is required.

What does it cost to retrofit onto an existing system?

For fifteen to twenty controls, two to four engineering weeks for the first pass including alerting and the registry, then about a day a quarter to maintain. Adversarial fixture suites for LLM features cost more to keep current, since the fixture set has to be refreshed as models change.

1 business day response

Want to know whether your controls actually fire?

We build self-testing control layers: planted violations through the production path, a registry that alarms on a missing result, and an evidence trail that ends the conversation with your customer's security team.

Talk to an engineerMore insights → or email bo@precisionfederal.com
UEI Y2JVCZXT9HP5CAGE 1AYQ0NAICS 541512SAM.GOV ACTIVE