Skip to content

fix(compliance): make the licence wildcard work, and fail closed on an unreadable allowlist - #75

Merged
aabusair merged 4 commits into
mainfrom
fix/allowlist-wildcard-and-fail-closed
Sep 22, 2026
Merged

aabusair merged 4 commits into
mainfrom
fix/allowlist-wildcard-and-fail-closed

Conversation

@aabusair

Copy link
Copy Markdown
Collaborator

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 worked

cla/allowlist.yml has always claimed "Supports exact IDs and simple * wildcards (handled by requires_cla.py)", and ships LicenseRef-Broadcom* on that basis. But the match was plain set membership:

req = {_norm_license_name(x) for x in (section.get("require_cla") or [])}
if norm_license in req:      # equality, no globbing anywhere in the module

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_reason strips the LicenseRef- prefix before matching:

LicenseRef-Broadcom-Proprietary  ->  (True, 'unit_match_canon')   # permissive!

Broadcom_Proprietary is listed in permissive_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 and fnmatch's case folding is platform-dependent.

Blast radius, measured before writing the fix

Repos on Broadcom_Source_Available 48 — already CLA via the exact entry, unchanged
Repos on Broadcom_Proprietary 0

No repo changes document today. The fix is preventive.

2. An unreadable allowlist read as a permissive one

allowlist_data = {}                               # safe default
...
allowlist_data = yaml.safe_load(raw_allowlist)    # reassigned FIRST
repos_config = allowlist_data.get(...)            # this is what raises

An emptied or comments-only file makes safe_load return None — no exception. The AttributeError then fires after the safe default has already been destroyed, gets swallowed into a debug_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 STRICT when the licence logic errors.

fetch_shared_config now reports allowlist_ok, and process_single_pr forces strict and clears allowlist_repos when 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 scalar repositories: is now rejected instead of iterated character by character.

config.get("allowlist_ok", True) defaults true so a hand-built shared_config is treated as deliberate. cla_sweeper passes the dict through unchanged; license_report.py calls get_license_decision directly and is unaffected.

Tests — 12 new, 75 total, OK

Mutation-tested:

Revert Result
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, not a gap. allowlist_ok is 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 NoneType instead 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 omitted allowlist_data — the only one that could come back poisoned.

Verification

Merges are live org-wide at ref: main, so:

  • End-to-end replay of the real cla/allowlist.yml through the real fetch_shared_config: allowlist_ok=True, allowlist_repos=['vmware/.github'] — unchanged
  • Override results after the change: all Broadcom LicenseRefs → CLA; MIT, Apache-2.0, LicenseRef-Other → no override, unchanged
  • Both consumers of the config dict checked

🤖 Generated with Claude Code

Amr AbuSair and others added 2 commits September 22, 2026 11:46
…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>
@aabusair

Copy link
Copy Markdown
Collaborator Author

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 fa5e222.

Mutation Before Now
LicenseRef-Broadcom* deleted from the real file passed ❌ 2 failures ✅
Normaliser stops collapsing _ → - passed ❌ 1 failure ✅
fnmatchcase → fnmatch passed ❌ still passes — see below

The wildcard entry was not pinned

TestOverrideWildcards builds its own allowlist dict, so deleting the wildcard from the shipped file left the suite green — while every Broadcom licence except the spelled-out one silently returned to the base tables, which list them as permissive. Back to DCO. Now asserted against the real file.

The normaliser gap is the one I'd already been caught on

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 -. Remove that rule and the production lookup for 48 repos breaks.

My first guard drove the lookup from the catalogue's own value — right instinct, still insufficient: the licenseref-broadcom* wildcard matches the catalogue spelling either way and masks the breakage. I verified that, then asserted the convergence property directly instead:

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

fnmatch defers to os.path.normcase, which is identity on POSIX — so fnmatch and fnmatchcase are indistinguishable on a Linux runner and differ only on Windows. The test stays because it documents why fnmatchcase is correct, but it is not coverage against that regression and shouldn't be counted as such.

One mutation deliberately left uncaught

Deleting the exact LicenseRef-Broadcom_Source_Available entry still passes, because the wildcard now covers it. That's the fix working, not a gap.

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>
@aabusair

Copy link
Copy Markdown
Collaborator Author

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 tested

Every fixture in TestOverrideWildcards pairs an exact entry with a wildcard over the same namespace — LicenseRef-Broadcom_Source_Available next to LicenseRef-Broadcom*. The wildcard answers first, so a broken exact branch is invisible.

Proved it 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 four non-findings, each checked

Mutation Why it doesn't fail
glob branch always taken fnmatchcase(x, x) is exact equality for metachar-free patterns — no behavioural change. Equivalent mutant.
isinstance guard removed masked by the except-reset
except-reset removed masked by the isinstance guard
exact Broadcom entry deleted the wildcard now covers it — that's the fix working

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 — allowlist must be a mapping, got NoneType instead of 'NoneType' object has no attribute 'get'.

Final matrix — 18/22 caught, 4 explained

Fix 1 (wildcard): glob branch never taken ✅ · fnmatchcase→False ✅ · fnmatchcase→True ✅ · exact branch removed ✅ (newly caught) · _matches_any→False ✅ · →True ✅ · require_cla call disabled ✅ · allow_dco call disabled ✅ · precedence swapped ✅

Fix 2 (fail-closed): allowlist_ok init True ✅ · never set True ✅ · repositories type check gone ✅ · fail-closed guard removed ✅ · guard no longer forces strict ✅ · default flipped ✅ · key dropped from the dict ✅

Config: wildcard entry deleted ✅ · require_cla emptied ✅

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>
@aabusair
aabusair merged commit b0d7dcc into main Sep 22, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant