diff --git a/fusil/python/__init__.py b/fusil/python/__init__.py index 6f341e6..f72d650 100644 --- a/fusil/python/__init__.py +++ b/fusil/python/__init__.py @@ -1009,6 +1009,22 @@ def setupProject(self) -> None: # these prefixes; no diagnostic starts with them. r"^(\+\+\+|---|!!!) ", r"\.critical\(", + # Third face of the same disease, and the one that survived PR #265: `warnings` + # prints the source line of whatever frame was running when it fired -- + # + # /.../logging/__init__.py:1536: RuntimeWarning: coroutine '...' was never awaited + # self._log(CRITICAL, msg, args, **kwargs) + # + # -- so any un-awaited coroutine collected while logging is on the stack echoes + # `logging`'s own source, and `CRITICAL` there is the LEVEL CONSTANT being passed + # as an argument, not a diagnostic. 14 kept dirs in one PyPy fleet, across + # asyncio.base_events, asyncio.streams and asyncio.selector_events. + # + # Match the constant only where it sits in an argument position, so a real + # formatted record (`CRITICAL:root:...`) and English prose ("critical error") + # both still score. Case-sensitive on purpose: the all-caps spelling is the + # module constant. + r"[(,]\s*CRITICAL\s*[),]", # The --new-uninit region prints a progress marker per poked type, # e.g. "[NEW-UNINIT] poking SystemError". The type name is arbitrary and # routinely collides with a crash word ("SystemError" -> a 1.0 hit) or, worse, diff --git a/fusil/python/blacklists.py b/fusil/python/blacklists.py index efdb978..c315882 100644 --- a/fusil/python/blacklists.py +++ b/fusil/python/blacklists.py @@ -67,6 +67,28 @@ "getaddrinfo", "socket", "SocketType", + # The int-as-FD family: the same self-harm shape as the int-as-pointer functions in + # CTYPES above, one layer up. These four take a RAW INTEGER file descriptor + # (`close(integer) -> None`, `dup(integer) -> integer`, `fromfd(fd, family, type)`, + # `send_fds(sock, buffers, fds)`), so handing them any fuzz integer closes or + # reinterprets a descriptor the interpreter is still using. It is not a target defect -- + # the contract is the argument: `close(integer)` does what it is told, on any interpreter. + # (A standalone harness closing fd 11 while sibling threads call getaddrinfo did NOT + # reproduce the abort on either interpreter, so the window needs the full stress region; + # the case here rests on the evidence below, not on a differential.) + # + # Measured, and it is not a small effect: 163 of 229 kept dirs in one PyPy + # --concurrency-stress fleet (71%) were this, in two faces that split exactly on the + # value passed. 120 closed some other descriptor and were captured with glibc's own + # `Unexpected error 9 on netlink descriptor 11` -- 11 being `socket.AF_ROSE`, which the + # stress region had picked as a shared object. The other 43 were SIGABRTs with an EMPTY + # stdout: they had closed the child's own stdout or stderr, so the diagnostic had nowhere + # to go. Of the 100 dirs whose shared constants include a 0, 1 or 2, 43 went silent; of the + # 63 that do not, none did. + "close", + "dup", + "fromfd", + "send_fds", } POSIX = { "_exit", diff --git a/tests/python/test_blacklists.py b/tests/python/test_blacklists.py index 47f1dd5..0e07b6e 100644 --- a/tests/python/test_blacklists.py +++ b/tests/python/test_blacklists.py @@ -85,6 +85,32 @@ def test_pypy_blacklist_stays_narrow(self): for keep in ("newdict", "strategy", "internal_repr", "intop", "move_to_end"): self.assertNotIn(keep, bl.BLACKLIST["__pypy__"]) + def test_socket_int_as_fd_functions_blacklisted(self): + """socket.close/dup/fromfd/send_fds take a RAW INTEGER file descriptor. + + Handing them a fuzz integer closes or reinterprets a descriptor the interpreter is + still using -- the int-as-FD analogue of the int-as-pointer functions in CTYPES, and + self-harm rather than a target defect -- the contract is the argument, and `close(integer)` + does what it is told on any interpreter. It dominated a PyPy + --concurrency-stress fleet: 163 of 229 kept dirs, in two faces that split on the value + passed -- 120 carrying glibc's `Unexpected error 9 on netlink descriptor 11` + (`socket.AF_ROSE == 11`), and 43 SIGABRTs with an EMPTY stdout, which had closed their own + stdout or stderr -- of the 100 dirs carrying a constant worth 0, 1 or 2, 43 went silent; + of the 63 without one, none did. + """ + self.assertLessEqual({"close", "dup", "fromfd", "send_fds"}, bl.BLACKLIST["socket"]) + + def test_socket_blacklist_keeps_the_socket_object_surface(self): + """Only the MODULE-level int-taking functions are excluded. + + `close` and `dup` are also METHODS on a socket object, where they take no descriptor + and are perfectly safe to fuzz. The entry is module-keyed for exactly that reason -- + putting these names in the name-based METHOD_BLACKLIST would silently stop fusil from + ever closing a socket, a file or anything else with a `close`. + """ + for name in ("close", "dup"): + self.assertNotIn(name, bl.METHOD_BLACKLIST) + def test_sys_trace_hooks_blacklisted(self): self.assertEqual( bl.BLACKLIST["sys"] & {"settrace", "setprofile"}, {"settrace", "setprofile"} diff --git a/tests/test_file_watch.py b/tests/test_file_watch.py index c4c7401..b86b790 100644 --- a/tests/test_file_watch.py +++ b/tests/test_file_watch.py @@ -356,6 +356,56 @@ def test_faulthandler_stack_lines_are_not_swallowed(self): self.assertEqual(w.score, 1.0) +class TestWarningSourceEchoIgnored(unittest.TestCase): + """`warnings` echoes the source of whatever frame it fired in; that source may score. + + The default warning formatter prints two lines -- a header naming a file and line, and + then that line's SOURCE, verbatim: + + /.../logging/__init__.py:1536: RuntimeWarning: coroutine '...' was never awaited + self._log(CRITICAL, msg, args, **kwargs) + + So an un-awaited coroutine collected while `logging` happens to be on the stack echoes + `logging`'s own source, and the `CRITICAL` in it is the level CONSTANT being passed as an + argument -- not a diagnostic. This is a third, distinct route to the same 1.0 word as the + traceback shapes above, and the traceback rules do not cover it: the line is neither a + `File "...", line N, in ...` frame nor a `.critical(` call. 14 kept dirs in one PyPy + fleet came in this way. + + The rule matches the constant only in an argument position, so a real formatted record + and English prose both still score. + """ + + ARG_CONSTANT = r"[(,]\s*CRITICAL\s*[),]" + + def _watch_with_rule(self): + w = _watch(words={"critical": 1.0}) + w.ignoreRegex(self.ARG_CONSTANT) + return w + + def test_logging_source_echo_is_ignored(self): + for line in ( + b" self._log(CRITICAL, msg, args, **kwargs)", + b" if self.isEnabledFor(CRITICAL):", + b" self.log(CRITICAL, msg, *args, **kwargs)", + ): + with self.subTest(line=line): + w = self._watch_with_rule() + self.assertIsNone(w.processLine(line)) + self.assertEqual(w.score, 0.0) + + def test_a_real_record_or_prose_still_scores(self): + for line in ( + b"CRITICAL:root:something exploded", + b"CRITICAL: target failed", + b"a critical error occurred in the target", + ): + with self.subTest(line=line): + w = self._watch_with_rule() + w.processLine(line) + self.assertEqual(w.score, 1.0) + + class TestCookiejarWarningIgnored(unittest.TestCase): """http.cookiejar's own "bug!" warning must not push a boring session over the threshold.