|
| 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. |
0 commit comments