Skip to content

Commit a19af95

Browse files
committed
feat: add on-demand security-contacts ingest for a single purl (CM-1313)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 4604ca5 commit a19af95

7 files changed

Lines changed: 165 additions & 9 deletions

File tree

backend/src/api/public/v1/packages/getPackage.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'crypto'
2+
13
import type { Request, Response } from 'express'
24

35
import { NotFoundError } from '@crowd/common'
@@ -8,6 +10,7 @@ import {
810
getStewardshipSummary,
911
securityContactConfidenceBand,
1012
} from '@crowd/data-access-layer'
13+
import { WorkflowIdConflictPolicy, WorkflowIdReusePolicy } from '@crowd/temporal'
1114

1215
import { getPackagesQx } from '@/db/packagesDb'
1316
import { ok } from '@/utils/api'
@@ -32,16 +35,38 @@ function repoMappingLabel(confidence: number | null): 'High' | 'Medium' | 'Low'
3235
return 'Low'
3336
}
3437

38+
// Deterministic per-purl id: concurrent requests for the same purl attach to the
39+
// same workflow run (WorkflowIdConflictPolicy.USE_EXISTING below) instead of each
40+
// kicking off its own ingest.
41+
function ondemandWorkflowId(purl: string): string {
42+
return `security-contacts-ondemand/${createHash('sha1').update(purl).digest('hex')}`
43+
}
44+
3545
export async function getPackage(req: Request, res: Response): Promise<void> {
3646
const { purl } = validateOrThrow(purlQuerySchema, req.query)
3747

3848
const qx = await getPackagesQx()
39-
const pkg = await getPackageDetailByPurl(qx, purl)
49+
let pkg = await getPackageDetailByPurl(qx, purl)
4050

4151
if (!pkg) {
4252
throw new NotFoundError()
4353
}
4454

55+
if (pkg.contactsLastRefreshed == null) {
56+
try {
57+
await req.temporal.workflow.execute('ingestSecurityContactsForPurlWorkflow', {
58+
taskQueue: 'packages-worker',
59+
workflowId: ondemandWorkflowId(purl),
60+
workflowIdReusePolicy: WorkflowIdReusePolicy.ALLOW_DUPLICATE,
61+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.USE_EXISTING,
62+
args: [purl],
63+
})
64+
pkg = (await getPackageDetailByPurl(qx, purl)) ?? pkg
65+
} catch (err) {
66+
req.log.warn(err, 'On-demand security contacts ingest failed — serving cached detail')
67+
}
68+
}
69+
4570
const [{ rows: advisories }, stewardshipSummary] = await Promise.all([
4671
getAdvisoriesByPackageId(qx, pkg.id),
4772
pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null,

services/apps/packages_worker/src/activities.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ export {
3232
} from './pypi/activities'
3333
export { getCriticalPypiCount } from './pypi/downloads/getCriticalPypiCount'
3434
export { processNuGetBatch } from './nuget/activities'
35-
export { processSecurityContactsBatch } from './security-contacts/activities'
35+
export {
36+
processSecurityContactsBatch,
37+
ingestSecurityContactsForPurlActivity,
38+
} from './security-contacts/activities'

services/apps/packages_worker/src/security-contacts/activities.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getServiceChildLogger } from '@crowd/logging'
33
import { getSecurityContactsConfig } from '../config'
44
import { getPackagesDb } from '../db'
55

6+
import { IngestSingleResult, ingestSecurityContactsForPurl } from './ingestSingle'
67
import { BatchResult, processBatch } from './processBatch'
78

89
const log = getServiceChildLogger('security-contacts-activity')
@@ -15,3 +16,14 @@ export async function processSecurityContactsBatch(): Promise<BatchResult> {
1516
log.info({ ...result }, 'Security contacts batch activity complete')
1617
return result
1718
}
19+
20+
export async function ingestSecurityContactsForPurlActivity(
21+
purl: string,
22+
): Promise<IngestSingleResult> {
23+
const config = getSecurityContactsConfig()
24+
const qx = await getPackagesDb()
25+
26+
const result = await ingestSecurityContactsForPurl(qx, config, purl)
27+
log.info({ purl, ...result }, 'On-demand security contacts ingest activity complete')
28+
return result
29+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
2+
import { getServiceChildLogger } from '@crowd/logging'
3+
4+
import { getSecurityContactsConfig } from '../config'
5+
6+
import { buildBaseDeps, processRepo } from './processBatch'
7+
import { RepoPackage, RepoTarget } from './types'
8+
9+
const log = getServiceChildLogger('security-contacts-ondemand')
10+
11+
type Config = ReturnType<typeof getSecurityContactsConfig>
12+
13+
export interface IngestSingleResult {
14+
found: boolean
15+
repoId?: string
16+
}
17+
18+
interface SingleRepoRow {
19+
repoId: string
20+
url: string
21+
homepage: string | null
22+
archived: boolean | null
23+
packages: RepoPackage[] | null
24+
}
25+
26+
// Mirrors the best-repo LATERAL selection in getPackageDetailByPurl
27+
// (services/libs/data-access-layer/src/osspckgs/api.ts) so the repo we ingest is the
28+
// one the read side surfaces. No `is_critical` filter — non-critical purls are exactly
29+
// what this on-demand path exists for. `packages` aggregates every package linked to
30+
// the repo (not just the requested purl) so extractors see every ecosystem, matching
31+
// the shape the batch sweep builds.
32+
async function findBestRepoForPurl(qx: QueryExecutor, purl: string): Promise<SingleRepoRow | null> {
33+
return qx.selectOneOrNone(
34+
`
35+
SELECT r.id::text AS "repoId",
36+
r.url,
37+
r.homepage,
38+
r.archived,
39+
(
40+
SELECT json_agg(json_build_object('purl', p2.purl, 'ecosystem', p2.ecosystem))
41+
FROM package_repos pr2
42+
JOIN packages p2 ON p2.id = pr2.package_id
43+
WHERE pr2.repo_id = r.id
44+
) AS packages
45+
FROM packages p
46+
JOIN LATERAL (
47+
SELECT pr.repo_id
48+
FROM package_repos pr
49+
WHERE pr.package_id = p.id
50+
ORDER BY pr.confidence DESC, (pr.source = 'declared') DESC
51+
LIMIT 1
52+
) best ON true
53+
JOIN repos r ON r.id = best.repo_id
54+
WHERE p.purl = $(purl) AND r.host = 'github'
55+
`,
56+
{ purl },
57+
)
58+
}
59+
60+
function toTarget(row: SingleRepoRow): RepoTarget {
61+
return {
62+
repoId: row.repoId,
63+
url: row.url,
64+
homepage: row.homepage,
65+
archived: row.archived,
66+
packages: row.packages ?? [],
67+
}
68+
}
69+
70+
/**
71+
* On-demand ingest for a single purl, bypassing the daily critical-only sweep.
72+
* Used when the akrites API hits a repo that has never been evaluated
73+
* (repos.contacts_last_refreshed IS NULL) — see security-contacts.md.
74+
*/
75+
export async function ingestSecurityContactsForPurl(
76+
qx: QueryExecutor,
77+
config: Config,
78+
purl: string,
79+
): Promise<IngestSingleResult> {
80+
const row = await findBestRepoForPurl(qx, purl)
81+
if (!row) {
82+
log.info({ purl }, 'On-demand security contacts: no linked github repo found — skipping')
83+
return { found: false }
84+
}
85+
86+
const target = toTarget(row)
87+
const baseDeps = buildBaseDeps(config)
88+
await processRepo(target, baseDeps, qx)
89+
90+
return { found: true, repoId: target.repoId }
91+
}

services/apps/packages_worker/src/security-contacts/processBatch.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,15 @@ function toTarget(row: SweepRow): RepoTarget {
109109
}
110110
}
111111

112-
async function processRepo(
112+
export function buildBaseDeps(config: Config): Omit<ExtractorDeps, 'repoTree'> {
113+
return {
114+
fetchTimeoutMs: FETCH_TIMEOUT_MS,
115+
userAgent: config.userAgent,
116+
githubGet: (path, opts) => githubApiGet(path, FETCH_TIMEOUT_MS, opts),
117+
}
118+
}
119+
120+
export async function processRepo(
113121
target: RepoTarget,
114122
baseDeps: Omit<ExtractorDeps, 'repoTree'>,
115123
qx: QueryExecutor,
@@ -162,11 +170,7 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
162170
const batch = await fetchBatch(qx)
163171
if (batch.length === 0) return { processed: 0 }
164172

165-
const deps: Omit<ExtractorDeps, 'repoTree'> = {
166-
fetchTimeoutMs: FETCH_TIMEOUT_MS,
167-
userAgent: config.userAgent,
168-
githubGet: (path, opts) => githubApiGet(path, FETCH_TIMEOUT_MS, opts),
169-
}
173+
const deps = buildBaseDeps(config)
170174

171175
const targets = batch.map(toTarget)
172176
// Fixed-cadence heartbeat: a slow repo can outlast the 2-minute heartbeatTimeout even while

services/apps/packages_worker/src/security-contacts/workflows.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { continueAsNew, log, proxyActivities } from '@temporalio/workflow'
22

33
import type * as activities from './activities'
4+
import type { IngestSingleResult } from './ingestSingle'
45

56
const acts = proxyActivities<typeof activities>({
67
startToCloseTimeout: '30 minutes',
@@ -14,6 +15,17 @@ const acts = proxyActivities<typeof activities>({
1415
},
1516
})
1617

18+
// Single-repo, synchronous, on-demand path: short bound (no continueAsNew/heartbeat —
19+
// one repo's extractors, not a whole sweep), so a caller awaiting the result (e.g. the
20+
// akrites API on a cache miss) doesn't hang.
21+
const singleActs = proxyActivities<typeof activities>({
22+
startToCloseTimeout: '45 seconds',
23+
retry: {
24+
initialInterval: '5 seconds',
25+
maximumAttempts: 2,
26+
},
27+
})
28+
1729
export async function ingestSecurityContacts(): Promise<void> {
1830
const result = await acts.processSecurityContactsBatch()
1931
if (result.processed === 0) {
@@ -22,3 +34,9 @@ export async function ingestSecurityContacts(): Promise<void> {
2234
}
2335
await continueAsNew<typeof ingestSecurityContacts>()
2436
}
37+
38+
export async function ingestSecurityContactsForPurlWorkflow(
39+
purl: string,
40+
): Promise<IngestSingleResult> {
41+
return singleActs.ingestSecurityContactsForPurlActivity(purl)
42+
}

services/apps/packages_worker/src/workflows/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ export {
2525
ingestPypiDownloadsDaily,
2626
} from '../pypi/downloads/ingestPypiDownloads'
2727
export { ingestNuGetPackages } from '../nuget/workflows'
28-
export { ingestSecurityContacts } from '../security-contacts/workflows'
28+
export {
29+
ingestSecurityContacts,
30+
ingestSecurityContactsForPurlWorkflow,
31+
} from '../security-contacts/workflows'

0 commit comments

Comments
 (0)