Skip to content

fix(triage): fail safe on code/SAST findings — never a speculative real + cut 0.2.2#277

Merged
galanko merged 2 commits into
mainfrom
fix/triage-code-finding-failsafe
Jun 15, 2026
Merged

fix(triage): fail safe on code/SAST findings — never a speculative real + cut 0.2.2#277
galanko merged 2 commits into
mainfrom
fix/triage-code-finding-failsafe

Conversation

@galanko

@galanko galanko commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Re-auditing the fixed Cliff (PR #276) on a SAST-heavy repo (Mealie) found the code/SAST triage path shipping fake CRITICAL real verdicts. Two of five noise findings came back real 0.82, exploitable: yes with an empty reachability path.

Root cause (corrected from the round-3 hand-off): the bug is not in the Deep dive — that never ran here (provenance: null, empty repo_url on every workspace). It's in the Quick-read synthesizer:

  1. The exposure_analyzer reasons about a code finding from the file path, never opening the file, and hedged: "Likely reachable … the exact call chain needs verification to confirm" + internet_facing=1.
  2. _classify_reachable matched the affirmative keywords (likely/reachable) → "yes".
  3. The dependency-shaped projection (reached=yes + internet-facing → real, _CONF_REAL=0.82) fired. That projection assumes a CVE/advisory model that does not hold for code findings (the enricher always abstains — there is no CVE), so it never should have been applied to them.

Fix (4 fail-safes, all upholding ADR-0051 §10 "never false-clear, never false-confirm")

  • Hedge-aware reachabilitylikely / suggests / appears / needs verification / may / could classify as undetermined, never a confident reachable. likely removed from the affirmative set (a probability ≠ a confirmation). Protects dependency findings from the same promotion.
  • Code/SAST findings defersynthesize_triage gains finding_type; a code finding can't be confidently cleared or confirmed from the Quick read (it never opens the file:line), so it returns needs_review, which auto-escalates to the file-reading Deep dive (decide_escalation). When no Deep dive can run, the honest "needs your review" stands rather than a fabricated verdict. dependency/posture keep the existing projection.
  • Triage never strands — a failed prerequisite (e.g. the exposure_analyzer timing out on the deploy-time alembic migration) degrades to needs_review instead of aborting with no verdict. The prior behavior left the CLI to poll-timeout and exit 1 (the migration "crash"). Triage always lands a verdict.
  • Deep-dive grounded-path guard — a trace_path reached=yes with no file:line hop is ungrounded speculation → needs_review, not a confident real (symmetric to the disproof challenge on the clearing side). Defense-in-depth for when the Deep dive does run.

Plus the 0.2.2 release (VERSION + backend + cliffsec CLI + CHANGELOG) so the triage-first cliffsec fix flow from #276 reaches installed users. (Tagging v0.2.2 + the release-environment approval is the maintainer step.)

Verification — zero real on all 5 Mealie noise findings

finding type before after
recipe.py:350 code real 0.82 needs_review
backups/alchemy_exporter:47 code real 0.82 needs_review
reprocess_images:63 code false_positive needs_review
alembic migration code crash, exit 1 ⚠️ needs_review
esbuild@0.27.7 dependency needs_review needs_review
  • Deterministic — ran the new synthesizer against the recorded enricher+exposure outputs that produced the false positives → zero real.
  • Live end-to-end — re-triaged all 5 through the daemon running this branch → all persist needs_review.

Test plan

  • cd backend && uv run ruff check cliff/ tests/ → clean
  • cd backend && uv run pytest -m 'not e2e'1675 passed, 14 skipped (the 1 failure on a re-run is a flaky live-LLM eval on the untouched report path — skips keyless)
  • cd cli && uv run --extra dev pytest99 passed
  • New/updated tests: synthesizer hedge + code-deferral + dependency-unaffected; runner degrade-on-failure + finding.type threading; deep-dive ungrounded-reached=yes guard.

Docs (private cliff-os)

ADR-0051 §3 amended (speculation never confirms; code findings defer; prerequisite-failure degrade) and ADR-0052 §6 (grounded-path failure mode).

Follow-up (out of scope)

cliffsec fix polls the sidebar for any verdict after POSTing /triage, so re-running on an already-triaged finding can read the stale verdict before the background re-triage overwrites it. Fresh-scan findings (the report tour) are unaffected. Worth a separate fix.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Made scanner triage more reliable: it now always produces a verdict, degrading gracefully when prerequisites only partially complete.
    • Improved reachability triage safety: speculative/hedged reachability no longer produces confident “real” outcomes and is routed to manual review.
    • Added protection for ungrounded trace results: deep dive stops early and marks the case for review when no concrete file+line evidence is available.
    • Extended the same conservative deferral behavior to code and secret findings.
  • Tests

    • Added coverage for hedged reachability, partial prerequisite runs, and ungrounded trace handling.

…eal` (ADR-0051/0052)

The Quick-read synthesizer shipped fake CRITICAL `real` verdicts on SAST/code
findings. Root cause (Mealie round-3 audit): the exposure analyzer reasons about
a code finding from the file PATH, not the file's code, and hedges ("Likely
reachable … needs verification to confirm") — but `_classify_reachable` matched
the affirmative keywords ("likely"/"reachable") → "yes", and with
`internet_facing=true` the dependency-shaped projection emitted `real` 0.82. The
Deep dive that would open the file:line never ran (no repo profile/clone), so the
speculation shipped as the final verdict.

Four fail-safes, all upholding ADR-0051 §10 (never false-clear, never
false-confirm):

- Hedge-aware reachability: "likely / suggests / appears / needs verification"
  classify as undetermined, never a confident reachable. `likely` is a
  probability, not a confirmation — removed from the affirmative set.
- Code/SAST findings defer: `synthesize_triage` gains `finding_type`; a `code`
  finding can't be confidently cleared OR confirmed from the Quick read (it
  never opens the file), so it returns `needs_review`, which auto-escalates to
  the file-reading Deep dive. Dependency/posture keep the existing projection.
- Triage never strands: a failed prerequisite (e.g. the exposure agent timing
  out on a deploy-time migration file) degrades to `needs_review` instead of
  aborting with no verdict — the prior behavior left the CLI to poll-timeout and
  exit 1. Triage always lands a verdict; never a silent clear, never a crash.
- Deep-dive grounded-path guard: a `trace_path` `reached=yes` with no file:line
  hop is ungrounded speculation → `needs_review`, not a confident `real`
  (symmetric to the disproof challenge on the clearing side).

Verified: all 5 Mealie noise findings now triage to `needs_review` (zero `real`),
both deterministically against the recorded exposure inputs and live end-to-end
through the daemon. The two prior `real` false positives and the migration
crash are gone.

Also cuts the 0.2.2 release (VERSION + backend + cliffsec CLI + CHANGELOG) so the
triage-first `cliffsec fix` flow (#276) reaches installed users.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Three triage fail-safes are added for v0.2.2: hedged reachability language (e.g., "likely", "may need verification") in exposure analysis is now classified as unknown instead of affirmative; code/SAST findings always defer to needs_review from quick-read via a new _deferred_quick_verdict path; prerequisite agent failures degrade to a persisted needs_review verdict instead of aborting; and deep-dive trace results with reached=yes but no grounded file:line hop route to needs_review. All changes are version-bumped to 0.2.2.

Changes

Triage Hardening Logic and Tests

Layer / File(s) Summary
Hedge detection and code finding deferral in triage synthesizer
backend/cliff/agents/runtime/triage_synthesizer.py, backend/tests/agents/test_triage_synthesizer.py
Adds _REACH_HEDGE keyword set so speculative reachability text classifies as unknown; adds finding_type parameter to synthesize_triage; adds _deferred_quick_verdict helper returning needs_review for code/secret findings; expands module docs; tests cover hedged phrasings, code/secret finding deferral, and non-regression for dependency findings.
Scanner triage runner prereq-failure degradation
backend/cliff/agents/triage_runner.py, backend/tests/agents/test_triage_runner.py
Removes _TERMINAL_FAILURE_STATUSES; introduces prereq_failed flag; on enricher/exposure non-completion, synthesizes and persists a quick needs_review verdict with finding_type and returns early instead of aborting with None; updates run_triage docstring; tightens report triage completion check; test scaffolding supports configurable finding type and prerequisite statuses; tests assert degraded verdict persists and code finding produces deferral.
Deep-dive runner ungrounded trace path guard
backend/cliff/agents/triage_deep/runner.py, backend/tests/agents/test_deep_dive_runner.py
Adds _has_grounded_path helper; inserts post-trace guard routing to needs_review when reached=yes but no hop has a non-empty file and positive line; tests add empty-path and file-without-line cases; fixes existing challenge-downgrade test to supply grounded hop.

Version Bump to 0.2.2

Layer / File(s) Summary
Version bump and changelog entry
VERSION, backend/pyproject.toml, cli/pyproject.toml, cli/cliff_cli/__init__.py, CHANGELOG.md
Bumps all version fields from 0.2.1 to 0.2.2 and adds the v0.2.2 changelog section with three Fixed entries describing hedge detection, code finding deferral, prerequisite failure degradation, and ungrounded trace routing.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • cliff-security/cliff#276: This PR's altered verdict semantics (downgrading speculative results to needs_review instead of real) directly affect the planner gating logic changed in #276, which conditions /pipeline/run-all and cliffsec fix planning on whether the sidebar verdict is non-real.

Poem

🐇 A bunny reviews each triage call,
No "likely reachable" slips through at all!
Code findings defer, prerequisites degrade,
Ungrounded paths? needs_review is made.
No fake real verdicts — the warren's secure,
With hedges and guards, our triage is pure! 🔒

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: implementing fail-safes for code/SAST triage (preventing speculative 'real' verdicts) and releasing version 0.2.2.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/triage-code-finding-failsafe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

755-760: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Update CHANGELOG.md reference links for v0.2.2 release.

The bottom-of-file reference links are stale and incomplete. The [Unreleased] link must point from v0.2.2 (not v0.2.1) to HEAD; a new [0.2.2] link is missing.

🔗 Proposed fix to update reference links
-[Unreleased]: https://github.com/cliff-security/cliff/compare/v0.2.1...HEAD
+[Unreleased]: https://github.com/cliff-security/cliff/compare/v0.2.2...HEAD
+[0.2.2]: https://github.com/cliff-security/cliff/releases/tag/v0.2.2
 [0.2.1]: https://github.com/cliff-security/cliff/releases/tag/v0.2.1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 755 - 760, Update the reference links at the
bottom of CHANGELOG.md to reflect the v0.2.2 release. Modify the [Unreleased]
link to compare v0.2.2 to HEAD instead of v0.2.1 to HEAD. Additionally, add a
new [0.2.2] reference link that points to the v0.2.2 release tag URL following
the same pattern as the other version links below it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/agents/triage_deep/runner.py`:
- Around line 92-97: The `_has_grounded_path` function currently requires only a
`file` to exist for a hop to be considered grounded, but the grounding rule
requires BOTH `file` and `line` to be present. Update the condition in the
return statement to verify that each node in the path has both a non-None `file`
and a non-None `line` value, not just `file` alone. This prevents traces like
`{"file": "a.py", "line": None}` from being incorrectly treated as grounded
reachability.

In `@backend/cliff/agents/triage_runner.py`:
- Around line 189-201: When a prerequisite fails (prereq_failed is True), the
code currently still retrieves stale enrichment and exposure data from previous
successful triage runs in the workspace via list_latest_runs_by_workspace_ids
and passes them to synthesize_triage, which can result in a confident verdict
instead of the intended degraded needs_review. To fix this, conditionally
extract the enrichment and exposure variables (using _structured_output on the
runs) only when prereq_failed is False; when prereq_failed is True, pass empty
or None values for enrichment and exposure to synthesize_triage so that the
degraded verdict is computed without influence from stale prior data.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Around line 755-760: Update the reference links at the bottom of CHANGELOG.md
to reflect the v0.2.2 release. Modify the [Unreleased] link to compare v0.2.2 to
HEAD instead of v0.2.1 to HEAD. Additionally, add a new [0.2.2] reference link
that points to the v0.2.2 release tag URL following the same pattern as the
other version links below it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a6a52e5c-ffcf-4130-a12c-777a255a8aea

📥 Commits

Reviewing files that changed from the base of the PR and between dfb0cbd and a5e340f.

⛔ Files ignored due to path filters (2)
  • backend/uv.lock is excluded by !**/*.lock
  • cli/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • CHANGELOG.md
  • VERSION
  • backend/cliff/agents/runtime/triage_synthesizer.py
  • backend/cliff/agents/triage_deep/runner.py
  • backend/cliff/agents/triage_runner.py
  • backend/pyproject.toml
  • backend/tests/agents/test_deep_dive_runner.py
  • backend/tests/agents/test_triage_runner.py
  • backend/tests/agents/test_triage_synthesizer.py
  • cli/cliff_cli/__init__.py
  • cli/pyproject.toml

Comment thread backend/cliff/agents/triage_deep/runner.py
Comment thread backend/cliff/agents/triage_runner.py
…erral, prereq robustness

Code-review pass (CodeRabbit + a multi-angle finder sweep) surfaced four more
defects in the same false-verdict class; all fixed with tests:

- **Hedged negatives were confidently cleared.** `_classify_reachable` checked
  NEGATIVE before HEDGE, so "cannot confirm it is not reachable" / "appears
  unreachable but unverified" matched "not reachable" → `unexploitable` 0.88 —
  a false-clear of a possibly-real finding. Hedging/uncertainty is now checked
  FIRST and dominates any negative/affirmative keyword; a clean "no path found"
  still clears. (Combined `_REACH_UNCERTAIN`+`_REACH_HEDGE` into one pass.)

- **`secret` findings fell through to the dependency projection.** A leaked
  secret has no CVE → the enricher abstains → the projection cleared it as
  `false_positive`, false-clearing a real secret. The deferral now covers
  `code` AND `secret` (no-advisory-model types); `dependency`/`posture` keep
  the projection. `_code_finding_deferral` → `_deferred_quick_verdict`, type-aware.

- **Deep-dive grounded-path guard required `file` only.** Now requires a real
  `file:line` (int line > 0), matching the trace prompt's "no hop without a
  file:line you actually read" — a `{file, line: null}` hop no longer counts.

- **Prereq-failure degrade hardened.** Degrade on ANY non-`completed` status
  (not just failed/rate_limited — e.g. awaiting_permission), and synthesize the
  degraded verdict from THIS run's inputs only (drop a prior attempt's stale
  enricher/exposure output) so a degraded verdict can't be a confident
  projection from stale data.

Full agents suite: 249 passed. ADR-0051 amendment + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
CHANGELOG.md (1)

768-773: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Update changelog reference links to match the new 0.2.2 section.

The [0.2.2] heading on line 10 lacks a corresponding reference link at the bottom, and the [Unreleased] link still points from v0.2.1 instead of v0.2.2. Keep a Changelog format requires these to stay synchronized.

Update the links section to:

-[Unreleased]: https://github.com/cliff-security/cliff/compare/v0.2.1...HEAD
+[Unreleased]: https://github.com/cliff-security/cliff/compare/v0.2.2...HEAD
+[0.2.2]: https://github.com/cliff-security/cliff/releases/tag/v0.2.2
 [0.2.1]: https://github.com/cliff-security/cliff/releases/tag/v0.2.1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 768 - 773, The reference links section at the
bottom of CHANGELOG.md needs to be updated to match the new [0.2.2] section.
Update the [Unreleased] reference link to point from v0.2.2 instead of v0.2.1 by
changing the version in the compare URL from v0.2.1 to v0.2.2. Additionally, add
a new reference link for [0.2.2] that points to the corresponding release tag,
and ensure all version links are properly ordered from newest to oldest to
maintain consistency with Keep a Changelog format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cliff/agents/runtime/triage_synthesizer.py`:
- Around line 155-160: The affirmative branch using substring matching on
_REACH_AFFIRMATIVE can incorrectly classify hedged text like "unconfirmed
reachable" as "yes" because it matches "confirmed" as a substring. To fix this,
ensure that uncertain or negated variations of affirmative keywords are caught
by the unknown check before reaching the affirmative check. Either add hedging
modifiers like "unconfirmed" to the _REACH_UNKNOWN set so they are caught
earlier in the evaluation, or modify the affirmative substring matching to use
word boundary checks instead of raw substring matching to avoid matching
affirmative keywords that are part of negated compound words.

---

Outside diff comments:
In `@CHANGELOG.md`:
- Around line 768-773: The reference links section at the bottom of CHANGELOG.md
needs to be updated to match the new [0.2.2] section. Update the [Unreleased]
reference link to point from v0.2.2 instead of v0.2.1 by changing the version in
the compare URL from v0.2.1 to v0.2.2. Additionally, add a new reference link
for [0.2.2] that points to the corresponding release tag, and ensure all version
links are properly ordered from newest to oldest to maintain consistency with
Keep a Changelog format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f0d4b56a-8146-4735-81a7-951871eebb0c

📥 Commits

Reviewing files that changed from the base of the PR and between a5e340f and 48ed13b.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • backend/cliff/agents/runtime/triage_synthesizer.py
  • backend/cliff/agents/triage_deep/runner.py
  • backend/cliff/agents/triage_runner.py
  • backend/tests/agents/test_deep_dive_runner.py
  • backend/tests/agents/test_triage_runner.py
  • backend/tests/agents/test_triage_synthesizer.py

Comment on lines +155 to 160
if any(k in s for k in _REACH_UNKNOWN):
return "unknown"
if any(k in s for k in _REACH_NEGATIVE) or s in ("no", "false", "none"):
return "no"
if any(k in s for k in _REACH_UNCERTAIN):
return "unknown"
if any(k in s for k in _REACH_AFFIRMATIVE):
return "yes"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hedge-safe logic can still be bypassed by affirmative substring collisions.

The affirmative branch does raw substring matching, so uncertain text like unconfirmed reachable can match confirmed and be classified as yes. That can reintroduce confident real projections from non-confirmed reachability language.

Suggested hardening patch
+import re
 from typing import Any
@@
 _REACH_NEGATIVE = (
@@
     "no reachable path",
+    "non-exploitable",
+    "non exploitable",
 )
@@
 _REACH_AFFIRMATIVE = (
@@
     "exploitable",
 )
+
+_REACH_AFFIRMATIVE_RE = re.compile(
+    r"\b(?:reachable|yes|true|confirmed|direct|exploitable)\b"
+)
@@
-    if any(k in s for k in _REACH_AFFIRMATIVE):
+    if _REACH_AFFIRMATIVE_RE.search(s):
         return "yes"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cliff/agents/runtime/triage_synthesizer.py` around lines 155 - 160,
The affirmative branch using substring matching on _REACH_AFFIRMATIVE can
incorrectly classify hedged text like "unconfirmed reachable" as "yes" because
it matches "confirmed" as a substring. To fix this, ensure that uncertain or
negated variations of affirmative keywords are caught by the unknown check
before reaching the affirmative check. Either add hedging modifiers like
"unconfirmed" to the _REACH_UNKNOWN set so they are caught earlier in the
evaluation, or modify the affirmative substring matching to use word boundary
checks instead of raw substring matching to avoid matching affirmative keywords
that are part of negated compound words.

@galanko galanko merged commit fe87965 into main Jun 15, 2026
8 checks passed
@galanko galanko deleted the fix/triage-code-finding-failsafe branch June 15, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant