Skip to content

Stage-1 consistency corrections are shadowed by a stale finding, so a corrected VULNERABLE is never verified and never disclosed #331

Description

@gadievron

Summary

When the Stage-1 consistency pass corrects a unit's verdict it writes result["verdict"] and does not
write result["finding"]. Ingestion has already set a lowercase finding, and the canonical
downstream read is finding-first — str(r.get("finding") or r.get("verdict", "")).lower() — so the
stale finding short-circuits the or and the correction is invisible.

A safe → VULNERABLE correction leaves {finding: "safe", verdict: "VULNERABLE"}. The unit is counted
safe, filtered out of Stage 2 entirely, and absent from the disclosure document.

There is a second edge, and it points the other way. Because the stale finding also survives a
downgrade to a verdict the block-list does not cover, it is currently acting as an accidental safety
net. The obvious one-line fix removes that net and introduces a different false negative. The fix
below is gated accordingly.

The lost write

libs/openant-core/utilities/stage1_consistency.py:297:

297|                            if old_verdict_norm != new_verdict:
298|                                result["verdict"] = new_verdict
299|                                result["stage1_consistency_update"] = {

/usr/bin/grep -nE '\["(verdict|finding)"\] *=' libs/openant-core/utilities/stage1_consistency.py
returns one line — :298. The file writes verdict once and finding never.

Ingestion guarantees the shadowing value. libs/openant-core/core/analyzer.py:149:

149|        if result.get("finding"):
150|            result["finding"] = str(result["finding"]).lower()
151|        elif result.get("verdict"):
152|            result["finding"] = str(result["verdict"]).lower()

Upstream of that, libs/openant-core/core/analysis_core.py:33-56 (_normalize_result) synthesises
verdict from finding, so both keys are present and agreeing before the consistency pass runs — and
that pass runs later, at libs/openant-core/core/analyzer.py:686.

Where the stale value is read

consumer effect
libs/openant-core/core/verifier.py:107 — Stage-2 input gate the corrected unit is not in vulnerable_results, so Stage 2 does not verify it at all
libs/openant-core/core/verifier.py:351-356confirmed_findings same canonical read; the unit is absent on the Stage-2 path
libs/openant-core/core/reporter.py:313-316 the fallback filter, used when confirmed_findings is absent (:307-308) — same outcome when Stage 2 did not run
libs/openant-core/core/analyzer.py:331_count_verdicts tallies it as safe

_count_verdicts is worth separating: its expression is
r.get("finding", r.get("verdict", "error").lower()) — a default-on-absent, not an or-on-falsy.
It reaches the same wrong answer here by a different route, so a fix targeting only the or idiom
would miss it.

Executed at b501962

Harness attached in the comment below; it reads only and makes no API calls.

A. _count_verdicts (core/analyzer.py:320)
   corrected-only  -> vulnerable=0  safe=1
   CONTROL vuln    -> vulnerable=1  safe=0
   CONTROL safe    -> vulnerable=0  safe=1
   mixed           -> vulnerable=1  safe=2

B. canonical finding-first read (core/reporter.py:315)
   corrected (finding=safe, verdict=VULNERABLE) -> 'safe'  in confirmed? False
   CONTROL   (finding=vulnerable)              -> 'vulnerable'  in confirmed? True

C. writes in utilities/stage1_consistency.py
   result["verdict"] : 1
   result["finding"] : 0

CONTROL vuln is the discriminating control: the same function, on a row whose finding agrees with
its verdict, counts correctly. The zero in A is an effect of the disagreement, not a broken harness.

An independent reviewer drove the real run_stage1_consistency_check with a stubbed model call
and confirmed the emitted shape is {'finding': 'safe', 'verdict': 'VULNERABLE'} — i.e. the rows
above are what the pass actually produces, not only what it is read to produce.

The negation, executed

The claim fails if anything re-derives finding after the pass. Enumerated (unedited output):

$ /usr/bin/grep -rn '\["finding"\] *=' --include="*.py" libs/openant-core | grep -v /tests/
libs/openant-core/core/analyzer.py:150:            result["finding"] = str(result["finding"]).lower()
libs/openant-core/core/analyzer.py:152:            result["finding"] = str(result["verdict"]).lower()
libs/openant-core/core/verifier.py:169:    # checkpoint and ``finding_verifier.py`` ``r["finding"] = cp_data["finding"]``
libs/openant-core/utilities/json_corrector.py:251:                    extracted["finding"] = extracted["finding"].lower()
libs/openant-core/utilities/finding_verifier.py:434:                # sets result["finding"] = correct_finding, and the report
libs/openant-core/utilities/finding_verifier.py:624:                    r["finding"] = cp_data["finding"]
libs/openant-core/utilities/finding_verifier.py:747:                result["finding"] = verification.correct_finding
libs/openant-core/utilities/finding_verifier.py:918:                                result["finding"] = new_verdict
libs/openant-core/experiment.py:558:                    r["finding"] = r["verdict"].lower()
libs/openant-core/experiment.py:595:                        result["finding"] = verification.correct_finding

Two are comments (:169, :434). The analyzer.py pair runs at ingestion, before the pass. The
finding_verifier.py writes are Stage 2 — which the corrected unit does not reach, per
libs/openant-core/core/verifier.py:107. experiment.py is not the production pipeline. No production
write re-derives finding between the pass and the consumers.

The naive fix is unsafe — measured

The obvious fix is to write both keys at :298. Do not ship that. The downgrade guard at
libs/openant-core/utilities/stage1_consistency.py:287 blocks only
old ∈ {VULNERABLE, BYPASSABLE} AND new ∈ DISCLOSURE_DROPPED, and DISCLOSURE_DROPPED is
{inconclusive, protected, rejected, safe} (libs/openant-core/core/verdict_taxonomy.py:86-91).
new_verdict is unvalidated model output — the code's own comment at :281 says so, and
_resolve_stage1_inconsistency (:334-342) json.loads it with no enum check.

Executed:

DISCLOSURE_DROPPED = ['inconclusive', 'protected', 'rejected', 'safe']

  VULNERABLE -> INSUFFICIENT_CONTEXT   blocked_at_287=False
      today     canonical='vulnerable'             in confirmed? True
      UNDER FIX canonical='insufficient_context'   in confirmed? False   <-- REMOVED
  VULNERABLE -> NOT_VULNERABLE         blocked_at_287=False
      today     canonical='vulnerable'             in confirmed? True
      UNDER FIX canonical='not_vulnerable'         in confirmed? False   <-- REMOVED
  VULNERABLE -> SAFE                   blocked_at_287=True

INSUFFICIENT_CONTEXT is not hypothetical — it is a first-class verdict in _normalize_result's map
at libs/openant-core/core/analysis_core.py:55, and it is not in DISCLOSURE_DROPPED. So the stale
finding is presently masking unrecognised-verdict downgrades, and an ungated write of finding
would drop a disclosed vulnerability in the two cases measured above.

Suggested fix

Gate the write on the new verdict being disclosure-eligible, so the correction lands for upgrades
without removing the accidental protection for unrecognised downgrades:

                            if old_verdict_norm != new_verdict:
                                result["verdict"] = new_verdict
                                # Canonical downstream reads are finding-first, and ingestion
                                # always sets `finding` (core/analyzer.py:149-152), so writing
                                # only `verdict` leaves the correction shadowed. Gate on
                                # disclosure-eligibility: `new_verdict` is unvalidated model
                                # output (see the comment at :281), and writing an unrecognised
                                # value here would drop a disclosed finding.
                                if str(new_verdict).lower() in DISCLOSURE_ELIGIBLE:
                                    result["finding"] = str(new_verdict).lower()

The alternative — widen :287's block-set from DISCLOSURE_DROPPED to "anything not
disclosure-eligible", then write both keys without the gate — is the more complete fix, and it is the
open question PR #245 deferred. Either way _count_verdicts at core/analyzer.py:331 should be
brought onto the same canonical read.

Regression tests should assert (1) after a safe → VULNERABLE correction the unit is in
_count_verdicts' vulnerable bucket and in confirmed_findings, and (2) after a
VULNERABLE → INSUFFICIENT_CONTEXT correction it is still disclosed. I found no existing test
covering a correction whose finding and verdict disagree.

Prior art, checked

libs/openant-core/tests/test_verifier_verdictonly_confirmed_drop.py exists and passes at b501962
(3 passed). Its subject is a result with no finding key, fixed by adding or r.get("verdict")
fallbacks. Those are not reached here: the or short-circuits on a present-but-stale value, as the executed
runs above show.
_resolve_stage1_finding's docstring states the absent-key intent explicitly at
libs/openant-core/utilities/finding_verifier.py:209-211, and the same assumption appears at
libs/openant-core/experiment.py:557 (if finding is None).

PRs #193, #195, #243 and #245 all touch this function and all left :298 writing one key.

Relation to existing issues

What I am not claiming

  • Not that a specific vulnerability was missed in a real scan. The evidence is the production sinks
    and the canonical reads executed at b501962, plus an independent drive of the real consistency
    pass with a stubbed model call. No end-to-end CLI run against a real repository was performed.
  • Not that every unit is affected — only units the consistency pass actually corrects. I did not
    measure how often it fires on a real corpus.
  • finding is present only for units arriving with either key; core/analyzer.py:149-152
    sets nothing when both are absent, which is A Stage-1 result with neither verdict nor finding is counted in no bucket and adopted as complete on resume, so units_analyzed overstates and the unit is never retried #324's case and where this mechanism does not fire.
  • The suggested fix is reasoned from the executed downgrade table above; I have not run the gated
    variant against the full suite.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions