Repository navigation
Conversation
…nfig.py Prerequisite: agent package did not parse, blocking all tests. Co-Authored-By: Claude Code (VulnFix)
f.id and results_dir_name are stamped verbatim into the <!-- vulnhunt-finding-id: ... --> / <!-- vulnhunt-results-dir: ... --> footer markers. Both are attacker-influenced LLM/scan-README data, so a value containing '-->' closed the comment and injected arbitrary markdown/HTML into the rendered GitHub issue (CWE-79/CWE-116). Restrict marker values to a strict identifier charset [A-Za-z0-9._-] -- the same class the downstream parsers accept -- so a breakout is impossible while legitimate ids/dir-names survive intact. Co-Authored-By: Claude Code (VulnFix)
residual_vectors entries are LLM/finding-derived and were interpolated verbatim into the '## Residual Risk' markdown appended to the PR/issue body. The hand-wave / consistency guards reject vague or empty entries but never escape, so an entry could inject raw HTML (<script>) or a markdown link ([x](javascript:...)) into the rendered body (CWE-79/CWE-116). Neutralize each entry via html.escape + backslash-escaping the markdown link/code metacharacters so metacharacters render as literal text while benign prose survives readably. Co-Authored-By: Claude Code (VulnFix)
…down injection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Adversarial review + fix applied (pushed CANON-21 (marker breakout) reviewed solid. CANON-44 review found a gap: Fix: collapse |
AmirF194
left a comment
There was a problem hiding this comment.
Ran this against 49b29e2 in a clean python:3.12-slim container.
The PR's own stated scope is escaping attacker-influenced values before they land in a posted PR/issue body, and _column7_cell (delivery.py:222,228) is the one interpolation site in this same file the diff leaves untouched. graph_callers/routed_callers are file:symbol strings straight from the scanned repo (query.py:169's callers_of, or the grep fallback's _enclosing_symbol), so a file literally named evil|report.py in the target repo is enough:
rows = [{..., "graph_callers": ["evil|report.py:handler", "clean.py:normal_caller"], ...}]
render_verification_table(rows)
# | 1 | VULN-001 | yes some_file.py:42 | yes | yes | n/a | yes (clean.py:normal_caller) (evil|report.py:handler) | yes | FULL |
# 9 columns expected, 10 parsed
A pipe in either half of the caller string splits into an extra cell, and a longer one can fabricate yes | yes | yes | FULL in place of the real verdict, which is exactly what a reviewer reads before approving an auto-generated security fix. Same _escape_residual_entry this PR adds for residual_vectors (or stripping |/newlines) would close this too.
… on main via #13) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Column 7 of the verification table interpolated graph/routed callers (file:symbol from the scanned repo) unescaped. A '|' added cells and could forge the Verdict column GitHub displays (e.g. caller 'x.py:a) | yes | FULL | <!--'); a CR/LF broke out of the table. Add _escape_table_cell (html/backtick/bracket convention of _escape_residual_entry + '|' -> '\|' + CR/LF collapse) and apply it to every cell of render_verification_row: index, VULN id, the six data cells (incl. computed column 7) and Verdict. file:line citations are unchanged. Reported by AmirF194 in PR #9 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_parse_table silently dropped any row whose cell count differed from the header, so an injected '|' (extra cells, forged Verdict) or a CR/LF (short row) removed the row from validation entirely and Gate 6 exited 0 — found while reproducing AmirF194's column-7 injection from the PR #9 review. - Split rows only on unescaped pipes (GFM semantics) and decode the renderer's backslash/html escapes so escaped cells, citations and callers validate against their literal text. - Raise MalformedTableError naming every bad row; validate() reports it and exits 1. - Rows run to the first blank line (GFM: leading pipe optional), so a newline-broken row's continuation is checked too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Risk
render_residual_risk_section interpolated tier raw as '**{tier}**' and fell
back to a generic one-liner for unknown values. Refuse any tier outside the
result-schema enum (FULL/MITIGATION/WORKAROUND) with a new UnknownTierError
(a HonestyGuardError, like the other guard failures).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ries Residual entries could ping users/teams under the operator's identity or render live 'https://' / 'www.' links. Insert a zero-width-space reference (​, the Dependabot convention) after '@' when a name follows, inside '://', and after a word-initial 'www'. Ordinary prose is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs in one pass
- The '### Finding' table cells (CWE, CWE name, severity, location, root
cause, data flow) did not escape '|' or newlines: location
'a.py:1 | **Severity** | Low' forged cells and a newline in root_cause
injected headings. New _sanitize_inline escapes '|' as '\|' and collapses
CR/LF for every single-line site (table cells, H2 title, Priority line);
multi-line free-form sections (exploit description/impact, fix strategy,
severity rationale) keep their newlines.
- render_body / _render_clean_scan replaced placeholders field-by-field, so
a value containing '{IDEMPOTENCY_KEY}' etc. was expanded by a later pass.
Switch to a single regex pass with a dict lookup (_substitute); unfilled
placeholders are now reported from the template, not from user values.
PR #9 review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_sanitize_marker_value silently dropped out-of-charset characters, which can produce a marker naming a different results dir / finding id. Log a warning (module logger) naming the marker and the before/after values. The consumer side (vulnhunter-fix scripts/issue_intake.py) already accepts only [A-Za-z0-9._-] results-dir names and rejects '..', '/', '\' since #26, matching this charset, so no change is needed there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @AmirF194 for the careful review and the concrete repro — confirmed. Reproducing it surfaced something worse in the gate:
Following your scoping argument, I also swept the rest of the rendered bodies:
All with RED→GREEN tests. vulnhunter-agent: 1162 passed; vulnhunter-fix (py3.11 & 3.12): 586 passed. |
emeth-
left a comment
There was a problem hiding this comment.
Approved - this is a clear improvement over main.
The escaping is correct where it's applied, _escape_table_cell and the validator's split/decode agree, and it was a good catch that the validator silently dropped malformed rows. Single-pass substitution and the tier whitelist are good too.
Some gaps that already exist on main may be worth follow-ups on, perhaps in a separate PR. They're mostly in Gate 6, which is the real control because the LLM can write the fix-side table directly:
-
The Verdict column isn't enforced anywhere.
verification-table-rules.mdsaysNEEDS_REWORKhalts delivery, but no gate reads the Verdict. Againstvalidate-verification.pywith--sidecars-dir/--result:- A correctly derived
NEEDS_REWORKrow exits 0. | 1 | VULN-001 | no | no | no | no | n/a | n/a | FULL |exits 0. That's the other half of Amir's forged-FULLconcern.- A
FULLrow with any resolving citations and column 7n/aexits 0, even when the sidecar lists callers the fix never routed through andresult.jsonsaysMITIGATION.
Suggestions:
- Derive the verdict in the validator and fail on a mismatch or
NEEDS_REWORK. - Fail when the Verdict is stronger than
completeness_tier. - Reject column 7
n/awhen the sidecar lists callers.
Separately, the column-8 values
sweep-root-causes.pyemits (yes,yes (n/a)) fail the citation rule today, so rendered tables are rejected when a sweep ran. - A correctly derived
-
_parse_tableuses the first header-looking line anywhere, ignoring HTML comments, code fences and the delimiter row. It has two failure modes, both of which exit 0:- A decoy table above the real one (inside a fence or a comment) gets validated instead.
- Text above the table with a lookalike-header table plus an unclosed
<!--hides the real table and## Residual Riskon GitHub, up to the vulnfix-key marker's-->. Gate 6 still validates the hidden table, so reviewers only see the forged one. Gate 2 also counts the hidden headings as present.
This matters because the fix side reads finding text straight from the scan README, and
deliver.mdgives no escaping guidance. Suggest stripping comments (an unclosed one runs to the end) and fences before parsing, refusing<!--outside the known markers, and requiring exactly one table under## Verification Table.
Escape untrusted values rendered into issue/PR bodies
Neutralizes the places where finding- or scan-derived text gets rendered into GitHub issue/PR markdown without escaping. These came from the cross-model
/vulnhuntevaluation (eval_rep/), plus the review feedback on this PR. Developed test-first, keeping the existing suites green.The branch is up to date with
mainvia a merge commit (0c917f4). The earlierconfig.pyprerequisite is gone: the identical fix landed onmainvia #13, and the now-redundantTestModuleImportRegressiontest is removed in835e20f.CANON-21: Constrain marker values in issue-body HTML comments (CWE-79)
Commit
2615c32.vulnhunter-agent/agent/issues_render.pysubstitutesVULN_IDandRESULTS_DIR_NAMEverbatim into HTML-comment markers such as<!-- vulnhunt-finding-id: {VULN_ID} -->. They stay un-escaped on purpose so the markers remain machine-parseable, but a value containing-->would close the comment early._sanitize_marker_value()restricts marker values to[A-Za-z0-9._-], which is exactly the charset the downstream parsers accept. It applies toVULN_ID,RESULTS_DIR_NAMEand (defensively)IDEMPOTENCY_KEY.71256ad): when stripping changes a value, a warning is now logged naming the marker and the before/after values. Previously this was silent, and stripping can turn a results-dir name into a different one. The consumer side (vulnhunter-fix/scripts/issue_intake.py) already enforces the same[A-Za-z0-9._-]charset and rejects..///\since Port results-dir validation from verify_extract to issue_intake #26, so it needs no change.test_issues_render.py::TestMarkerCommentBreakout,::TestMarkerSanitizeWarns.CANON-44: Escape residual-risk entries in the delivery body (CWE-79/116)
Commits
4949b3a,49b29e2,fea53f1.vulnhunter-fix/vulnhunter_fix/delivery.pyrendered eachresidual_vectorsentry verbatim into the## Residual Riskbullets._escape_residual_entry()html-escapes the entry, backslash-escapes`[], and collapses CR/LF so an entry can't break out of its bullet. It now also defuses@user/@org/teammentions and barehttps:///www.autolinks with a zero-width-space reference (​, the Dependabot convention). Ordinary prose is unchanged.vulnhunter-fix/tests/test_hand_wave_guard.py.Review: verification-table cell injection (reported by AmirF194)
Commit
b81bee4._column7_cellinterpolatedgraph_callers/routed_callers(file:symbolfrom the scanned repo) unescaped into the 9-column verification table. A caller likex.py:a) | yes | FULL | <!--added cells and forged the Verdict column GitHub displays, and a newline broke out of the table._escape_table_cell(), does the html/backtick/bracket escaping, escapes|as\|and collapses CR/LF. It is applied to every cell ofrender_verification_row: index, VULN id, the six data cells (including the computed column 7) and the Verdict. The Verdict is derived from the escaped cells.yes (file.py:42)citations come out byte-for-byte unchanged.test_sweep_and_verification.py::test_verification_row_*. The pipe-forging and newline cases each render exactly 9 cells and the real verdict (WORKAROUND). Every data field is checked individually.Gate bypass in
validate-verification.py(found while reproducing the above)Commit
d135de4._parse_tablesilently dropped any row whose cell count differed from the header. A forged row was therefore never validated, and Gate 6 exited 0.MalformedTableError, naming the row, line and cell count, and the gate exits 1. Rows run to the first blank line, as in GFM (a leading pipe is optional), so the continuation of a newline-broken row is checked too.references/verification-table-rules.mddocuments the rule.expected 9 cells, got 12). The newline-broken row also exits 1. A renderer-escaped row (a\|bcaller,src/\[id\].pycitation) parses into 9 cells, validates (exit 0) and is genuinely checked: a bad citation in it now fails.Review: smaller items
3480e7b):render_residual_risk_sectioninterpolated**{tier}**raw. A tier outside the result-schema enum (FULL/MITIGATION/WORKAROUND) now raisesUnknownTierError, aHonestyGuardErrorlike the other guard failures. The old"not fully closed"fallback is gone.0b2ee90): the### Findingtable fields (CWE, CWE name, severity, location, root cause, data flow) did not escape|or newlines. A new_sanitize_inline()handles every single-line site: the table cells, the H2 title and the Priority line. The multi-line free-form sections (exploit description/impact, fix strategy, severity rationale) keep their newlines.0b2ee90):render_bodyand the clean-scan renderer replaced placeholders field by field, so a value containing{IDEMPOTENCY_KEY}was expanded by a later pass. A single regex pass with a dict lookup (_substitute) fixes this. Unfilled-placeholder warnings now come from the template, not from user values.Verification
Every change was written test-first (RED, then GREEN). The CI-equivalent
pytest --covruns:vulnhunter-agent(py3.12): 1162 passed, 1 skippedvulnhunter-fix(py3.11 and py3.12): 586 passed, 10 skipped each; coverage 86.75%main:test_issues_render.py+23,test_hand_wave_guard.py+15,test_sweep_and_verification.py+16 (54 total)🤖 Generated with Claude Code