Repository navigation
fix(compliance): make the licence wildcard work, and fail closed on an unreadable allowlist - #75
Conversation
…n unreadable allowlist Two defects found reviewing #74 and deliberately left out of it, because both change enforcement rather than documentation. ## 1. The documented '*' wildcard never worked cla/allowlist.yml has always said "Supports exact IDs and simple '*' wildcards (handled by requires_cla.py)" and ships `LicenseRef-Broadcom*` on that basis. But _override_requires_cla built a set and tested `norm_license in req` — plain equality. The entry could only ever match a licence literally named `LicenseRef-Broadcom*`. That was not merely inert. A licence falling past the override goes to the base tables, where is_permissive_with_reason strips the `LicenseRef-` prefix before matching, so `LicenseRef-Broadcom-Proprietary` finds `Broadcom_Proprietary` in permissive_names.json and comes back permissive -> DCO. A proprietary Broadcom licence was gated on the weaker document. _matches_any now globs any pattern containing * ? or [, and compares exactly otherwise. fnmatchcase rather than fnmatch, since both sides are already lowercased by _norm_license_name and fnmatch's case folding is platform-dependent. Blast radius measured before writing it: of 145 repos in the last licence report, 48 use Broadcom_Source_Available — already CLA via the exact entry, unchanged — and none use Broadcom_Proprietary. So this changes no repo's current document and closes the gap prospectively. ## 2. An unreadable allowlist read as a permissive one fetch_shared_config assigned `allowlist_data = yaml.safe_load(...)` before the `.get()` that can raise. An emptied or comments-only file yields None, so the AttributeError fired *after* the safe `{}` default had already been overwritten, was swallowed into a debug_log, and every licence override silently vanished org-wide. The direction matters: Broadcom Source Available is listed as permissive in the base tables and is held at CLA *only* by the override. Losing it downgrades all 48 of those repos to DCO. Fail-open, in a compliance gate, and inconsistent with the function twenty lines below that already defaults to STRICT when the licence logic errors. fetch_shared_config now reports `allowlist_ok`, and process_single_pr forces is_strict and clears allowlist_repos when it is False — "we could not read the policy" is not "the policy permits this". The parse failure is now an ::error:: rather than a debug_log, and names the type it got. The scalar `repositories:` case is rejected instead of being iterated character by character. `config.get("allowlist_ok", True)` defaults true so a hand-built shared_config — the tests, and any caller supplying its own data — is treated as deliberate rather than as a failure. cla_sweeper passes the real dict through unchanged; license_report.py calls get_license_decision directly and is unaffected by the new key. ## Tests 12 new, 75 total, OK. Mutation-tested: - wildcard -> plain set membership: 3 failures - drop the fail-closed guard: 1 failure Reverting the isinstance guard or the except-reset individually does NOT fail, and that is correct rather than a gap: `allowlist_ok` is the single point of truth, so both are overlapping safety nets that converge on the same outcome. The guard's real contribution is the message — "allowlist must be a mapping, got NoneType" instead of "'NoneType' object has no attribute 'get'". Also extended test_missing_config_degrades_to_empty_rather_than_raising, which asserted three of the four keys and omitted allowlist_data — the only one that could come back poisoned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Probed the new tests by mutating things they ought to catch, rather than assuming 12 tests meant 12 tests' worth of coverage. Three slipped through. **The shipped wildcard entry was not pinned.** TestOverrideWildcards builds its own allowlist dict, so deleting `LicenseRef-Broadcom*` from the real cla/allowlist.yml left the suite green — while every Broadcom licence except the one spelled out silently returned to the base tables, which list them as permissive, i.e. back to DCO. Now asserted against the shipped file. **A broken normaliser passed unnoticed** — the same defect I was pulled up on in #74 and reproduced here. The allowlist spells it `LicenseRef-Broadcom_Source_Available` (underscores); licenses_all.json's canonical spdx_id is `LicenseRef-Broadcom-Source-Available` (hyphens). They meet only because _norm_license_name collapses [\s_]+ to '-'. Removing that rule breaks the production lookup for 48 repos. My first attempt at a guard drove the lookup from the catalogue's own value, which was the right instinct and still insufficient: the `licenseref-broadcom*` wildcard matches the catalogue spelling either way, so it masks the breakage. Verified, then asserted the convergence property directly instead — the two spellings must normalise to the same string, independent of any matcher. **The case-sensitivity test cannot fail on this platform.** fnmatch defers to os.path.normcase, which is identity on POSIX, so fnmatch and fnmatchcase are indistinguishable here and differ only on Windows. The test is kept because it documents why fnmatchcase is the correct choice, but it is recorded as unable to detect a regression on a Linux runner rather than presented as coverage it does not provide. 80 tests, OK. Re-ran the full mutation matrix afterwards: wildcard entry deleted -> 2 failures, normaliser broken -> 1, globbing disabled -> 5, fail-closed guard removed -> 1. One mutation deliberately left uncaught: deleting the exact `LicenseRef-Broadcom_Source_Available` entry still passes, because the wildcard now covers it. That is the fix working, not a gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Probed the new tests by mutating things they should catch, rather than assuming 12 tests meant 12 tests' worth of coverage. Three slipped through. Fixed in
The wildcard entry was not pinned
The normaliser gap is the one I'd already been caught onThe allowlist spells it My first guard drove the lookup from the catalogue's own value — right instinct, still insufficient: the self.assertEqual(
_norm_license_name(catalogue["Broadcom_Source_Available"]["spdx_id"]),
_norm_license_name("LicenseRef-Broadcom_Source_Available"),
)The case-sensitivity test cannot fail here, and I'm not claiming it does
One mutation deliberately left uncaughtDeleting the exact 80 tests, OK. |
…n sweep
Ran all 22 mutations reachable from the two fixes rather than the handful I
had guessed at. 17 were caught; of the 5 that were not, four are equivalent
mutants and one was a real hole.
The hole: every fixture in TestOverrideWildcards pairs an exact entry with a
wildcard over the same namespace — `LicenseRef-Broadcom_Source_Available`
alongside `LicenseRef-Broadcom*` — so the wildcard answers first and a broken
exact branch is invisible. Verified directly: with `elif norm_license ==
pattern` disabled, an exact-only allowlist returns None instead of True for
'mit', and the whole suite still passed. Added a fixture with no wildcard at
all, covering require_cla, allow_dco, and a non-match.
The other four are equivalent or correct, each checked rather than assumed:
- "glob always taken": fnmatchcase(x, x) is exact equality for any pattern
without metacharacters, so forcing every pattern through fnmatch changes
no outcome.
- "isinstance guard removed" and "except reset removed", individually: each
is masked by the other. Removing BOTH does fail the suite, which is what
defence in depth is supposed to look like. The guard's separate value is
the message — "allowlist must be a mapping, got NoneType" rather than
"'NoneType' object has no attribute 'get'".
- "exact Broadcom entry deleted": the wildcard now covers it. That is the
fix working.
18/22 caught, the remaining four explained. 81 tests, OK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran a full mutation sweep — all 22 mutations reachable from the two fixes, not just the ones I'd guessed at. 17 caught, 5 not. One was a real hole; four are equivalent mutants, each verified rather than assumed. The real hole: the exact-match branch was never testedEvery fixture in Proved it directly: with The four non-findings, each checked
On the middle two: removing both together does fail the suite. That's what defence in depth should look like, and I checked rather than claiming it. The guard's independent value is the message — Final matrix — 18/22 caught, 4 explainedFix 1 (wildcard): glob branch never taken ✅ · fnmatchcase→False ✅ · fnmatchcase→True ✅ · exact branch removed ✅ (newly caught) · Fix 2 (fail-closed): Config: wildcard entry deleted ✅ · 81 tests, OK. |
Found by reading the diff rather than by mutating it. cla_sweeper fetches the config once and threads the same dict through every PR in the sweep, and the new guard clears allowlist_repos. Rebinding the local name is safe; calling .clear() on it would strip the DCO downgrade from every repo processed after the first unreadable-allowlist PR in that sweep. The code rebinds and is correct. Nothing pinned it, so a later refactor to .clear() or a slice assignment would pass. Verified the test fails against exactly that mutation. 82 tests, OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two enforcement defects found while reviewing #74 and deliberately deferred from it, since both change behaviour rather than documentation.
1. The documented
*wildcard never workedcla/allowlist.ymlhas always claimed "Supports exact IDs and simple*wildcards (handled by requires_cla.py)", and shipsLicenseRef-Broadcom*on that basis. But the match was plain set membership:So that entry could only match a licence literally named
LicenseRef-Broadcom*.Why that was not merely untidy. A licence that falls past the override goes to the base tables, and
is_permissive_with_reasonstrips theLicenseRef-prefix before matching:Broadcom_Proprietaryis listed inpermissive_names.json, so a proprietary Broadcom licence resolved to DCO. The wildcard was evidently added to prevent exactly that._matches_any()now globs any pattern containing*,?or[, and compares exactly otherwise —fnmatchcase, since both sides are already lowercased andfnmatch's case folding is platform-dependent.Blast radius, measured before writing the fix
Broadcom_Source_AvailableBroadcom_ProprietaryNo repo changes document today. The fix is preventive.
2. An unreadable allowlist read as a permissive one
An emptied or comments-only file makes
safe_loadreturnNone— no exception. TheAttributeErrorthen fires after the safe default has already been destroyed, gets swallowed into adebug_log, and every licence override silently disappears org-wide.The direction matters. Broadcom Source Available is permissive in the base tables and is held at CLA only by the override. Lose it and all 48 of those repos drop to DCO — fail-open, in a compliance gate, and inconsistent with the same function twenty lines below which already defaults to
STRICTwhen the licence logic errors.fetch_shared_confignow reportsallowlist_ok, andprocess_single_prforces strict and clearsallowlist_reposwhen it is false. "We could not read the policy" is not "the policy permits this." The failure is an::error::naming the type it got, rather than a debug line. A scalarrepositories:is now rejected instead of iterated character by character.config.get("allowlist_ok", True)defaults true so a hand-builtshared_configis treated as deliberate.cla_sweeperpasses the dict through unchanged;license_report.pycallsget_license_decisiondirectly and is unaffected.Tests — 12 new, 75 total, OK
Mutation-tested:
Reverting the
isinstanceguard or the except-reset individually does not fail — and that is correct, not a gap.allowlist_okis the single point of truth, so both are overlapping safety nets converging on the same outcome. The guard's real contribution is the message:allowlist must be a mapping, got NoneTypeinstead of'NoneType' object has no attribute 'get'.Also extended
test_missing_config_degrades_to_empty_rather_than_raising, which asserted three of four keys and omittedallowlist_data— the only one that could come back poisoned.Verification
Merges are live org-wide at
ref: main, so:cla/allowlist.ymlthrough the realfetch_shared_config:allowlist_ok=True,allowlist_repos=['vmware/.github']— unchangedMIT,Apache-2.0,LicenseRef-Other→ no override, unchanged🤖 Generated with Claude Code