-
Notifications
You must be signed in to change notification settings - Fork 728
feat: add on-demand security-contacts ingest for a single purl (CM-1313) #4312
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
base: main
Are you sure you want to change the base?
Changes from all commits
a19af95
471ad1d
ffcd3ac
1402d42
59fdeee
418e40b
a536c7f
24bb6c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { createHash } from 'crypto' | ||
| import type { Request, Response } from 'express' | ||
|
|
||
| import { NotFoundError } from '@crowd/common' | ||
|
|
@@ -8,8 +9,10 @@ import { | |
| getStewardshipSummary, | ||
| securityContactConfidenceBand, | ||
| } from '@crowd/data-access-layer' | ||
| import { WorkflowIdConflictPolicy, WorkflowIdReusePolicy } from '@crowd/temporal' | ||
|
|
||
| import { getPackagesQx } from '@/db/packagesDb' | ||
| import { getPackagesTemporalClient } from '@/db/packagesTemporal' | ||
| import { ok } from '@/utils/api' | ||
| import { validateOrThrow } from '@/utils/validation' | ||
|
|
||
|
|
@@ -32,16 +35,43 @@ function repoMappingLabel(confidence: number | null): 'High' | 'Medium' | 'Low' | |
| return 'Low' | ||
| } | ||
|
|
||
| // Deterministic per-purl id: concurrent requests for the same purl attach to the | ||
| // same workflow run (WorkflowIdConflictPolicy.USE_EXISTING below) instead of each | ||
| // kicking off its own ingest. | ||
| function ondemandWorkflowId(purl: string): string { | ||
| return `security-contacts-ondemand/${createHash('sha1').update(purl).digest('hex')}` | ||
| } | ||
|
|
||
| export async function getPackage(req: Request, res: Response): Promise<void> { | ||
| const { purl } = validateOrThrow(purlQuerySchema, req.query) | ||
|
|
||
| const qx = await getPackagesQx() | ||
| const pkg = await getPackageDetailByPurl(qx, purl) | ||
| let pkg = await getPackageDetailByPurl(qx, purl) | ||
|
|
||
| if (!pkg) { | ||
| throw new NotFoundError() | ||
| } | ||
|
|
||
| if (pkg.contactsLastRefreshed == null) { | ||
|
mbani01 marked this conversation as resolved.
|
||
| try { | ||
| const packagesTemporal = await getPackagesTemporalClient() | ||
| await packagesTemporal.workflow.execute('ingestSecurityContactsForPurlWorkflow', { | ||
| taskQueue: 'packages-worker', | ||
| workflowId: ondemandWorkflowId(purl), | ||
| workflowIdReusePolicy: WorkflowIdReusePolicy.ALLOW_DUPLICATE, | ||
| workflowIdConflictPolicy: WorkflowIdConflictPolicy.USE_EXISTING, | ||
| // Bounds the request's wait if packages-worker is down or its task queue is | ||
| // backed up — the activity's startToCloseTimeout only starts counting once a | ||
| // worker picks the task up, so without this the request could hang indefinitely. | ||
| workflowExecutionTimeout: '60 seconds', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Workflow timeout shorter than retriesMedium Severity The API awaits the workflow with a 60-second execution timeout, while the on-demand activity allows up to 45 seconds per attempt and up to two attempts with retry backoff. A slow first attempt can exceed the workflow cap before the retry finishes, causing Additional Locations (1)Reviewed by Cursor Bugbot for commit 24bb6c1. Configure here. |
||
| args: [purl], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shared repo concurrent ingest raceMedium Severity On-demand workflow IDs are hashed per Reviewed by Cursor Bugbot for commit 471ad1d. Configure here. |
||
| }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Concurrent repo contact writesMedium Severity On-demand ingest can run Reviewed by Cursor Bugbot for commit 59fdeee. Configure here. |
||
| pkg = (await getPackageDetailByPurl(qx, purl)) ?? pkg | ||
| } catch (err) { | ||
| req.log.warn(err, 'On-demand security contacts ingest failed — serving cached detail') | ||
| } | ||
| } | ||
|
mbani01 marked this conversation as resolved.
mbani01 marked this conversation as resolved.
|
||
|
|
||
| const [{ rows: advisories }, stewardshipSummary] = await Promise.all([ | ||
| getAdvisoriesByPackageId(qx, pkg.id), | ||
| pkg.stewardshipId ? getStewardshipSummary(qx, Number(pkg.stewardshipId)) : null, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { IS_DEV_ENV, SERVICE } from '@crowd/common' | ||
| import { Client, Connection, getDataConverter } from '@crowd/temporal' | ||
|
|
||
| import { PACKAGES_TEMPORAL_CONFIG } from '@/conf' | ||
|
|
||
| let _init: Promise<Client> | undefined | ||
|
|
||
| // Separate connection from the API's default req.temporal client — packages_worker | ||
| // (npm/maven/pypi/osv/security-contacts/...) polls task queues in its own Temporal | ||
| // namespace (CROWD_PACKAGES_TEMPORAL_NAMESPACE), not the API's default namespace. | ||
| export function getPackagesTemporalClient(): Promise<Client> { | ||
| if (!_init) { | ||
| if (!PACKAGES_TEMPORAL_CONFIG?.serverUrl) { | ||
| throw new Error('Packages Temporal is not configured — set CROWD_PACKAGES_TEMPORAL_NAMESPACE') | ||
|
mbani01 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const cfg = PACKAGES_TEMPORAL_CONFIG | ||
| _init = Connection.connect({ | ||
| address: cfg.serverUrl, | ||
| tls: | ||
| cfg.certificate && cfg.privateKey | ||
| ? { | ||
| clientCertPair: { | ||
| crt: Buffer.from(cfg.certificate, 'base64'), | ||
| key: Buffer.from(cfg.privateKey, 'base64'), | ||
| }, | ||
| } | ||
| : undefined, | ||
| }) | ||
| .then( | ||
| async (connection) => | ||
| new Client({ | ||
| connection, | ||
| namespace: cfg.namespace, | ||
| identity: SERVICE, | ||
| dataConverter: IS_DEV_ENV ? undefined : await getDataConverter(), | ||
| }), | ||
| ) | ||
| .catch((err) => { | ||
| _init = undefined | ||
| throw err | ||
| }) | ||
| } | ||
| return _init | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' | ||
| import { getServiceChildLogger } from '@crowd/logging' | ||
|
|
||
| import { getSecurityContactsConfig } from '../config' | ||
|
|
||
| import { buildBaseDeps, processRepo } from './processBatch' | ||
| import { RepoPackage, RepoTarget } from './types' | ||
| import { markRepoAttempted } from './writeContacts' | ||
|
|
||
| const log = getServiceChildLogger('security-contacts-ondemand') | ||
|
|
||
| type Config = ReturnType<typeof getSecurityContactsConfig> | ||
|
|
||
| export interface IngestSingleResult { | ||
| found: boolean | ||
| repoId?: string | ||
| } | ||
|
|
||
| interface SingleRepoRow { | ||
| repoId: string | ||
| url: string | ||
| homepage: string | null | ||
| archived: boolean | null | ||
| packages: RepoPackage[] | null | ||
| } | ||
|
|
||
| // Mirrors the best-repo LATERAL selection in getPackageDetailByPurl | ||
| // (services/libs/data-access-layer/src/osspckgs/api.ts) so the repo we ingest is the | ||
| // one the read side surfaces. No `is_critical` filter — non-critical purls are exactly | ||
| // what this on-demand path exists for. `packages` aggregates every package linked to | ||
| // the repo (not just the requested purl) so extractors see every ecosystem, matching | ||
|
mbani01 marked this conversation as resolved.
|
||
| // the shape the batch sweep builds. | ||
| // | ||
| // No host filter: processRepo already degrades gracefully for non-github repos (the | ||
| // github-specific extractors no-op, security.txt/registry-manifest extractors still run) — | ||
| // filtering here would leave non-github repos permanently NULL, re-triggering this | ||
| // on-demand path on every single request. | ||
| async function findBestRepoForPurl(qx: QueryExecutor, purl: string): Promise<SingleRepoRow | null> { | ||
| return qx.selectOneOrNone( | ||
| ` | ||
| SELECT r.id::text AS "repoId", | ||
| r.url, | ||
| r.homepage, | ||
| r.archived, | ||
| ( | ||
| SELECT json_agg(json_build_object('purl', p2.purl, 'ecosystem', p2.ecosystem)) | ||
| FROM package_repos pr2 | ||
| JOIN packages p2 ON p2.id = pr2.package_id | ||
| WHERE pr2.repo_id = r.id | ||
| ) AS packages | ||
| FROM packages p | ||
| JOIN LATERAL ( | ||
| SELECT pr.repo_id | ||
| FROM package_repos pr | ||
| WHERE pr.package_id = p.id | ||
| ORDER BY pr.confidence DESC, (pr.source = 'declared') DESC | ||
| LIMIT 1 | ||
| ) best ON true | ||
| JOIN repos r ON r.id = best.repo_id | ||
| WHERE p.purl = $(purl) | ||
| `, | ||
| { purl }, | ||
| ) | ||
| } | ||
|
|
||
| function toTarget(row: SingleRepoRow): RepoTarget { | ||
| return { | ||
| repoId: row.repoId, | ||
| url: row.url, | ||
| homepage: row.homepage, | ||
| archived: row.archived, | ||
| packages: row.packages ?? [], | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * On-demand ingest for a single purl, bypassing the daily critical-only sweep. | ||
| * Used when the akrites API hits a repo that has never been evaluated | ||
| * (repos.contacts_last_refreshed IS NULL). | ||
| */ | ||
| export async function ingestSecurityContactsForPurl( | ||
| qx: QueryExecutor, | ||
| config: Config, | ||
| purl: string, | ||
| ): Promise<IngestSingleResult> { | ||
| const row = await findBestRepoForPurl(qx, purl) | ||
| if (!row) { | ||
| log.info({ purl }, 'On-demand security contacts: no linked repo found — skipping') | ||
| return { found: false } | ||
| } | ||
|
|
||
| const target = toTarget(row) | ||
| const baseDeps = buildBaseDeps(config) | ||
| try { | ||
| await processRepo(target, baseDeps, qx) | ||
| } catch (err) { | ||
| log.error({ repoId: target.repoId, errMsg: (err as Error).message }, 'Repo processing failed') | ||
| await markRepoAttempted(qx, target.repoId).catch(() => undefined) | ||
| } | ||
|
Comment on lines
+94
to
+99
|
||
|
|
||
| return { found: true, repoId: target.repoId } | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.