fix(guards): read .gitignore like git, allowlist settings.json keys, refuse workspaces and bunfig.toml

Three findings on tools/security_guards.py and tools/install_portal_clis.py.

GHSA-rv99-7xf2-8f4p (Martin Vlach, @Huge): the gitignore guard stripped
both ends of each line, but git drops only unescaped trailing whitespace,
so " salary_data.json" satisfied the required-rule check while git had
stopped ignoring the file. Only the root file was read, though a
.gitignore in a subdirectory outranks it, so a negation there re-included
personal data with CI green. Lines are now read as git reads them, and
every .gitignore below the root (outside .git and node_modules) is checked
for negations; an intentional one is allowlisted as "<dir>/.gitignore:!p".

GHSA-cqcv-crx7-x7q7 (@folovco, @Huge): the guard checked allow, ask, deny
and hooks and ignored every other key, so env, apiKeyHelper, a statusLine
command, enableAllProjectMcpServers, permissions.defaultMode or
additionalDirectories passed CI. Any top-level key outside
ALLOWED_SETTINGS_KEYS and any permissions key outside
ALLOWED_PERMISSIONS_KEYS now fails.

GHSA-wq7j-x5jq-pjrw (@folovco, @Huge), on top of #718's --ignore-scripts
(cherry-picked, rudycelekli): the installer and check 3 now refuse a
manifest that declares workspaces (whose packages bring their own
lifecycle scripts; a workspace postinstall ran under Bun 1.3.10) or that
has a bunfig.toml beside it (which reconfigures the install). The
advisory's workspace proof of concept is now skipped before bun runs.

Twelve new tests, all failing before this change; SECURITY.md, README and
SETUP describe the new checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mads LorentzenandClaude Fable 5.1 authored and Mads Lorentzen committed 2026-10-27 16:14:35 +01:00
1 parent 2b8803f777
commit 0bcfd75e60
8 files changed
+306 -12

No files matched your search

+21
View File
@@ -33,6 +33,7 @@ class FakeRun:
def __call__(self, argv, cwd):
self.calls.append(Path(cwd).parent.name)
self.argvs = getattr(self, "argvs", []) + [list(argv)]
return subprocess.CompletedProcess(argv, 1 if Path(cwd).parent.name in self.fail else 0)
@@ -70,6 +71,26 @@ class InstallPortalClis(unittest.TestCase):
def test_trusted_dependencies_is_unsafe(self):
self.assertIn("trustedDependencies", unsafe_reason(self.add_cli("x", {"trustedDependencies": ["y"]})))
# GHSA-wq7j-x5jq-pjrw: the gate read only the top-level manifest, but a
# `workspaces` entry pulls in sub-packages with their own lifecycle scripts
# (a workspace postinstall ran under Bun 1.3.10), and a bunfig.toml beside
# the manifest reconfigures the install itself (registry, scopes, cache).
def test_workspaces_is_unsafe(self):
self.assertIn("workspaces", unsafe_reason(self.add_cli("x", {"workspaces": ["packages/*"]})))
def test_bunfig_is_unsafe(self):
cli = self.add_cli("x")
(cli / "bunfig.toml").write_text('[install]\nregistry = "https://registry.example"\n', encoding="utf-8")
self.assertIn("bunfig.toml", unsafe_reason(cli))
def test_bun_install_never_runs_lifecycle_scripts(self):
# #718: --ignore-scripts on every install, so a dependency's hook stays
# off whatever the Bun release's default trust list says.
self.add_cli("a-search")
run = FakeRun()
self.assertEqual(install(discover(self.root), "bun", False, self.root, run), 0)
self.assertEqual(run.argvs, [["bun", "install", "--ignore-scripts"]])
def test_one_failure_does_not_stop_the_rest(self):
for name in ("a-search", "b-search", "c-search"):
self.add_cli(name)