Skip to content

Commit 29ec640

Browse files
ctzismeclaude
andcommitted
Address code review findings; release 0.0.4
Bug fixes: - kernel.py: 6.20+ kernels (between the last vulnerable arc and 7.0.0) were misclassified as UNKNOWN_BRANCH. Now classified as PATCHED on the assumption the upstream fix carried forward. Test added. - modules.py: when `modprobe --showconfig` succeeds and reports no blacklist for the module, trust that result instead of falling through to a manual conf-dir scan. The previous "safety net" could turn a successfully-no-blacklist into a false MITIGATED verdict if a stray .conf existed in a directory modprobe ignores. Tests added. - fixer.py: rmmod failure no longer flips overall fix success. The blacklist write is the persistent mitigation; rmmod is best-effort (the module may be in use). Existing test updated to assert the new semantic — rmmod failure produces a note + action.success=False but overall r.success=True. Cleanup: - output.py: don't compute _kernel_upgrade_note() unconditionally; only call it inside the verdict branches that use it. - test_detector.py: remove dead code in test_ubuntu_kernel_above_threshold (the first detect() call's result was overwritten before use). - python-publish.yml: remove GitHub Actions template placeholder comments; add the actual PyPI project URL. - pyproject.toml: add Homepage and Source URLs; switch Development Status from "4 - Beta" to "3 - Alpha" to match the 0.0.x version line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fabeaa8 commit 29ec640

12 files changed

Lines changed: 90 additions & 35 deletions

File tree

.github/workflows/python-publish.yml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ jobs:
2828

2929
- name: Build release distributions
3030
run: |
31-
# NOTE: put your own distribution build steps here.
3231
python -m pip install build
3332
python -m build
3433
@@ -50,12 +49,7 @@ jobs:
5049
# For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
5150
environment:
5251
name: pypi
53-
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54-
# url: https://pypi.org/p/YOURPROJECT
55-
#
56-
# ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57-
# ALTERNATIVE: exactly, uncomment the following line instead:
58-
# url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
52+
url: https://pypi.org/project/copyfail-guard/
5953

6054
steps:
6155
- name: Retrieve release distributions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on Linux. Works on Debian/Ubuntu, RHEL/Rocky/AlmaLinux, Fedora, and SUSE.
1111

1212
```sh
1313
pip install copyfail-guard
14+
copyfail-guard
1415
```
1516

1617
## Background

pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "copyfail-guard"
7-
version = "0.0.3"
7+
version = "0.0.4"
88
description = "Detect and mitigate CVE-2026-31431 (Copy Fail) on Linux systems."
99
readme = "README.md"
1010
requires-python = ">=3.9"
1111
license = { file = "LICENSE" }
12-
authors = [{ name = "copyfail-guard contributors" }]
12+
authors = [{ name = "ctz" }]
1313
keywords = ["cve", "linux", "security", "kernel", "algif_aead", "copy-fail"]
1414
classifiers = [
15-
"Development Status :: 4 - Beta",
15+
"Development Status :: 3 - Alpha",
1616
"Environment :: Console",
1717
"Intended Audience :: System Administrators",
1818
"License :: OSI Approved :: Apache Software License",
@@ -30,7 +30,9 @@ classifiers = [
3030
dependencies = []
3131

3232
[project.urls]
33-
"Bug Reports" = "https://github.com/your-org/copyfail-guard/issues"
33+
Homepage = "https://github.com/ctzisme/copyfail-guard"
34+
"Bug Reports" = "https://github.com/ctzisme/copyfail-guard/issues"
35+
Source = "https://github.com/ctzisme/copyfail-guard"
3436

3537
[project.scripts]
3638
copyfail-guard = "copyfail_guard.cli:main"

src/copyfail_guard/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""copyfail-guard — detect and mitigate CVE-2026-31431 ("Copy Fail") on Linux."""
22

3-
__version__ = "0.0.3"
3+
__version__ = "0.0.4"

src/copyfail_guard/fixer.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,5 +290,12 @@ def apply_fix(ctx: SystemContext, *, dry_run: bool = False) -> FixResult:
290290
)
291291
)
292292

293-
success = all(a.success for a in actions if a.executed) or dry_run
293+
# Overall success is determined by the persistent steps only. The blacklist
294+
# write is what actually mitigates across reboots; rmmod is best-effort
295+
# (the module may be in use and refuse to unload), and the audit log is
296+
# informational. Both are reported as individual action records, but
297+
# neither flips overall success.
298+
persistent_types = {"precheck", "write_blacklist"}
299+
persistent_actions = [a for a in actions if a.executed and a.type in persistent_types]
300+
success = all(a.success for a in persistent_actions) or dry_run
294301
return FixResult(success, dry_run, tuple(actions), tuple(notes))

src/copyfail_guard/kernel.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,9 @@ def classify(kv: KernelVersion) -> Classification:
105105
if arc_start <= v <= vuln_max:
106106
return Classification(Verdict.IN_RANGE, branch, threshold)
107107

108+
# No arc matched. If v is newer than the highest known vulnerable point,
109+
# assume the fix carried forward into later branches (e.g. 6.20+ before 7.0).
110+
last_vuln_max = VULNERABLE_ARCS[-1][1]
111+
if v > last_vuln_max:
112+
return Classification(Verdict.PATCHED, None, None)
108113
return Classification(Verdict.UNKNOWN_BRANCH, None, None)

src/copyfail_guard/modules.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,14 @@ def find_blacklist(ctx: SystemContext, name: str = MODULE_NAME) -> BlacklistResu
153153
return BlacklistResult(True, "install_redirect", None)
154154
if re.search(rf"^\s*blacklist\s+{re.escape(name)}\s*$", r.stdout, re.MULTILINE):
155155
return BlacklistResult(True, "blacklist", None)
156-
# modprobe ran but found nothing — still scan conf dirs as a safety net.
156+
# modprobe ran successfully and reported no blacklist for this module.
157+
# Trust that — modprobe knows about include directives and override
158+
# precedence, and a stray .conf in a directory it doesn't read would
159+
# not actually block module loading at runtime.
160+
return BlacklistResult(False, None, None)
157161
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
158162
pass
163+
# modprobe binary unavailable or errored — fall back to scanning conf dirs.
159164
return _scan_conf_dirs(ctx, name)
160165

161166

src/copyfail_guard/output.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,22 +165,21 @@ def render_detection_text(r: DetectionResult) -> str:
165165
if r.in_container:
166166
lines.append(" Environment: container")
167167

168-
upgrade_note = _kernel_upgrade_note(r)
169168
if r.verdict == Verdict.VULNERABLE:
170169
lines.append("")
171170
lines.append("Recommended actions:")
172171
lines.append(" 1. Apply mitigation now:")
173172
lines.append(" sudo copyfail-guard fix")
174173
lines.append(" 2. Update the kernel for a permanent fix:")
175-
lines.append(f" {upgrade_note}")
174+
lines.append(f" {_kernel_upgrade_note(r)}")
176175
elif r.verdict == Verdict.UNMITIGABLE_BUILTIN:
177176
lines.append("")
178177
lines.append("Recommended action (mitigation alone is insufficient):")
179-
lines.append(f" {upgrade_note}")
178+
lines.append(f" {_kernel_upgrade_note(r)}")
180179
elif r.verdict == Verdict.MITIGATED:
181180
lines.append("")
182181
lines.append("Mitigation in place. For a permanent fix:")
183-
lines.append(f" {upgrade_note}")
182+
lines.append(f" {_kernel_upgrade_note(r)}")
184183

185184
if r.notes:
186185
lines.append("")

tests/test_detector.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,9 @@ def test_ubuntu_mitigated_with_install_false(self):
3636
self.assertFalse(any("more robust" in n for n in r.notes))
3737

3838
def test_ubuntu_kernel_above_threshold_is_patched(self):
39-
# 6.6.137 is the patched threshold of the 6.6 branch — but our fixture's lib/modules
40-
# dir is named 6.8.0-50-generic, so we have to pretend uname says 6.6.137. We'll
41-
# craft a scenario with a fixture that reports a patched release.
42-
# Quick reuse: use ubuntu2404-patched fixture which has only modules.dep entry but
43-
# we'll claim the running kernel is 6.6.200.
44-
r = detect(
45-
SystemContext(
46-
root=FIXTURES / "ubuntu2404-patched",
47-
uname_release="6.6.200-generic",
48-
is_linux=True,
49-
)
50-
)
51-
# ubuntu2404-patched fixture has the dep entry under 6.8.0-50-generic, so for
52-
# release 6.6.200 the loadable check returns False (different modules dir).
53-
# That yields not_applicable. Let's instead set up a temp fixture.
54-
39+
# Patched-branch case: claim the running kernel is 6.6.200, well past the
40+
# 6.6.137 fix point. Build a synthetic fixture whose lib/modules layout
41+
# matches the claimed release so the loadable-module check works.
5542
import tempfile
5643

5744
with tempfile.TemporaryDirectory() as tmp:

tests/test_fixer.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ def test_non_root_with_dry_run_succeeds(self):
162162

163163
class RmmodFailureTests(unittest.TestCase):
164164
def test_rmmod_failure_on_loaded_module_records_failure(self):
165+
# rmmod is best-effort: if the module is in use, the persistent
166+
# blacklist write is what actually mitigates across reboots, so the
167+
# overall fix should still report success — just with a note.
165168
import tempfile
166169

167170
with tempfile.TemporaryDirectory() as tmp:
@@ -172,11 +175,15 @@ def test_rmmod_failure_on_loaded_module_records_failure(self):
172175
(base / "proc" / "modules").write_text("algif_aead 16384 1 - Live 0\n")
173176
runner = FakeRunner(returncode=1, stderr="Module is in use")
174177
r = apply_fix(_ctx(base, runner=runner))
175-
self.assertFalse(r.success)
178+
# The individual rmmod action records the failure ...
176179
rm = [a for a in r.actions if a.type == "rmmod"][0]
177180
self.assertFalse(rm.success)
178181
self.assertIn("in use", rm.error)
179182
self.assertTrue(any("still loaded" in n for n in r.notes))
183+
# ... but overall success holds because the blacklist landed.
184+
self.assertTrue(r.success)
185+
wb = [a for a in r.actions if a.type == "write_blacklist"][0]
186+
self.assertTrue(wb.success)
180187

181188
def test_rmmod_failure_when_not_loaded_is_ok(self):
182189
import tempfile

0 commit comments

Comments
 (0)