Skip to content

Commit 1c4fe98

Browse files
authored
docs: security contacts adr [CM-1325] (#4375)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com> Signed-off-by: Mouad BANI <mbani@contractor.linuxfoundation.org>
1 parent 84deb7e commit 1c4fe98

2 files changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
# ADR-0010: Security contacts — tiered extraction, confidence scoring, and Temporal batch ingestion
2+
3+
**Date**: 2026-07-21
4+
**Status**: accepted
5+
**Deciders**: Mouad BANI
6+
7+
_Consolidated ADR for the security-contacts worker — record further security-contacts decisions here rather than opening new ADRs._
8+
9+
## Context
10+
11+
The Akrites effort needs a single, confidence-rated answer to "who do we contact about a
12+
vulnerability in this package?" for every repo linked to an `is_critical` package. Today that
13+
information is scattered across registry manifests, repo files (`SECURITY.md`,
14+
`SECURITY-INSIGHTS.yml`, `SECURITY_CONTACTS`, `security.txt`), and GitHub API state (private
15+
vulnerability reporting), with no cross-ecosystem standard. Much of it is noise: RFC 2606
16+
placeholder emails, templated SECURITY.md files linking to generic GitHub docs, registry usernames
17+
that are only *guessed* to be GitHub logins, and bot/AI-agent accounts among top committers. The
18+
data is consumed by the public `/v1/akrites/packages/detail` endpoint, so it must carry enough
19+
provenance and confidence signal for a downstream security team to trust — or discount — each
20+
contact. The worker follows the ADR-0001 §Worker architecture pattern
21+
(`services/apps/packages_worker/src/security-contacts/`, own entrypoint, shared image).
22+
23+
## Decision
24+
25+
Build the security-contacts sub-worker as a **Temporal-scheduled batch ingestion** (daily cron at
26+
06:00, `ScheduleOverlapPolicy.SKIP`, one 500-repo batch per activity, `continueAsNew` until a batch
27+
comes back empty, 24 h execution timeout across the chain), plus a separate **on-demand
28+
single-purl workflow** invoked by the akrites API when it hits a never-evaluated repo. Each repo
29+
runs a **tiered extractor hierarchy (A > B > D)**; results are reconciled, scored into a `[0, 1]`
30+
confidence with a banded label, and persisted to `security_contacts` (keyed by `repo_id`,
31+
soft-delete semantics) plus policy columns on `repos`.
32+
33+
### Tiered extractor hierarchy
34+
35+
| Tier | Source | Extractor |
36+
| ---- | ------ | --------- |
37+
| A1 | `SECURITY-INSIGHTS.yml` (root, `.github/`, `.gitlab/`) | `securityInsights.ts` |
38+
| A2 | GitHub private vulnerability reporting status (authed) | `pvr.ts` |
39+
| A3 | `SECURITY_CONTACTS` / `OWNERS` (k8s-style) | `securityContactsFile.ts` |
40+
| A4 | RFC 9116 `security.txt` on the project homepage | `securityTxt.ts` |
41+
| B1 | `SECURITY.md` (root, `.github/`, `docs/`) | `securityMd.ts` |
42+
| B2 | Registry manifests — npm, PyPI, Maven, Cargo, NuGet, RubyGems, Composer | `extractors/registry/` |
43+
| D | Top-3 committers (last 90 days) + repo owner profile; static Go ecosystem fallback | `topCommitters.ts`, `repoOwner.ts`, `registry/go.ts` |
44+
45+
Supporting decisions:
46+
47+
- **One git-tree fetch per repo** (`gitTree.ts`) is shared by all file-probing extractors, instead
48+
of each extractor blind-probing well-known paths with 404s.
49+
- **A2 vetoes B1's PVR guess**: when the authed API authoritatively reports PVR disabled, a
50+
"use GitHub PVR" mention parsed out of SECURITY.md is dropped. The PVR call is skipped entirely
51+
for known-archived repos (GitHub rejects them with 422).
52+
- **Maven walks the parent-POM chain, but only group-related parents**: developer emails often
53+
live only in a shared parent POM (`jackson-bom`, `commons-parent`, …), so a leaf POM that yields
54+
none triggers traversal up the parent chain — restricted to parents in a related groupId.
55+
Generic convenience parents (`oss-parent`, `spring-boot-starter-parent`) are excluded: they
56+
would donate maintainers unrelated to the actual project. Apache-convention obfuscated emails
57+
("ggregory at apache.org") are deobfuscated on the way.
58+
- **Tier D is a gated last resort**: committers and owner are fetched only when no usable
59+
(non-junk, reachable) higher-tier contact exists. Bot and AI-agent committer accounts
60+
(`dependabot`, `renovate`, `github-actions`, `claude`, `copilot`, …, plus `[bot]` suffixes) are
61+
excluded.
62+
- **Go packages get a static ecosystem fallback**: Go has no registry manifest carrying contact
63+
metadata (the module proxy and pkg.go.dev expose none), so the Go fetcher makes no HTTP call and
64+
always emits two tier-D `security-team` contacts per the Go security policy —
65+
`security@golang.org` and the `https://g.co/vulnz` report form. At tier D they never outrank a
66+
real repo-level contact, and they don't suppress the committers/owner gate (which only checks
67+
for usable contacts above tier D), so a Go repo is never left with an empty answer.
68+
- **Tier C (CODEOWNERS / repo admins / org owners) is not implemented** — the type system reserves
69+
it, but tier D's committers + owner covered the "no contact at all" gap first. C remains a
70+
follow-up if precision between B and D proves insufficient.
71+
- Extractors run under `Promise.allSettled`; one failing source never sinks the repo
72+
(`status: 'partial'` changes the write semantics — see below).
73+
74+
### Scoring
75+
76+
Computed by the pure function `score.ts`, after reconciliation — so a merged contact is scored
77+
with its highest surviving tier and role and the union of its provenance:
78+
79+
```
80+
raw = 0.55·tier + 0.20·channel + 0.15·freshness + 0.10·corroboration
81+
− 0.25 (if CDP-unverified) − 0.05 (if channel is github-handle)
82+
score = round(clamp(raw, 0, 1) · 1000) / 1000 -- stored as NUMERIC(4,3)
83+
```
84+
85+
**Tier** — A = 1.0, B = 0.7, C = 0.4, D = 0.2.
86+
87+
**Channel quality** — evaluated in this order:
88+
89+
1. A CDP-unverified contact (see penalty rules below) gets a flat **0.35** regardless of channel.
90+
2. `email`: local-part in the security set (`security`, `secure`, `psirt`, `sirt`, `cert`, `cve`,
91+
`abuse`, `vuln`, `vulnerability`, `vulnerabilities`, `disclosure`) *or any local-part starting
92+
with `security`* = **1.0**; local-part in the generic set (`info`, `team`, `contact`, `hello`,
93+
`hi`, `support`, `admin`, `help`, `maintainers`, `dev`, `devs`, `opensource`, `open-source`,
94+
`office`, `mail`) = **0.7**; anything else (an individual's address) = **0.6**. Matching is on
95+
the lowercased, trimmed local-part.
96+
3. `github-pvr` = **0.95**; `url` / `web-form` = **0.5**; `github-handle` = **0.4**.
97+
98+
**Freshness** — takes the **most recent** `declaredAt ?? fetchedAt` across all provenance
99+
entries: 1.0 at ≤ 90 days, 0.0 at ≥ 730 days, linear in between. No parseable timestamp at all
100+
scores **0** — unknown age is treated as stale, not fresh.
101+
102+
**Corroboration** — counts **distinct provenance `source` identifiers**, so it measures
103+
independent extractors, not the same file fetched twice: 3+ sources = 1.0, exactly 2 = 0.5,
104+
1 = 0. `cdp-*` sources are excluded from the count: a CDP identity lookup re-attests a value we
105+
already had, it is not an independent attestation.
106+
107+
**Penalties**:
108+
109+
- **Handle-only −0.05** (`channel === 'github-handle'`): a bare handle is not directly
110+
contactable; the penalty keeps it just below an equivalent resolved email for the same person.
111+
- **CDP-unverified −0.25** (plus the 0.35 channel quality above): applies only when **every**
112+
provenance entry is `cdp-unverified`. A single `cdp-verified` resolution — or any real
113+
extractor source, e.g. the same email independently found in a manifest — lifts the penalty
114+
entirely.
115+
116+
**Confidence band** (`securityContactConfidenceBand` in the data-access-layer, applied to the
117+
rounded score): PRIMARY ≥ 0.80 > SECONDARY ≥ 0.55 > FALLBACK ≥ 0.30 > NONE. Both `score` and the
118+
band are stored per contact; `packageConfidence` on the API is the band of the max score.
119+
120+
Worked examples:
121+
122+
- `security@project.org` from a fresh `SECURITY-INSIGHTS.yml`, corroborated by `security.txt`:
123+
0.55·1.0 + 0.20·1.0 + 0.15·1.0 + 0.10·0.5 = **0.95 → PRIMARY**.
124+
- An individual maintainer email from a 1-year-old npm manifest, single source:
125+
0.55·0.7 + 0.20·0.6 + 0.15·0.57 + 0 ≈ **0.59 → SECONDARY**.
126+
- A tier-D committer's email resolved only via an unverified CDP identity, fresh, no
127+
corroboration: 0.55·0.2 + 0.20·0.35 + 0.15·1.0 + 0 − 0.25 = **0.08 → NONE**.
128+
129+
**Reachability is orthogonal to the score**: `reachable` / `reachability_reason` (from
130+
`classifyEmailReachability` in `@crowd/common`) are stored alongside but never feed the formula —
131+
they gate the tier-D fallback (an unreachable email doesn't count as a "usable" higher-tier
132+
contact) and let consumers filter, without hiding that a source declared the address.
133+
134+
Weights, penalties, and band cut-offs are the spec's starting point; calibration against a
135+
hand-labeled set is a post-rollout follow-up.
136+
137+
### Reconciliation and noise filtering
138+
139+
`reconcile.ts` runs: junk filter (RFC 2606 placeholder domains, generic hosts like
140+
`docs.github.com`/`dependabot.com`, localhost) → exact-match merge on `channel + normalized value`
141+
(provenance concatenated, highest role/tier wins) → identity-link merge collapsing a bare
142+
`github-handle` into an email that carries the same explicit `handle` field (never matched by
143+
display name — two people can share a name) → provenance dedup → score → stable sort
144+
(score, role priority, tier, value). Email contacts additionally carry a
145+
`reachable`/`reachability_reason` classification from `@crowd/common`.
146+
147+
### Handle verification and CDP email resolution
148+
149+
Two distinct trust problems, two mechanisms:
150+
151+
- **Registry usernames are only candidates.** RubyGems/NuGet owner names are *guessed* GitHub
152+
logins; `verifyHandleCandidates.ts` confirms a candidate only when the same login owns the repo
153+
or appears in its top-100 contributors. Unconfirmed candidates are dropped entirely.
154+
- **Confirmed handles are resolved to emails through CDP's identity graph**
155+
(`resolveCdpEmails.ts`, read-only connection to the CDP database): verified emails are emitted
156+
with source `cdp-verified`; unverified ones with `cdp-unverified` and the scoring penalty above.
157+
GitHub noreply emails (`…@users.noreply.github.com`) are parsed back into handles first so they
158+
ride the same resolution path.
159+
160+
### GitHub API access layer
161+
162+
All GitHub calls go through one rate-limit-aware gateway (`githubToken.ts`), reusing the
163+
enricher's GitHub App auth and `InstallationPool`:
164+
165+
- An **app-wide semaphore caps concurrent GitHub requests at 50** — GitHub's secondary limit
166+
rejects bursts above ~100 per app, and repo-level concurrency (100) would otherwise multiply
167+
into far more in-flight calls.
168+
- **Primary rate limits park the offending installation** until its reset and rotate to another;
169+
**secondary limits are waited out** (`retry-after`), since they are app-wide and switching
170+
installations cannot help.
171+
- **Absent is not an error**: 404/410/422/451 return a null body so extractors treat "file/repo
172+
not there" as a normal outcome instead of a failure that marks the repo `partial`.
173+
- **No App configured → unauthenticated fallback** with a warning, so local/dev runs still work
174+
at the unauthenticated quota.
175+
176+
### Write semantics and refresh cadence
177+
178+
- **Soft-delete, not replace**: a full pass marks the repo's active rows `deleted_at = NOW()`,
179+
then bulk-upserts on `(repo_id, channel, value)` with `deleted_at = NULL` — rediscovered
180+
contacts are revived in place, disappeared ones stay soft-deleted. Readers filter
181+
`deleted_at IS NULL`.
182+
- **Partial passes merge only**: when an extractor failed, the soft-delete step is skipped — a
183+
source that wasn't consulted cannot wipe contacts it didn't see. Stale rows are cleaned on the
184+
next fully-successful pass.
185+
- **Batched, chunked persistence**: extraction results for the whole batch are collected in memory
186+
and written in chunks of 100 repos, one transaction per chunk. Per-repo transactions at repo
187+
concurrency 100 exhausted the packages-db pool and were the measured sweep bottleneck. A failing
188+
chunk only re-extracts its own 100 repos next sweep; remaining chunks are still attempted.
189+
- **Cadence via `repos.contacts_last_refreshed`**: never evaluated → always eligible; evaluated
190+
with no contacts → retry after 20 h (just under the daily tick); has contacts → refresh after
191+
156 h (just under weekly). Failed repos are marked attempted so the sweep always advances.
192+
193+
### Scheduling and the on-demand path
194+
195+
The batch activity heartbeats on a fixed 30 s cadence (a single slow repo can outlast the 2-minute
196+
heartbeat timeout even while all slots are busy) and checks the Temporal cancellation signal so a
197+
superseded attempt stops instead of racing its retry. The on-demand workflow
198+
(`ingestSecurityContactsForPurlWorkflow`) uses a short 45 s activity timeout — a caller awaiting an
199+
API cache miss must not hang. It selects the same best-repo the read side surfaces (mirroring the
200+
`getPackageDetailByPurl` LATERAL), applies **no `is_critical` filter** (non-critical purls are
201+
exactly what the path exists for) and **no host filter** (non-GitHub repos degrade gracefully;
202+
filtering would leave them permanently NULL and re-trigger the path on every request).
203+
204+
On the read side, the API contract distinguishes **`securityContacts: null`** (repo never
205+
evaluated — `contacts_last_refreshed IS NULL`, which is what triggers the on-demand ingest) from
206+
**`[]`** (evaluated, nothing found — no re-ingest until the daily retry). `packageConfidence` is
207+
derived at read time as the band of the highest contact score, not stored.
208+
209+
## Alternatives Considered
210+
211+
### Alternative 1: Standalone polling-loop worker (the `github-repos-enricher` pattern)
212+
- **Pros**: simplest runtime; the original implementation plan specified it; proven pattern in
213+
this service; no Temporal coupling.
214+
- **Cons**: scheduling, retry, overlap protection, and run observability all hand-rolled; no
215+
natural home for the synchronous on-demand single-purl path the API needs.
216+
- **Why not**: ADR-0001 already designates Temporal (`workflows.ts`/`activities.ts`/`schedule.ts`,
217+
`SKIP` overlap) as the standard for ingestion sub-workers, with the enricher's loop explicitly
218+
marked as legacy-to-migrate. The on-demand purl workflow settled it — it needs a client-invocable,
219+
awaitable execution, which Temporal gives for free and a polling loop does not.
220+
221+
### Alternative 2: Hard `DELETE` + `INSERT` per repo (the plan's original write model)
222+
- **Pros**: simplest idempotent recompute; no `deleted_at` filtering for readers.
223+
- **Cons**: a partial pass (one failed extractor) wipes contacts the failed source discovered
224+
earlier; row identity churns every sweep; per-repo transactions at concurrency 100 overwhelm a
225+
pool sized for far fewer connections.
226+
- **Why not**: an extractor outage (e.g. a registry API down for a day) would silently erase good
227+
contacts fleet-wide. Soft-delete + upsert keeps history, lets partial passes merge safely, and
228+
the chunked batch write removed the measured persistence bottleneck.
229+
230+
### Alternative 3: Trust registry owner usernames as GitHub handles directly
231+
- **Pros**: no extra GitHub API call; more contacts surfaced.
232+
- **Cons**: a RubyGems/NuGet username and a GitHub login are separate namespaces — an unrelated
233+
person or bot can hold the same name.
234+
- **Why not**: emitting a wrong person as a *security contact* is worse than emitting nothing.
235+
Corroboration against the repo's contributors/owner costs one API call per repo and removes the
236+
collision class entirely.
237+
238+
### Alternative 4: Always emit tier D committers and owner
239+
- **Pros**: maximal coverage; no gating logic.
240+
- **Cons**: floods well-documented repos with low-confidence individual contacts; surfaces
241+
individuals who never volunteered for security contact duty; wastes two GitHub calls per repo.
242+
- **Why not**: tier D exists only to avoid an empty answer. When a usable A/B contact exists, the
243+
committers add noise, not signal — the gate keeps D rows out of exactly the repos that don't
244+
need them.
245+
246+
### Alternative 5: Resolve handles via GitHub public profile email only (no CDP lookup)
247+
- **Pros**: no cross-database read into CDP; single data source.
248+
- **Cons**: most GitHub profiles expose no public email, so most confirmed handles would remain
249+
handle-only contacts (channel quality 0.4) that a security team cannot actually write to.
250+
- **Why not**: CDP's identity graph already links GitHub handles to verified emails at meaningful
251+
coverage. Both paths are used — public profile email where present, CDP resolution on top —
252+
with the verified/unverified distinction preserved in provenance and scoring.
253+
254+
## Consequences
255+
256+
### Positive
257+
- One queryable, confidence-banded contact source per repo, with full per-contact provenance
258+
(source, path, fetch/declared timestamps) — consumers can audit why any contact exists.
259+
- Idempotent, self-advancing sweep: failed repos are marked attempted, failed chunks re-extract in
260+
isolation, and re-runs converge instead of duplicating.
261+
- The on-demand path fills coverage for non-critical purls lazily, exactly when the API needs them.
262+
- Pure scoring/reconcile functions are unit-tested in isolation (`__tests__/`).
263+
264+
### Negative
265+
- GitHub API budget: tree fetch, PVR check, contributor verification, and tier D lookups consume
266+
the shared GitHub App token pool alongside the enricher.
267+
- Readers must remember `deleted_at IS NULL`; the soft-delete convention is enforced only by review.
268+
- Cross-database coupling: CDP email resolution needs a read connection to the CDP database; an
269+
outage degrades (logged, contacts kept unresolved) but coverage silently drops.
270+
- Scoring weights and the 20 h/156 h cadences are judgment values, not calibrated ones.
271+
- Tier C is a hole in the hierarchy: repos with CODEOWNERS but no security files skip straight
272+
from B to committers.
273+
274+
### Risks
275+
- **Wrong-person contact despite corroboration** — a top committer or repo owner is not
276+
necessarily a security contact. Mitigated by tier D's 0.2 tier score, the `committer`/`org-owner`
277+
roles, and confidence bands that push these to FALLBACK; consumers are expected to respect bands.
278+
- **Single-writer assumption**`writeContacts` takes no lock; correctness relies on the Temporal
279+
schedule (SKIP overlap) and heartbeat-based supersession keeping one writer per repo. A future
280+
second caller (e.g. a backfill script) must respect this or add locking.
281+
- **Source-format drift** — SECURITY-INSIGHTS schema versions, registry API shapes, and GitHub's
282+
`stats/contributors` 202-polling behavior all change over time. Extractor isolation limits blast
283+
radius to one source; fixture-based tests catch parser regressions.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Use the `/adr` skill in Claude Code to record new ADRs or query past decisions.
1616
| [ADR-0007](./0007-test-factory-primitives-and-defaults.md) | Test factory primitives and defaults | accepted | 2026-07-10 |
1717
| [ADR-0008](./0008-how-we-write-unit-tests.md) | How we write unit tests | accepted | 2026-07-13 |
1818
| [ADR-0009](./0009-packagist-worker-design-decisions.md) | Packagist worker — design decisions | accepted | 2026-07-13 |
19+
| [ADR-0010](./0010-security-contacts-worker.md) | Security contacts — tiered extraction, confidence scoring, and Temporal batch ingestion | accepted | 2026-07-21 |
1920

2021
## Why ADRs?
2122

0 commit comments

Comments
 (0)