Skip to content
Merged
10 changes: 10 additions & 0 deletions services/apps/packages_worker/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export function getPackagesDbConfig() {
}
}

export function getCdpDbConfig() {
return {
host: requireEnv('CROWD_DB_READ_HOST'),
port: requireEnvInt('CROWD_DB_PORT'),
database: requireEnv('CROWD_DB_DATABASE'),
user: requireEnv('CROWD_DB_USERNAME'),
password: requireEnv('CROWD_DB_PASSWORD'),
}
}

export function getGithubAppConfig() {
const rawPrivateKey = requireEnv('CROWD_GITHUB_PRIVATE_KEY')
const privateKeyPem = Buffer.from(rawPrivateKey, 'base64').toString('ascii')
Expand Down
7 changes: 6 additions & 1 deletion services/apps/packages_worker/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { pgpQx } from '@crowd/data-access-layer/src/queryExecutor'
import { DbConnection, getDbConnection } from '@crowd/database'

import { getPackagesDbConfig } from './config'
import { getCdpDbConfig, getPackagesDbConfig } from './config'

export async function getPackagesDb() {
const conn = await getDbConnection(getPackagesDbConfig())
return pgpQx(conn)
}

export async function getCdpDb() {
const conn = await getDbConnection(getCdpDbConfig())
Comment thread
mbani01 marked this conversation as resolved.
return pgpQx(conn)
}

// Raw pg-promise connection for the same pool getPackagesDb() wraps
// (getDbConnection caches per host:database). Needed for COPY FROM STDIN via
// pg-copy-streams, which requires direct access to the underlying pg client.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getServiceChildLogger } from '@crowd/logging'

import { getSecurityContactsConfig } from '../config'
import { getPackagesDb } from '../db'
import { getCdpDb, getPackagesDb } from '../db'

import { IngestSingleResult, ingestSecurityContactsForPurl } from './ingestSingle'
import { BatchResult, processBatch } from './processBatch'
Expand All @@ -11,8 +11,9 @@ const log = getServiceChildLogger('security-contacts-activity')
export async function processSecurityContactsBatch(): Promise<BatchResult> {
const config = getSecurityContactsConfig()
const qx = await getPackagesDb()
const cdpQx = await getCdpDb()
Comment thread
mbani01 marked this conversation as resolved.

const result = await processBatch(qx, config)
const result = await processBatch(qx, cdpQx, config)
log.info({ ...result }, 'Security contacts batch activity complete')
return result
}
Expand All @@ -22,8 +23,9 @@ export async function ingestSecurityContactsForPurlActivity(
): Promise<IngestSingleResult> {
const config = getSecurityContactsConfig()
const qx = await getPackagesDb()
const cdpQx = await getCdpDb()

const result = await ingestSecurityContactsForPurl(qx, config, purl)
const result = await ingestSecurityContactsForPurl(qx, cdpQx, config, purl)
log.info({ purl, ...result }, 'On-demand security contacts ingest activity complete')
return result
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function toTarget(row: SingleRepoRow): RepoTarget {
*/
export async function ingestSecurityContactsForPurl(
qx: QueryExecutor,
cdpQx: QueryExecutor,
config: Config,
purl: string,
): Promise<IngestSingleResult> {
Expand All @@ -92,7 +93,7 @@ export async function ingestSecurityContactsForPurl(
const target = toTarget(row)
const baseDeps = buildBaseDeps(config)
try {
await processRepo(target, baseDeps, qx)
await processRepo(target, baseDeps, qx, cdpQx)
} catch (err) {
log.error({ repoId: target.repoId, errMsg: (err as Error).message }, 'Repo processing failed')
await markRepoAttempted(qx, target.repoId).catch(() => undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { extractSecurityMd } from './extractors/securityMd'
import { extractSecurityTxt } from './extractors/securityTxt'
import { githubApiGet } from './githubToken'
import { reconcile } from './reconcile'
import { resolveCdpEmails } from './resolveCdpEmails'
import {
Extractor,
ExtractorDeps,
Expand Down Expand Up @@ -120,6 +121,7 @@ export async function processRepo(
target: RepoTarget,
baseDeps: Omit<ExtractorDeps, 'repoTree'>,
qx: QueryExecutor,
cdpQx: QueryExecutor,
): Promise<void> {
// One tree fetch per repo, shared by extractors that probe well-known paths.
let repoTree: ExtractorDeps['repoTree'] = { paths: null }
Expand Down Expand Up @@ -161,11 +163,27 @@ export async function processRepo(
contacts = contacts.filter((c) => c.channel !== 'github-pvr')
}

const handleContacts = contacts.filter((c) => c.channel === 'github-handle')
if (handleContacts.length > 0) {
try {
contacts.push(...(await resolveCdpEmails(cdpQx, handleContacts)))
Comment thread
mbani01 marked this conversation as resolved.
} catch (err) {
log.warn(
{ repoId: target.repoId, errMsg: (err as Error).message },
'CDP email resolution failed — proceeding without resolved emails',
)
}
Comment thread
mbani01 marked this conversation as resolved.
Comment on lines +170 to +175
}

const scored = reconcile(contacts)
await writeContacts(qx, target.repoId, scored, policies)
}

export async function processBatch(qx: QueryExecutor, config: Config): Promise<BatchResult> {
export async function processBatch(
qx: QueryExecutor,
cdpQx: QueryExecutor,
config: Config,
): Promise<BatchResult> {
const batch = await fetchBatch(qx)
if (batch.length === 0) return { processed: 0 }

Expand All @@ -191,7 +209,7 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
)
}
try {
await processRepo(target, deps, qx)
await processRepo(target, deps, qx, cdpQx)
} catch (err) {
log.error(
{ repoId: target.repoId, errMsg: (err as Error).message },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
findMembersByGithubHandles,
findResolvableEmailsForMembers,
} from '@crowd/data-access-layer/src/members/identities'
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'

import { ProvenanceEntry, RawContact } from './types'

function latestTimestamp(provenance: ProvenanceEntry[]): string {
return provenance
.map((p) => p.declaredAt ?? p.fetchedAt)
.reduce((a, b) => (new Date(b).getTime() > new Date(a).getTime() ? b : a))
}
Comment thread
mbani01 marked this conversation as resolved.

export async function resolveCdpEmails(
Comment thread
mbani01 marked this conversation as resolved.
cdpQx: QueryExecutor,
handleContacts: RawContact[],
): Promise<RawContact[]> {
Comment thread
mbani01 marked this conversation as resolved.
if (handleContacts.length === 0) return []

const handles = [...new Set(handleContacts.map((c) => c.value.toLowerCase()))]
const members = await findMembersByGithubHandles(cdpQx, handles)
if (members.length === 0) return []

const memberIdsByHandle = new Map<string, string[]>()
for (const m of members) {
const key = m.githubHandle.toLowerCase()
memberIdsByHandle.set(key, [...(memberIdsByHandle.get(key) ?? []), m.memberId])
}

const emails = await findResolvableEmailsForMembers(cdpQx, [
...new Set(members.map((m) => m.memberId)),
])
const emailsByMember = new Map<string, { verified: string[]; unverified: string[] }>()
for (const e of emails) {
const bucket = emailsByMember.get(e.memberId) ?? { verified: [], unverified: [] }
;(e.verified ? bucket.verified : bucket.unverified).push(e.email)
emailsByMember.set(e.memberId, bucket)
}

return handleContacts.flatMap((contact) => {
const fetchedAt = latestTimestamp(contact.provenance)
const memberIds = memberIdsByHandle.get(contact.value.toLowerCase()) ?? []
return memberIds.flatMap((memberId) => {
const bucket = emailsByMember.get(memberId)
if (!bucket) return []
const useVerified = bucket.verified.length > 0
const source = useVerified ? 'cdp-verified' : 'cdp-unverified'
return (useVerified ? bucket.verified : bucket.unverified).map((email) => ({
channel: 'email' as const,
value: email,
role: contact.role,
tier: contact.tier,
Comment thread
mbani01 marked this conversation as resolved.
provenance: [{ source, sourceTier: contact.tier, fetchedAt }],
Comment thread
mbani01 marked this conversation as resolved.
}))
Comment thread
cursor[bot] marked this conversation as resolved.
})
})
}
30 changes: 23 additions & 7 deletions services/apps/packages_worker/src/security-contacts/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
securityContactConfidenceBand,
} from '@crowd/data-access-layer/src/osspckgs/api'

import { ContactChannel, ProvenanceEntry, RawContact, SourceTier } from './types'
import { ProvenanceEntry, RawContact, SourceTier } from './types'

const WEIGHTS = { tier: 0.55, channel: 0.2, freshness: 0.15, corroboration: 0.1 }

Expand All @@ -12,6 +12,9 @@ const TIER_SCORE: Record<SourceTier, number> = { A: 1.0, B: 0.7, C: 0.4, D: 0.2
// github-handle contacts carry no resolved email; nudge them below an equivalent email
const HANDLE_ONLY_PENALTY = 0.05

const CDP_UNVERIFIED_CHANNEL_QUALITY = 0.35
const CDP_UNVERIFIED_PENALTY = 0.25

const FRESH_DAYS = 90
const STALE_DAYS = 730
const DAY_MS = 24 * 60 * 60 * 1000
Expand Down Expand Up @@ -55,10 +58,11 @@ function emailQuality(value: string): number {
return 0.6
}

function channelQuality(channel: ContactChannel, value: string): number {
switch (channel) {
function channelQuality(contact: RawContact): number {
if (isCdpUnverified(contact.provenance)) return CDP_UNVERIFIED_CHANNEL_QUALITY
switch (contact.channel) {
case 'email':
return emailQuality(value)
return emailQuality(contact.value)
case 'github-pvr':
return 0.95
case 'web-form':
Expand All @@ -81,23 +85,35 @@ function freshnessScore(provenance: ProvenanceEntry[], now: Date): number {
return 1 - (ageDays - FRESH_DAYS) / (STALE_DAYS - FRESH_DAYS)
}

// Independent = distinct extractors, not the same file re-fetched
// Independent = distinct extractors, not the same file re-fetched. A cdp-* source is an
// identity lookup of the same value, not an independent attestation, so it never corroborates.
function corroborationScore(provenance: ProvenanceEntry[]): number {
const sources = new Set(provenance.map((p) => p.source))
const sources = new Set(provenance.filter((p) => !isCdpSource(p)).map((p) => p.source))
if (sources.size >= 3) return 1.0
if (sources.size === 2) return 0.5
return 0
}

function isCdpSource(p: ProvenanceEntry): boolean {
return p.source.startsWith('cdp-')
}

// A CDP-resolved email is unverified only when every source is a cdp-* lookup and none found a
// verified identity; if a real extractor also found the email, the non-cdp source lifts the penalty.
function isCdpUnverified(provenance: ProvenanceEntry[]): boolean {
return provenance.every(isCdpSource) && provenance.some((p) => p.source === 'cdp-unverified')
}
Comment thread
mbani01 marked this conversation as resolved.

export function scoreContact(
contact: RawContact,
now: Date = new Date(),
): { score: number; confidence: SecurityContactConfidence } {
const raw =
WEIGHTS.tier * TIER_SCORE[contact.tier] +
WEIGHTS.channel * channelQuality(contact.channel, contact.value) +
WEIGHTS.channel * channelQuality(contact) +
WEIGHTS.freshness * freshnessScore(contact.provenance, now) +
WEIGHTS.corroboration * corroborationScore(contact.provenance) -
(isCdpUnverified(contact.provenance) ? CDP_UNVERIFIED_PENALTY : 0) -
(contact.channel === 'github-handle' ? HANDLE_ONLY_PENALTY : 0)

const score = Math.round(Math.min(1, Math.max(0, raw)) * 1000) / 1000
Expand Down
38 changes: 38 additions & 0 deletions services/libs/data-access-layer/src/members/identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,44 @@ export async function findMembersByGithubHandles(
)
}

export async function findResolvableEmailsForMembers(
qx: QueryExecutor,
memberIds: string[],
): Promise<{ memberId: string; email: string; verified: boolean }[]> {
return qx.select(
Comment thread
mbani01 marked this conversation as resolved.
`
WITH emails AS (
SELECT "memberId", lower(value) AS email, bool_or(verified) AS verified
FROM "memberIdentities"
WHERE "memberId" IN ($(memberIds:csv))
AND type = $(emailType)
AND value NOT ILIKE '%@users.noreply.github.com'
AND "deletedAt" IS NULL
GROUP BY "memberId", lower(value)
),
"usernameTwins" AS (
SELECT "memberId", lower(value) AS email
FROM "memberIdentities"
WHERE "memberId" IN ($(memberIds:csv))
AND type = $(usernameType)
AND verified = true
Comment on lines +735 to +736
AND "deletedAt" IS NULL
)
SELECT
e."memberId",
e.email,
(e.verified OR t.email IS NOT NULL) AS verified
FROM emails e
LEFT JOIN "usernameTwins" t ON t."memberId" = e."memberId" AND t.email = e.email
`,
{
memberIds,
emailType: MemberIdentityType.EMAIL,
usernameType: MemberIdentityType.USERNAME,
},
)
}

export async function findVerifiedEmailsByMemberIds(
qx: QueryExecutor,
memberIds: string[],
Expand Down
Loading