diff --git a/desloppify/languages/cxx/cppcheck_diagnostics.py b/desloppify/languages/cxx/cppcheck_diagnostics.py new file mode 100644 index 000000000..89a4899db --- /dev/null +++ b/desloppify/languages/cxx/cppcheck_diagnostics.py @@ -0,0 +1,35 @@ +"""Distinguish cppcheck setup/parse failures from reliable code diagnostics.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +from desloppify.languages._framework.generic_parts.tool_runner import ToolRunResult + +_CONFIGURATION_IDS = frozenset({"missingInclude", "missingIncludeSystem", "unknownMacro"}) + + +def configuration_checked_result(result: ToolRunResult) -> ToolRunResult: + """Fail the invocation when cppcheck could not understand its translation units. + + Cppcheck can exit zero while reporting missing headers or unsupported macros. + The security runner retries individual files and preserves regex security + fallback; returning an error here activates that coverage accounting. + """ + if result.status == "error": + return result + for entry in result.entries: + message = str(entry.get("message", "")) + is_missing_include = "Include file:" in message and "not found" in message + is_unknown_macro = "unknown macro here" in message and "configure" in message + is_cpp_header_as_c = ( + "invalid C code" in message + and Path(str(entry.get("file", ""))).suffix.lower() in {".h", ".hh", ".hpp", ".hxx"} + ) + if entry.get("check_id") in _CONFIGURATION_IDS or is_missing_include or is_unknown_macro or is_cpp_header_as_c: + return replace( + result, entries=[], status="error", error_kind="tool_configuration", + message=f"cppcheck requires include paths, macro definitions or language configuration: {message}", + ) + return result diff --git a/desloppify/languages/cxx/detectors/security.py b/desloppify/languages/cxx/detectors/security.py index 2d4570856..e84ef66f0 100644 --- a/desloppify/languages/cxx/detectors/security.py +++ b/desloppify/languages/cxx/detectors/security.py @@ -23,6 +23,8 @@ run_tool_result, ) +from desloppify.languages.cxx.cppcheck_diagnostics import configuration_checked_result + logger = logging.getLogger(__name__) _SOURCE_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx"}) @@ -248,7 +250,7 @@ def _normalize_kind(check_id: str, message: str) -> str | None: ): return "command_injection" - if any(api in text for api in _UNSAFE_C_STRING_APIS): + if re.search(r"\b(?:" + "|".join(_UNSAFE_C_STRING_APIS) + r")\b", text): return "unsafe_c_string" if "unsafebufferhandling" in check: return "unsafe_c_string" @@ -495,6 +497,7 @@ def _run_cppcheck_invocation(scan_root: Path, files: list[str]) -> CxxToolScanRe scan_root, _parse_cppcheck_output, ) + result = configuration_checked_result(result) state = _tool_result_state(result) return CxxToolScanResult( tool="cppcheck", diff --git a/desloppify/languages/cxx/tests/test_security.py b/desloppify/languages/cxx/tests/test_security.py index d18aa4082..a1df6ce36 100644 --- a/desloppify/languages/cxx/tests/test_security.py +++ b/desloppify/languages/cxx/tests/test_security.py @@ -611,3 +611,35 @@ def _fake_run_tool_result(cmd, path, parser, **_kwargs): assert len(result.entries) == 1 assert result.entries[0]["file"] == str(source.resolve()) + + +def test_missing_widget_include_is_not_an_unsafe_gets_call(tmp_path): + entry = { + "file": str(tmp_path / "controller.cpp"), + "line": 11, + "severity": "information", + "check_id": "missingInclude", + "message": 'Include file: "Widgets/Input/SVirtualJoystick.h" not found.', + "source": "cppcheck", + } + assert security_mod._normalize_tool_entry(entry) is None + + +def test_unsafe_string_api_names_require_identifier_boundaries(): + for api in security_mod._UNSAFE_C_STRING_APIS: + assert security_mod._normalize_kind("", f"Calling '{api}()' is unsafe") == "unsafe_c_string" + assert security_mod._normalize_kind(f"clang-analyzer-security.insecureAPI.{api}", "") == "unsafe_c_string" + assert security_mod._normalize_kind("", f"Include file: my_{api}_wrapper.h not found") is None + + +def test_cppcheck_configuration_failure_keeps_regex_security_fallback(tmp_path, monkeypatch): + source = tmp_path / "unsafe.cpp" + source.write_text("void copy(char* a, const char* b) { strcpy(a,b); }\n") + monkeypatch.setattr(security_mod.shutil, "which", lambda name: "/usr/bin/cppcheck" if name == "cppcheck" else None) + monkeypatch.setattr(security_mod, "run_tool_result", lambda *_: ToolRunResult(entries=[{ + "file": str(source), "line": 1, "severity": "error", "check_id": "unknownMacro", + "message": "Unknown macro requires configuration", "source": "cppcheck", + }], status="ok", returncode=0)) + result = security_mod.detect_cxx_security([str(source)], None) + assert result.coverage.status == "reduced" + assert any(e["detail"]["kind"] == "unsafe_c_string" and e["detail"]["source"] == "regex" for e in result.entries)