-
Notifications
You must be signed in to change notification settings - Fork 6.3k
fix(report): propagate artifact write errors in finish_scan (#1108) #1117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -312,7 +312,11 @@ def add_vulnerability_report( | |
| if self.vulnerability_found_callback: | ||
| self.vulnerability_found_callback(report) | ||
|
|
||
| self.save_run_data() | ||
| try: | ||
| self.save_run_data() | ||
| except Exception: | ||
| self.vulnerability_reports.pop() | ||
| raise | ||
| return report_id | ||
|
|
||
| def get_existing_vulnerabilities(self) -> list[dict[str, Any]]: | ||
|
|
@@ -371,6 +375,10 @@ def update_scan_final_fields( | |
| technical_analysis: str, | ||
| recommendations: str, | ||
| ) -> None: | ||
| prev_scan_results = self.scan_results | ||
| prev_final_scan_result = self.final_scan_result | ||
| prev_run_scan_results = self.run_record.get("scan_results") | ||
|
|
||
| self.scan_results = { | ||
| "scan_completed": True, | ||
| "executive_summary": executive_summary.strip(), | ||
|
|
@@ -384,7 +392,17 @@ def update_scan_final_fields( | |
| self.run_record["scan_results"] = self.scan_results | ||
|
|
||
| logger.info("Updated scan final fields") | ||
| self.save_run_data(mark_complete=True) | ||
| try: | ||
| self.save_run_data(mark_complete=True) | ||
| except Exception: | ||
| self.scan_results = prev_scan_results | ||
| self.final_scan_result = prev_final_scan_result | ||
| if prev_run_scan_results is None: | ||
| self.run_record.pop("scan_results", None) | ||
| else: | ||
| self.run_record["scan_results"] = prev_run_scan_results | ||
| raise | ||
|
|
||
| posthog.end(self, exit_reason="finished_by_tool") | ||
| scarf.end(self, exit_reason="finished_by_tool") | ||
|
|
||
|
|
@@ -410,6 +428,9 @@ def set_scan_config(self, config: dict[str, Any]) -> None: | |
| ) | ||
|
|
||
| def save_run_data(self, mark_complete: bool = False, status: str | None = None) -> None: | ||
| prev_end_time = self.end_time | ||
| prev_status = self.run_record.get("status") | ||
|
|
||
| if mark_complete: | ||
| self.end_time = datetime.now(UTC).isoformat() | ||
| self.run_record["end_time"] = self.end_time | ||
|
|
@@ -424,10 +445,26 @@ def save_run_data(self, mark_complete: bool = False, status: str | None = None) | |
| self.run_record["status"] = status | ||
|
|
||
| self._sync_llm_usage_record() | ||
| self._save_artifacts() | ||
| try: | ||
| self._save_artifacts() | ||
| except Exception: | ||
| if mark_complete or status: | ||
| self.end_time = prev_end_time | ||
| if prev_end_time is None: | ||
| self.run_record.pop("end_time", None) | ||
| else: | ||
| self.run_record["end_time"] = prev_end_time | ||
| if prev_status is not None: | ||
| self.run_record["status"] = prev_status | ||
| else: | ||
| self.run_record.pop("status", None) | ||
| raise | ||
|
|
||
| def cleanup(self, status: str = "stopped") -> None: | ||
| self.save_run_data(status=status) | ||
| try: | ||
| self.save_run_data(status=status) | ||
| except (OSError, RuntimeError): | ||
| logger.exception("Failed to save scan data during cleanup") | ||
|
|
||
| def _format_final_scan_result(self, scan_results: dict[str, Any]) -> str: | ||
| return f"""# Executive Summary | ||
|
|
@@ -480,6 +517,7 @@ def _save_artifacts(self) -> None: | |
| logger.info("Essential scan data saved to: %s", run_dir) | ||
| except (OSError, RuntimeError): | ||
| logger.exception("Failed to save scan data") | ||
| raise | ||
|
Comment on lines
517
to
+520
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: strix/report/state.py
Line: 517-520
Comment:
**Failed finish leaves final report**
When `write_executive_report` succeeds but a later vulnerability or run-record write fails, this re-raise rolls back the in-memory final state without removing `penetration_test_report.md`. Cleanup cannot rewrite that file after `final_scan_result` is restored to `None`, so the viewer can load a completed report even though `finish_scan` returned failure and the run is stopped.
**Knowledge Base Used:**
- [Reporting and Output](https://app.greptile.com/strix-org-3/-/custom-context/knowledge-base/usestrix/strix/-/docs/reporting-and-output.md)
- [Tools Overview](https://app.greptile.com/strix-org-3/-/custom-context/knowledge-base/usestrix/strix/-/docs/tools-overview.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
| def _sarif_repository_context(self) -> dict[str, Any] | None: | ||
| """Repo/commit/branch context for SARIF provenance (repo scans only). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,7 +63,7 @@ def _do_finish( | |
| recommendations=recommendations.strip(), | ||
| ) | ||
| vuln_count = len(report_state.vulnerability_reports) | ||
| except (ImportError, AttributeError) as e: | ||
| except (ImportError, AttributeError, OSError, RuntimeError) as e: | ||
| logger.exception("finish_scan persistence failed") | ||
| return {"success": False, "error": f"Failed to complete scan: {e!s}"} | ||
|
Comment on lines
+66
to
68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the first final artifact write fails but a later cleanup write succeeds, Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: strix/tools/finish/tool.py
Line: 66-68
Comment:
**Failed finish retains completed state**
When the first final artifact write fails but a later cleanup write succeeds, `save_run_data(mark_complete=True)` has already marked the in-memory run record completed and this error path does not restore it. Cleanup then preserves and persists that completed status even though `finish_scan` returned failure and the coordinator was never marked completed.
**Knowledge Base Used:**
- [Reporting and Output](https://app.greptile.com/strix-org-3/-/custom-context/knowledge-base/usestrix/strix/-/docs/reporting-and-output.md)
- [Tools Overview](https://app.greptile.com/strix-org-3/-/custom-context/knowledge-base/usestrix/strix/-/docs/tools-overview.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| else: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ | |
| check_duplicate, | ||
| ) | ||
| from strix.report.state import ReportState, set_global_report_state | ||
| from strix.tools.finish.tool import finish_scan | ||
| from strix.tools.finish.tool import _do_finish, finish_scan | ||
| from strix.tools.reporting.tool import ( | ||
| _do_create, | ||
| _do_create_dependency, | ||
|
|
@@ -1087,3 +1087,103 @@ async def test_dependency_report_rejects_contextual_breakdown_without_reasoning( | |
| assert result["success"] is False | ||
| assert any("contextual_cvss_reasoning is required" in error for error in result["errors"]) | ||
| assert report_state.vulnerability_reports == [] | ||
|
|
||
|
|
||
| def test_save_artifacts_propagates_write_error( | ||
| report_state: ReportState, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| def _failing_write(*_args: object, **_kwargs: object) -> None: | ||
| raise OSError("Disk quota exceeded") | ||
|
|
||
| monkeypatch.setattr("strix.report.state.write_executive_report", _failing_write) | ||
| report_state.final_scan_result = "Test report" | ||
|
|
||
| with pytest.raises(OSError, match="Disk quota exceeded"): | ||
| report_state.save_run_data() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_finish_scan_reports_failure_on_artifact_write_error( | ||
| report_state: ReportState, monkeypatch: pytest.MonkeyPatch | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Suggestion: Use |
||
| ) -> None: | ||
| def _failing_write(*_args: object, **_kwargs: object) -> None: | ||
| raise OSError("Permission denied") | ||
|
|
||
| monkeypatch.setattr("strix.report.state.write_executive_report", _failing_write) | ||
|
|
||
| result = _do_finish( | ||
| parent_id=None, | ||
| executive_summary="Summary", | ||
| methodology="Methodology", | ||
| technical_analysis="Analysis", | ||
| recommendations="Recommendations", | ||
| ) | ||
|
|
||
| assert result["success"] is False | ||
| assert "Failed to complete scan: Permission denied" in result["error"] | ||
| assert "scan_completed" not in result or result.get("scan_completed") is not True | ||
| assert report_state.scan_results is None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_failed_finish_rolls_back_in_memory_completion_state( | ||
| report_state: ReportState, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| def _failing_write(*_args: object, **_kwargs: object) -> None: | ||
| raise OSError("Permission denied") | ||
|
|
||
| monkeypatch.setattr("strix.report.state.write_executive_report", _failing_write) | ||
|
|
||
| result = _do_finish( | ||
| parent_id=None, | ||
| executive_summary="Summary", | ||
| methodology="Methodology", | ||
| technical_analysis="Analysis", | ||
| recommendations="Recommendations", | ||
| ) | ||
|
|
||
| assert result["success"] is False | ||
| assert report_state.scan_results is None | ||
| assert report_state.final_scan_result is None | ||
| assert report_state.run_record.get("status") == "running" | ||
|
|
||
| monkeypatch.undo() | ||
| report_state.cleanup(status="stopped") | ||
| assert report_state.run_record.get("status") == "stopped" | ||
| assert report_state.run_record.get("scan_results") is None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_create_vulnerability_report_reports_failure_on_write_error( | ||
| report_state: ReportState, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| def _failing_write(*_args: object, **_kwargs: object) -> None: | ||
| raise OSError("Read-only file system") | ||
|
|
||
| monkeypatch.setattr("strix.report.state.write_vulnerabilities", _failing_write) | ||
|
|
||
| result = await _do_create( | ||
| title="SQL Injection in Login", | ||
| description="Exploitable SQL injection in authentication form.", | ||
| impact="Full database access.", | ||
| target="http://example.com/login", | ||
| technical_analysis="Unsanitized user input passed to query.", | ||
| poc_description="Submit ' OR '1'='1 payload.", | ||
| poc_script_code="curl http://example.com/login", | ||
| remediation_steps="Use parameterized queries.", | ||
| evidence="HTTP 500 SQL error trace", | ||
| assumptions="Target DB is PostgreSQL", | ||
| fix_effort="low", | ||
| cvss_breakdown=_CVSS, | ||
| endpoint="/login", | ||
| method="POST", | ||
| cve=None, | ||
| cwe="CWE-89", | ||
| code_locations=None, | ||
| fix_pr_body=None, | ||
| ) | ||
|
|
||
| assert result["success"] is False | ||
| assert "Failed to create vulnerability report: Read-only file system" in result["error"] | ||
| assert report_state.vulnerability_reports == [] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion] Re-raising from
_save_artifactsis the rightfinish_scanfix, but everysave_run_data()caller now surfaces write errors.add_vulnerability_reportappends toself.vulnerability_reportsand then callssave_run_data()with no rollback.create_vulnerability_report/create_dependency_reportstill catch only(ImportError, AttributeError), so a mid-scan disk-full or permission error becomes an unhandled tool exception after the report is already in memory. A retry then hits duplicate detection against a finding that never landed on disk.Suggestion: Either catch
(OSError, RuntimeError)in those reporting tools and return a structuredsuccess: false(same pattern as_do_finish), or roll back the in-memory append ifsave_run_data()fails.record_sdk_usageis already wrapped inexcept Exceptionin hooks, so that path is fine.