fix: salvage optional finding fields instead of discarding the finding - #171
fix: salvage optional finding fields instead of discarding the finding#171Haiduongcable wants to merge 11 commits into
Conversation
The unsealed-findings recovery path already salvages malformed writeup, remediationTests, and preventiveControls by dropping just that field and keeping the finding, but any other schema mismatch falls through to the outer handler and discards the entire finding. Two concrete cases hit this without any of the finding's actual content being unrecoverable: - taxonomy.cwe is optional in the hand-rolled validator (defaults to an empty list) but required as a key by the JSON schema, so a finding missing the key outright is silently dropped in full. - codeEvidence is optional at the finding level, but a single malformed entry (e.g. missing `explanation`) currently fails the whole finding instead of just that auxiliary field, unlike its sibling optional fields. This extends the existing salvage pattern to both: codeEvidence joins the existing auxiliary-field recovery list, and a missing taxonomy.cwe is backfilled to an empty list. Dropping codeEvidence can leave dangling evidenceRefs in rootCause/validation/attackPath, so those are pruned in the same pass and a recovery warning is recorded. identity and confidence.rationale are deliberately left as hard requirements in both validators; there is no safe default for identity, and confidence.rationale is a real content requirement rather than a validator mismatch.
The previous change recovered codeEvidence only for schema mismatches the hand-rolled _validate_finding validator doesn't itself check (a missing explanation). Any malformation _validate_finding does check directly -- a non-array codeEvidence, a non-object entry, a missing id, or a duplicate id -- still raised inside _validate_finding before the codeEvidence recovery ran, discarding the whole finding exactly as before. Move codeEvidence validation (schema check plus the same duplicate-id check _validate_finding performs) ahead of _validate_finding, so any malformation is caught and the field dropped -- never the finding -- regardless of which validator would have caught it. Prune dangling evidenceRefs before _validate_finding as well, since that function's own referential check would otherwise fail on the now-removed ids. Also prune the documented nested attackPath.dataflow.evidenceRefs location, not only the top-level attackPath.evidenceRefs sibling field, so a stripped codeEvidence entry cannot leave a dangling reference findings.json still contains no consumer resolves.
|
Fork CI is green on this exact commit, run within the fork so the full matrix executes without Commit:
Local verification (
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e09e0dbdee
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Issue & Root Cause Analysis Problem When there is a little bit of the schema mismatch on the optional fields (taxonomy.cwe not exists or garbage value is there for elements in codeEvidence), the whole findings have been completely dropped in finalizescancontract.py. The New bug noticed during code review When we try to prune orphaned evidenceRefs after cleaning out the ill-formed codeEvidence, we use the lookup on sets ref in knownevidenceids. If one of the elements of a list of evidenceRefs is not hashable (due to ill-formed LLM or scanner's garbage output contains either dictionary/object or a nested list). Then we got TypeError. As we have only wrapped contract checks within ContractError handler, this new type of error will crack the cli application down and leave us with trace backs. Python logic after changes (finalizescancontract.py) In the references pruning logic used for root Cause, validation and attack Path (including in attackPath.dataflow), remove such references: V known_evidence_ids = { def prune_evidence_refs(container: dict) -> bool: |
|
known_evidence_ids = { def prune_evidence_refs(container: dict) -> bool: modified = False if modified: return modified finalize_scan_contract.py |
The dangling-evidenceRefs pruning added alongside codeEvidence recovery tested set membership directly (`ref in known_evidence_ids`) without first checking that each reference was a string. An evidenceRefs entry that is a JSON object or array is unhashable, so the membership test raises TypeError instead of the ContractError the recovery loop and CLI are prepared to handle -- one malformed optional reference now aborted scan completion with a traceback instead of being recovered or discarded like every other malformed field in this path. Guard the membership test with an isinstance check first so non-string entries (objects, arrays, or otherwise-hashable non-string values) are pruned the same way missing or unknown references already are, rather than crashing.
…lds' into fix/finding-recovery-missing-fields
|
Addressed the automated review finding above (P1: unhashable evidence references before set lookup). Root cause: the dangling- Fix: guard the membership test with kept_refs = [
ref for ref in refs if isinstance(ref, str) and ref in known_evidence_ids
]Verification:
|
|
@riju568 thanks for the writeup and the proposed patch — the |
|
Thanks for your appreciation. @Haiduongcable I am hare if anything I can contribute. : ) |
|
known_evidence_ids = { def prune_evidence_refs_recursive(data) -> bool: finalize_scan_contract.py |
|
//tests-ts/scan-recovery.test.ts it('Salves findings that have non-list evidenceRefs and steps that use array inputs for the attackpath', async () => { const result = await runScanContractRecovery([malformedFinding]); expect(result.findings).toHaveLength(1); expect(recovered.attackPath[0].evidenceRefs).toEqual(['EVD-1']); |
evidenceRefs Payloadrefs = container.get("evidenceRefs") |
|
Related finding-recovery work: merged #159 already handles malformed optional remediation fields, while #184 separately normalizes valid list-shaped severity change conditions. This PR addresses missing |
The recovery cases were asserted in a single test, so a regression in any one of them surfaced under a name that did not identify it. Split them so each failure mode has its own named guard and can be preserved independently when this work is coordinated with the other recovery changes: - a missing taxonomy.cwe is backfilled rather than discarding the finding - codeEvidence rejected by the JSON-schema pass is dropped, and the rootCause references it strands are pruned - duplicate codeEvidence ids, which only the hand-rolled validator catches, are dropped, and the nested attackPath.dataflow references are pruned Behaviour and coverage are unchanged: the warning assertions still total the same six across the three tests.
…lds' into fix/finding-recovery-missing-fields
07d5488 to
4ff20bc
Compare
|
@mldangelo-oai thanks — the regressions are now individually preservable. Split into one named test per failure mode, each with its own fixture and scan:
The last two stay separate because they hit different detection paths — a missing required field is caught by the JSON-schema pass, duplicate ids only by the hand-rolled validator — and strand references in different places ( Confirmed the split is real, not cosmetic: restoring the upstream Verification: types, format, build clean; 721 pass / 5 skip / 0 fail; One flag while you coordinate: recovery warnings are appended before |
Summary
finalize_scan_contract.pyalready salvages a malformedwriteup,remediationTests, orpreventiveControlsfield by dropping just that field and keeping the finding. Any other schemamismatch falls through to the outer handler and discards the entire finding instead.
Two concrete cases hit this even though the finding's actual content is otherwise fine:
taxonomy.cweis optional in the hand-rolled validator (defaults to an empty list) but requiredas a key by the JSON schema, so a finding missing the key outright is silently dropped in full.
codeEvidenceis optional at the finding level, but a single malformed entry (missingexplanation, wrong shape, missingid, duplicateid, etc.) currently fails the whole findinginstead of just that auxiliary field, unlike its sibling optional fields.
A real, otherwise-valid finding can vanish from
report.md,findings.json, SARIF, and CSV becauseone optional field was malformed, with only a generic completion warning as a trace.
Changes
taxonomy.cwekey to an empty list before final schema validation, matchingthe hand-rolled validator's own tolerant default.
codeEvidence: a malformed entry dropsjust that field, keeping the finding.
codeEvidencerecovery ahead of_validate_finding. The hand-rolled validator independentlyrejects some
codeEvidencemalformations (non-array, non-object entries, missingid, duplicateid) before the schema-based recovery would otherwise run, so validating/strippingcodeEvidencefirst ensures every malformation type is salvaged consistently, not only the ones a bare
JSON-schema check would catch.
codeEvidencecan leave danglingevidenceRefsinrootCause,validation, andattackPath— including the documented nestedattackPath.dataflow.evidenceRefslocation. Theseare pruned in the same pass, with a recovery warning recorded, so the sealed contract never
references removed evidence.
Deliberately not changed
identityandconfidence.rationaleremain hard requirements in both validators. There is no safedefault for
identity, andconfidence.rationaleis a genuine content requirement rather than avalidator mismatch, so a finding missing either is still (correctly) discarded.
Verification
Reproduced directly against
finalize_scan_contract.pyand the bundled example finding before anychange: a missing
taxonomy.cweand five distinct malformed-codeEvidenceshapes each discardedthe whole finding. After the fix, all seven cases are recovered — finding kept, offending
field/reference stripped, warning recorded — while
identity/confidence.rationalecases areunchanged.
Added a regression case to
tests-ts/scan-recovery.test.tscovering all of the above, including thenested
attackPath.dataflow.evidenceRefscase. Confirmed the test fails against unpatchedupstream/mainsource (findingCount4 → 1, all three malformed findings fully discarded) beforepassing against the fix.
Full local gate:
pnpm run types,pnpm run test(702 pass / 5 skip, 0 fail),pnpm run format,pnpm run build, and the packaging check (pnpm pack+check:package) all pass.