-
Notifications
You must be signed in to change notification settings - Fork 727
feat: package-repo ownership-evidence signal (CM-1394) #4576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
9547a2d
feat: add ownership-match penalty to package_repo_confidence (CM-1394)
joanagmaia 4be467d
feat: add ownership-evidence matching util for package repos (CM-1394)
joanagmaia adb4069
feat: wire ownership-evidence matching into declared repo writers (CM…
joanagmaia 84af02f
docs: add ADR-0022 for package-repo ownership-evidence signal (CM-1394)
joanagmaia 40c5937
fix: wire ownership-evidence matching into rubygems declared writer (…
joanagmaia df7a1dc
feat: emit ingest-time ownership-match counters across all package ec…
joanagmaia babbf13
fix: address bot review findings on ownership-evidence PR (CM-1394)
joanagmaia 29e808d
Update test description for packageRepoLinkClaimParams
joanagmaia 01d6e40
fix: correct misleading compat-overload comment in ownership-penalty …
joanagmaia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
73 changes: 73 additions & 0 deletions
73
backend/src/osspckgs/migrations/V1788393600__package_repo_owner_match.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| -- Ownership-evidence matching for package→repo links (CM-1394). | ||
| -- | ||
| -- The per-package registry workers call matchOwnership() in TypeScript | ||
| -- (packages_worker/src/utils/ownershipMatch.ts). Cargo is the exception: its | ||
| -- pipeline is set-based over the crates.io dump and cannot call a parser per row, | ||
| -- so it needs the same normalization available in SQL. Both sides must stay in | ||
| -- step — the vanity suffix list and the prefix rule below mirror that module. | ||
|
|
||
| ALTER TABLE package_repos | ||
| ADD COLUMN IF NOT EXISTS ownership_match text NOT NULL DEFAULT 'no_evidence' | ||
| CHECK (ownership_match IN ('matched', 'unmatched', 'no_evidence')); | ||
|
|
||
| CREATE OR REPLACE FUNCTION package_repo_owner_key(p_identity text) | ||
| RETURNS text | ||
| LANGUAGE sql IMMUTABLE AS $$ | ||
| WITH base AS ( | ||
| SELECT regexp_replace(lower(btrim(COALESCE(p_identity, ''))), '^@', '') AS v | ||
| ), | ||
| trimmed AS ( | ||
| SELECT v, substring(v from '-(?:ai|io|team|labs|oss|dev)$') AS suffix FROM base | ||
| ) | ||
| SELECT NULLIF( | ||
| regexp_replace( | ||
| CASE | ||
| WHEN suffix IS NOT NULL AND length(v) > length(suffix) + 1 | ||
| THEN left(v, length(v) - length(suffix)) | ||
| ELSE v | ||
| END, | ||
| '[^a-z0-9]', '', 'g' | ||
| ), | ||
| '' | ||
| ) | ||
| FROM trimmed; | ||
| $$; | ||
|
|
||
| CREATE OR REPLACE FUNCTION package_repo_owner_match(p_repo_owner text, p_candidates text[]) | ||
| RETURNS text | ||
| LANGUAGE plpgsql IMMUTABLE AS $$ | ||
| DECLARE | ||
| owner_key text; | ||
| keys text[]; | ||
| BEGIN | ||
| owner_key := package_repo_owner_key(p_repo_owner); | ||
| IF owner_key IS NULL THEN | ||
| RETURN 'no_evidence'; | ||
| END IF; | ||
|
|
||
| SELECT array_agg(k) INTO keys | ||
| FROM ( | ||
| SELECT package_repo_owner_key(c) AS k | ||
| FROM unnest(COALESCE(p_candidates, '{}'::text[])) AS c | ||
| ) t | ||
| WHERE k IS NOT NULL; | ||
|
|
||
| IF keys IS NULL OR cardinality(keys) = 0 THEN | ||
| RETURN 'no_evidence'; | ||
| END IF; | ||
|
|
||
| -- Prefix equality on at least 4 characters: `tokio` matches `tokio-rs`, while | ||
| -- short keys stay exact so `ab` cannot claim `abcdef`. | ||
| IF EXISTS ( | ||
| SELECT 1 | ||
| FROM unnest(keys) AS k | ||
| WHERE k = owner_key | ||
| OR (length(k) <= length(owner_key) AND length(k) >= 4 AND owner_key LIKE k || '%') | ||
| OR (length(owner_key) < length(k) AND length(owner_key) >= 4 AND k LIKE owner_key || '%') | ||
| ) THEN | ||
| RETURN 'matched'; | ||
| END IF; | ||
|
|
||
| RETURN 'unmatched'; | ||
| END; | ||
| $$; | ||
211 changes: 211 additions & 0 deletions
211
backend/src/osspckgs/migrations/V1788393601__no_evidence_ownership_penalty.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| -- Enable the no_evidence ownership penalty (CM-1394). | ||
| -- | ||
| -- V1788393600 added the ownership_match column; V1788307300 added the signal | ||
| -- parameter. This migration widens the scoring function to read ownership_match | ||
| -- too and activates the no_evidence branch — deferred until now because no writer | ||
| -- set the column until CM-1394 landed. | ||
|
|
||
| -- Adding a parameter changes the signature, so the V1788307300 function is dropped | ||
| -- rather than replaced — CREATE OR REPLACE would leave both overloads callable. | ||
| -- The 9-arg compat overload from V1788307300 depends on the 10-arg one below, so it | ||
| -- must go first or the DROP FUNCTION on the 10-arg signature fails on that dependency. | ||
| DROP FUNCTION IF EXISTS package_repo_confidence( | ||
| text, text, text, bool, bool, bool, text, bool, bigint | ||
| ); | ||
| DROP FUNCTION IF EXISTS package_repo_confidence( | ||
| text, text, text, text, bool, bool, bool, text, bool, bigint | ||
| ); | ||
|
joanagmaia marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| CREATE OR REPLACE FUNCTION package_repo_confidence( | ||
| p_source text, | ||
| p_ecosystem text, | ||
| p_signal text, | ||
| p_ownership_match text, | ||
| p_provenance text, | ||
| p_archived bool, | ||
| p_is_fork bool, | ||
| p_disabled bool, | ||
| p_host text, | ||
| p_competing_github bool, | ||
| p_repo_id bigint | ||
| ) | ||
| RETURNS numeric(12, 9) | ||
| LANGUAGE plpgsql IMMUTABLE AS $$ | ||
| DECLARE | ||
| base numeric; | ||
| source_priority int; | ||
| offset_units bigint; | ||
| BEGIN | ||
| base := CASE p_source | ||
| WHEN 'manual' THEN 0.99 | ||
| WHEN 'heuristic' THEN 0.30 | ||
| WHEN 'deps_dev' THEN CASE p_provenance | ||
| WHEN 'SLSA_ATTESTATION' THEN 0.99 | ||
| WHEN 'RUBYGEMS_PUBLISH_ATTESTATION' THEN 0.95 | ||
| WHEN 'PYPI_PUBLISH_ATTESTATION' THEN 0.95 | ||
| WHEN 'GO_ORIGIN' THEN 0.90 | ||
| ELSE 0.50 | ||
| END | ||
| -- maven splits off npm/cargo/the rest: POM <scm> blocks are notoriously | ||
| -- stale (legacy SVN URLs, org renames, dead mirrors). | ||
| WHEN 'declared' THEN CASE WHEN p_ecosystem = 'maven' THEN 0.80 ELSE 0.85 END | ||
| ELSE 0.30 | ||
| END; | ||
|
|
||
| -- Signal and ownership adjust the declared tier only. A deps.dev publish | ||
| -- attestation already proves the publisher–repo relationship, and manual links | ||
| -- are operator-pinned. | ||
| IF p_source = 'declared' THEN | ||
| IF p_signal = 'secondary' THEN | ||
| base := base - 0.10; | ||
| END IF; | ||
|
|
||
| IF p_ownership_match = 'unmatched' THEN | ||
| base := base - 0.25; | ||
| ELSIF p_ownership_match = 'no_evidence' THEN | ||
| base := base - 0.10; | ||
| END IF; | ||
| END IF; | ||
|
|
||
| source_priority := CASE p_source | ||
| WHEN 'manual' THEN 3 | ||
| WHEN 'deps_dev' THEN 2 | ||
| WHEN 'declared' THEN 1 | ||
| ELSE 0 | ||
| END; | ||
|
|
||
| IF p_disabled IS TRUE THEN | ||
| -- Scale proportionally so pre-disabled claim ordering is preserved across sources. | ||
| -- The offset uses a tighter modulo so max contribution (3*1000+999)*1e-9 ≈ 4e-6 | ||
| -- stays below the 0.00016 minimum scaled tier gap and cannot invert source ordering. | ||
| base := 0.05 + LEAST(base, 0.99) * 0.004; | ||
| offset_units := source_priority::bigint * 1000 + COALESCE(p_repo_id, 0) % 1000; | ||
| ELSE | ||
| IF p_archived IS TRUE THEN | ||
| base := base - 0.20; | ||
| END IF; | ||
|
|
||
| IF p_is_fork IS TRUE THEN | ||
| base := base - 0.10; | ||
| END IF; | ||
|
|
||
| IF p_competing_github IS TRUE AND p_host IS NOT NULL AND p_host <> 'github' THEN | ||
| base := base - 0.05; | ||
| END IF; | ||
|
|
||
| base := GREATEST(base, 0.05); | ||
|
|
||
| -- Tie-breaker: reduces same-source collisions to the rare case where two repo IDs for | ||
| -- the same package are congruent mod 1,000,000. BEST_REPO_LINK_JOIN uses a secondary | ||
| -- ORDER BY repo_id DESC as the canonical deterministic pick when confidence ties. | ||
| offset_units := source_priority::bigint * 1000000 + COALESCE(p_repo_id, 0) % 1000000; | ||
| END IF; | ||
|
|
||
| RETURN LEAST(base + offset_units * 0.000000001, 0.999999999); | ||
| END; | ||
| $$; | ||
|
|
||
| -- Compat overload for callers still on the pre-ownership-match signature during a rolling | ||
| -- deploy; delegates to the widened function as 'no_evidence', the safest default since the | ||
| -- ownership penalty only applies to p_source = 'declared' — every other source ignores it. | ||
| -- Drop in a later cleanup migration once all writers emit the 11-arg call. | ||
| CREATE OR REPLACE FUNCTION package_repo_confidence( | ||
| p_source text, | ||
| p_ecosystem text, | ||
| p_signal text, | ||
| p_provenance text, | ||
| p_archived bool, | ||
| p_is_fork bool, | ||
| p_disabled bool, | ||
| p_host text, | ||
| p_competing_github bool, | ||
| p_repo_id bigint | ||
| ) | ||
| RETURNS numeric(12, 9) | ||
| LANGUAGE sql IMMUTABLE AS $$ | ||
| SELECT package_repo_confidence( | ||
| p_source, p_ecosystem, p_signal, 'no_evidence', p_provenance, | ||
| p_archived, p_is_fork, p_disabled, p_host, p_competing_github, p_repo_id | ||
| ) | ||
| $$; | ||
|
|
||
| -- Replaced only to pass cur.ownership_match through to the widened scoring function; | ||
| -- the chunking, locking and keyset paging are unchanged from V1788307300. | ||
| CREATE OR REPLACE PROCEDURE rescore_package_repo_confidence( | ||
| p_repo_ids bigint[] DEFAULT NULL, | ||
| chunk_size int DEFAULT 25000, | ||
| INOUT applied_rows int DEFAULT 0 | ||
| ) | ||
| LANGUAGE plpgsql AS $$ | ||
| DECLARE | ||
| batch_rows int; | ||
| updated_rows int; | ||
| cursor_id bigint := 0; | ||
| BEGIN | ||
| IF chunk_size IS NULL OR chunk_size <= 0 THEN | ||
| RAISE EXCEPTION 'rescore_package_repo_confidence: chunk_size must be positive, got %', chunk_size; | ||
| END IF; | ||
|
|
||
| -- Session-level: survives the internal COMMITs below. | ||
| IF NOT pg_try_advisory_lock(hashtextextended('rescore_package_repo_confidence', 0)) THEN | ||
| RAISE EXCEPTION 'rescore_package_repo_confidence: another execution is already in progress'; | ||
| END IF; | ||
|
|
||
| applied_rows := 0; | ||
|
|
||
| LOOP | ||
| WITH batch AS ( | ||
| SELECT pr.id | ||
| FROM package_repos pr | ||
| WHERE pr.id > cursor_id | ||
| AND (p_repo_ids IS NULL OR pr.repo_id = ANY(p_repo_ids)) | ||
| -- deps_dev rows with NULL provenance were ingested before this column existed; | ||
| -- skip them so the backfill does not downgrade SLSA/attestation links to 0.50. | ||
| -- They will be rescored correctly once the next ingest populates provenance. | ||
| AND NOT (pr.source = 'deps_dev' AND pr.provenance IS NULL) | ||
| ORDER BY pr.id | ||
| LIMIT chunk_size | ||
| FOR UPDATE | ||
| ), | ||
| updated AS ( | ||
| UPDATE package_repos pr | ||
| SET confidence = s.confidence, | ||
| verified_at = GREATEST(clock_timestamp(), cur.verified_at + interval '1 millisecond') | ||
| FROM batch b | ||
| JOIN package_repos cur ON cur.id = b.id | ||
| JOIN packages p ON p.id = cur.package_id | ||
| JOIN repos r ON r.id = cur.repo_id, | ||
| LATERAL ( | ||
| SELECT package_repo_confidence( | ||
| cur.source, p.ecosystem, cur.signal, cur.ownership_match, cur.provenance, | ||
| r.archived, r.is_fork, r.disabled, r.host, | ||
| EXISTS ( | ||
| SELECT 1 | ||
| FROM package_repos c | ||
| JOIN repos cr ON cr.id = c.repo_id | ||
| WHERE c.package_id = cur.package_id | ||
| AND c.repo_id <> cur.repo_id | ||
| AND cr.host = 'github' | ||
| ), | ||
| cur.repo_id | ||
| ) AS confidence | ||
| ) s | ||
| WHERE pr.id = b.id | ||
| AND s.confidence IS DISTINCT FROM cur.confidence | ||
| RETURNING pr.id | ||
| ) | ||
| SELECT COUNT(*), COALESCE(MAX(b.id), cursor_id), | ||
| (SELECT COUNT(*) FROM updated) | ||
| INTO batch_rows, cursor_id, updated_rows | ||
| FROM batch b; | ||
|
|
||
| applied_rows := applied_rows + updated_rows; | ||
|
|
||
| COMMIT; | ||
|
|
||
| EXIT WHEN batch_rows < chunk_size; | ||
| END LOOP; | ||
|
|
||
| PERFORM pg_advisory_unlock(hashtextextended('rescore_package_repo_confidence', 0)); | ||
| END; | ||
| $$; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| # ADR-0022: Ownership evidence for package→repo links | ||
|
|
||
| **Date**: 2026-09-01 | ||
| **Status**: accepted | ||
| **Deciders**: Joana Maia | ||
|
|
||
| ## Context | ||
|
|
||
| A package can name any repository it likes. Nothing in the ingestion path ever | ||
| checked whether the entity publishing the package has anything to do with the | ||
| entity owning the repo, so a package declaring `github.com/torvalds/linux` | ||
| produced a link indistinguishable from the kernel's own. That is how unrelated | ||
| packages inflate a repo's `packages_published` count and, through it, every | ||
| aggregate computed from those counts. | ||
|
|
||
| The evidence needed for the check is already ingested: npm scopes, Maven | ||
| groupIds, packagist vendors, Go module paths, and maintainer/owner/author logins | ||
| for most registries. [ADR-0020](./0020-package-repo-confidence-scoring.md) | ||
| reserved the `ownership_match` column and priced it — `unmatched` −0.25, | ||
| `no_evidence` −0.10 — so all that is missing is a producer for the value. | ||
|
|
||
| ## Decision | ||
|
|
||
| `matchOwnership({ namespace, maintainers, repoOwner })` | ||
| (`packages_worker/src/utils/ownershipMatch.ts`) returns `matched`, | ||
| `unmatched`, or `no_evidence`, and every declared writer calls it before | ||
| persisting a link. Namespace evidence is checked first, maintainer logins are | ||
| the fallback. `no_evidence` is a distinct outcome from `unmatched`: a registry | ||
| that exposes nothing to compare must not be treated as a failed comparison. | ||
|
|
||
| ### Normalisation | ||
|
|
||
| Identities are compared after `normalizeIdentity`: trim, lowercase, strip a | ||
| leading `@`, strip one trailing vanity suffix from `-ai`, `-io`, `-team`, | ||
| `-labs`, `-oss`, `-dev` (only when a suffix strip leaves more than one | ||
| character), then drop all non-alphanumerics. Reverse-DNS namespaces (multi-segment, e.g. `org.projectlombok`) contribute | ||
| only their identity-bearing segments as candidates — structural labels (`io`, | ||
| `com`, `org`, `github`, etc.) are excluded, and no joined form is produced. | ||
| Flat single-segment scopes are normalised as a whole string. This means | ||
| `io.github.resilience4j` matches `resilience4j` via its identity-bearing | ||
| segment, while `io.github.attacker` cannot prefix-match a lookalike owner | ||
| such as `io-github`. Two identities match on | ||
| equality or on prefix when the shorter is at least 4 characters, which is what | ||
| makes `tokio-rs` → `tokio` and `langchain-ai` → `langchain` match while `ab` | ||
| against `abcdef` stays `unmatched`. | ||
|
|
||
| ### Per-ecosystem evidence | ||
|
|
||
| | Ecosystem | Namespace evidence | Maintainer evidence | | ||
| | --- | --- | --- | | ||
| | npm | package scope | `maintainers` | | ||
| | pypi | — | maintainer/author names | | ||
| | packagist | vendor from `name` | maintainers | | ||
| | nuget | — | owners + authors | | ||
| | maven | groupId | developer/contributor usernames | | ||
| | cargo | — | maintainer GitHub logins | | ||
| | go | module path owner, VCS hosts only | — | | ||
| | rubygems | — | none at the link-writing loop | | ||
|
|
||
| Go derives an owner only for module paths rooted at a known VCS host | ||
| (`github.com`, `gitlab.com`, `bitbucket.org`, `codeberg.org`, `gitea.com`, | ||
| `git.sr.ht`); a vanity module path would otherwise produce a false `unmatched`. | ||
| Rubygems stays `no_evidence` for now — owners are fetched in the critical loop, | ||
| not in the core loop that writes the link. Maven's backfill caller passes no | ||
| evidence and therefore yields `no_evidence`, not `unmatched`. | ||
|
joanagmaia marked this conversation as resolved.
|
||
|
|
||
| Cargo's pipeline is set-based SQL over a dump, so it gets a SQL twin of the | ||
| matcher — `package_repo_owner_key(text)` (IMMUTABLE) and | ||
| `package_repo_owner_match(repo_owner, candidates[])` — applied as a lateral | ||
| join against the staged maintainer logins, with the repo owner carried on | ||
| `cargo_sync.repo_choice` (see | ||
| [ADR-0021](./0021-secondary-manifest-repository-signal.md)). | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - Squatting links are separated from legitimate ones by score alone — the read | ||
| side needs no filtering, and `ORDER BY confidence DESC LIMIT 1` starts | ||
| returning the right repo for the epic's named cases. | ||
| - `no_evidence` carries a smaller penalty (−0.10) than `unmatched` (−0.25), so ecosystems with thin metadata are not scored as if they had actively mismatched the owner. | ||
| - The matcher is a pure function with standalone unit tests; the SQL twin | ||
| mirrors it explicitly rather than approximating it. | ||
|
|
||
| ### Negative | ||
|
|
||
| - Two implementations of one rule (TypeScript and SQL) that must stay in sync. | ||
| - Heuristic matching produces false `unmatched` for legitimate publisher/owner | ||
| splits, costing those links 0.25. | ||
| - Rubygems and the Maven backfill contribute `no_evidence` until their loops | ||
| are extended, so their links carry a −0.10 they could avoid. | ||
|
|
||
| ### Risks | ||
|
|
||
| - **The TS and SQL normalisers drift, so cargo scores differently from every | ||
| other ecosystem.** Mitigation: both were validated against the same inputs | ||
| before merge; any change to the vanity-suffix list or the prefix rule must | ||
| touch both, and the cargo integration path is the canary. | ||
| - **Vanity-suffix stripping over-matches, e.g. two distinct orgs collapsing to | ||
| the same key.** Mitigation: the 4-character floor on prefix matching, and | ||
| the penalty is a score adjustment rather than a hard rejection. | ||
| - **The `unmatched` distribution is unknown until the migration is applied.** | ||
| Mitigation: record the matched/unmatched/no_evidence baseline per ecosystem | ||
| before the rescore, so an unexpectedly large `unmatched` share is caught | ||
| before the scores reach downstream consumers. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.