Skip to content

Prevent silent data loss from malformed JSON in batch analysis - #5

Merged
claude[bot] merged 2 commits into
mainfrom
improve/20260829-190213
Aug 29, 2026
Merged

Prevent silent data loss from malformed JSON in batch analysis#5
claude[bot] merged 2 commits into
mainfrom
improve/20260829-190213

Conversation

@ssevera1

Copy link
Copy Markdown
Owner

What

Add defensive validation and error handling for malformed or truncated JSON responses from LLM to prevent silent data loss in batch analysis results.

Why

Malformed JSON responses from the LLM can currently cause data loss when they fail to parse. This change validates JSON extraction completeness, checks for empty strings after extraction, ensures parsed JSON is a non-empty list, validates array elements are dicts, and captures parse errors with context rather than silently dropping results.

@claude claude 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.

The restructuring into guard clauses is a readable improvement, and the isinstance(parsed, list) / empty-array / non-dict-element checks are reasonable hardening. But a few things in the diff work against the PR's stated goal of preventing silent data loss.

1. vulntester/llm/analyzer.py:307response[:500] truncates the only surviving copy of the response.
This is the parse-failure path, so raw_analysis is the only record of what the LLM said, and report/generator.py:327 renders it verbatim into the HTML report. A batch prompt covers up to _MAX_FINDINGS_PER_BATCH (25) findings, so a real response is many kilobytes; capping at 500 chars discards nearly all of it with no truncation marker, and the report shows a fragment cut off mid-sentence. That is new, deliberate data loss in a PR titled "prevent silent data loss," and it isn't mentioned in the description. Please keep the full response, or if a cap is genuinely wanted, make it a named constant and append an explicit "...[truncated]" marker plus log the original length.

2. vulntester/llm/analyzer.py:292-299 — an array of all non-dict elements contributes zero results and zero error entries.
If the model returns e.g. [\"finding 1 is fine\", \"finding 2 is fine\"], every element is skipped by the new isinstance(item, dict) guard, the batch appends nothing to results, and the caller has no way to tell the batch was dropped. Every other failure mode here appends a {\"finding_title\": \"Batch Analysis\", \"error\": ...} marker; this one should too — e.g. track whether any dict was appended and, if not, record an error for the batch.

3. vulntester/llm/analyzer.py:296 — non-int finding_index raises TypeError, which the except clause does not catch.
item.get(\"finding_index\", 0) - 1 raises TypeError when the model emits \"finding_index\": \"1\" (a common LLM formatting slip). TypeError is not a subclass of ValueError, so it escapes the try at line 301, propagates out of _analyze_findings_batch, and aborts analyze_report — discarding the already-completed executive summary and every prior batch's results. This is pre-existing, but it is precisely the malformed-JSON data-loss class this PR is scoped to, and the diff touches this exact line, so it should be handled here (coerce via int(...) inside a try, or add TypeError to the except tuple).

Minor: line 302 truncating str(e) to 100 chars is fine for JSONDecodeError but will clip longer ValueError messages in the log for no real benefit. The new blank lines at 291/300 (and the others in the diff) carry trailing whitespace — CI has no linter so it won't fail, but it's worth cleaning up.

No new tests accompany the change; given tests/test_llm_analyzer.py already covers this function's failure modes closely, cases for non-dict elements and the empty-extraction path would fit naturally.

…erce finding_index

- raw_analysis is capped at a named _MAX_RAW_ANALYSIS_CHARS (4000) with an
  explicit "...[truncated, N chars total]" marker and the original length
  logged, instead of a silent 500-char clip.
- A JSON array whose elements are all non-dicts now records a "Batch Analysis"
  error entry rather than contributing nothing at all.
- finding_index is coerced with int(), so a string index no longer raises
  TypeError out of _analyze_findings_batch and aborts analyze_report.
- Log the full parse error; drop trailing whitespace.

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

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Resolved the review. All three points checked out against the code, so all three are fixed; each fix has a regression test that fails without it.

1. response[:500] (analyzer.py:307) — fixed. Confirmed: this is the only surviving copy of the response on the parse-failure path, and report/generator.py:327 renders item.raw_analysis verbatim. With _MAX_FINDINGS_PER_BATCH = 25 a real response is kilobytes, so the clip discarded nearly all of it unmarked. Took the named-constant option: _MAX_RAW_ANALYSIS_CHARS = 4000, an appended ...[truncated, N chars total] marker, and the original length logged at ERROR. Tests: test_short_unparseable_response_is_kept_whole, test_long_unparseable_response_is_marked_as_truncated.

2. All-non-dict array contributed nothing — fixed. Confirmed by reading the loop: every element hitting the isinstance(item, dict) guard continues and the batch appends neither results nor an error. Now counts appended dicts and, when zero, records {"finding_title": "Batch Analysis", "error": "LLM response JSON array contained no analysis objects"}, matching the other failure modes. Test: test_array_of_only_non_dicts_records_an_error. Also added test_non_dict_elements_are_skipped_without_flagging_a_usable_batch so a partially-bad array doesn't gain a spurious error entry.

3. Non-int finding_index raised TypeError — fixed. Verified "1" - 1 raises TypeError, and isinstance(TypeError(), ValueError) is False, so it escaped the except (json.JSONDecodeError, ValueError) at line 301 and propagated out of analyze_report, which does not wrap the _analyze_findings_batch call at analyzer.py:61 — discarding the completed executive summary and all prior batches. Now coerced through a _finding_index() helper that int()s the value and falls back to 0 (item left untitled) on TypeError/ValueError. Tests: test_string_finding_index_is_coerced_not_raised, test_unusable_finding_index_leaves_the_item_untitled, and test_a_bad_index_does_not_discard_the_rest_of_the_report, which drives the full analyze_report path.

Minor points: both taken. The parse-error log now records the full str(e) rather than clipping at 100 chars, and the trailing whitespace on the new blank lines is gone. Also added test_empty_json_extraction_records_an_error for the empty-extraction path.

Nothing pushed back on this round.

Checks: .github/workflows/ci.yml runs pip install -e . && pip install pytest && pytest -q (no linter or mypy step). pytest -q: 40 passed. Against the pre-fix analyzer, 5 of the 8 new tests fail as expected; the other 3 are the extra coverage the review asked for and pass either way.

@claude claude 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.

Traced every branch of the rewritten parse path in vulntester/llm/analyzer.py:293-354 against the new tests. The control flow is equivalent to the old extend behavior on the happy path, and each new early-continue (empty extraction, non-array, empty array, all-elements-skipped) appends a marker dict so a batch can no longer vanish from finding_analysis without a trace. _finding_index correctly contains the TypeError that "1" - 1 used to raise — that exception was not covered by the (JSONDecodeError, ValueError) handler and would have aborted the whole report, so this is a real fix rather than defensive noise; unusable indices degrade to index 0 -> -1, which falls outside the range check and leaves the item untitled instead of mistitling it.

Checked the report side too: raw_analysis and error are rendered through a Jinja Environment(autoescape=True) (vulntester/report/generator.py:386), so the newly-retained raw model text cannot inject markup, and _raw_analysis_text always leaves a "...[truncated, N chars total]" marker plus an error-level log rather than clipping silently. Non-dict elements in a mixed array are dropped with a warning — that is a deliberate narrowing (the template could not render them anyway) and is pinned by a test. Scope matches the title and description; no unrelated changes bundled in.

@claude
claude Bot merged commit ca0b965 into main Aug 29, 2026
3 checks passed
@claude
claude Bot deleted the improve/20260829-190213 branch August 29, 2026 19:08
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