diff --git a/backend/src/api/public/v1/akrites-external/index.ts b/backend/src/api/public/v1/akrites-external/index.ts index 1d0b15ec2c..e60f35fc3b 100644 --- a/backend/src/api/public/v1/akrites-external/index.ts +++ b/backend/src/api/public/v1/akrites-external/index.ts @@ -7,6 +7,8 @@ import { SCOPES } from '@/security/scopes' import { getAkritesExternalAdvisoryDetail } from '../packages/getAkritesExternalAdvisoryDetail' import { getAkritesExternalAdvisoryDetailBatch } from '../packages/getAkritesExternalAdvisoryDetailBatch' +import { getAkritesExternalContactDetail } from '../packages/getAkritesExternalContactDetail' +import { getAkritesExternalContactDetailBatch } from '../packages/getAkritesExternalContactDetailBatch' import { getAkritesExternalPackageDetail } from '../packages/getAkritesExternalPackageDetail' import { getAkritesExternalPackageDetailBatch } from '../packages/getAkritesExternalPackageDetailBatch' @@ -36,5 +38,17 @@ export function akritesExternalRouter(): Router { advisoriesSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalAdvisoryDetailBatch)) router.use('/advisories', advisoriesSubRouter) + // Security contacts expose contact PII (e.g. reporter emails), so the contract gates + // them behind a dedicated cdp:maintainers:read scope and explicitly forbids reaching + // them via the packages scope. That scope isn't issued by Auth0 yet, so reuse the + // closest issued one — READ_MAINTAINER_ROLES (maintainer data) — NOT READ_PACKAGES. + // TODO: swap for cdp:maintainers:read once issued. + const contactsSubRouter = Router() + contactsSubRouter.use(rateLimiter) + contactsSubRouter.use(requireScopes([SCOPES.READ_MAINTAINER_ROLES])) + contactsSubRouter.get('/detail', safeWrap(getAkritesExternalContactDetail)) + contactsSubRouter.post(/^\/detail:batch\/?$/, safeWrap(getAkritesExternalContactDetailBatch)) + router.use('/contacts', contactsSubRouter) + return router } diff --git a/backend/src/api/public/v1/akrites-external/openapi.yaml b/backend/src/api/public/v1/akrites-external/openapi.yaml index d1e5f85cba..8bb8f78366 100644 --- a/backend/src/api/public/v1/akrites-external/openapi.yaml +++ b/backend/src/api/public/v1/akrites-external/openapi.yaml @@ -9,8 +9,8 @@ info: entirely between Akrites and Auth0. - Packages and Advisories endpoints are implemented. Blast Radius and - Contacts are specced separately and not yet built. + Packages, Advisories and Contacts endpoints are implemented. Blast Radius + is specced separately and not yet built. TODO: scopes below (read:packages, read:stewardships) are the existing @@ -37,6 +37,14 @@ tags: issues it, the implementation reuses read:packages (advisories need no stewardship read). Confirm the final scope name (read:advisories vs cdp:advisories:read) with Akrites/product. + - name: Contacts + description: > + Security contacts for a package — includes contact PII (e.g. reporter + emails). The contract gates these behind a dedicated cdp:maintainers:read + scope and forbids reaching them via the packages scope; until Auth0 issues + it, the implementation requires read:maintainer-roles (NOT read:packages). + The response shape is still under discussion upstream (reportingMethods / + reportingGuidelines / integrationHints are reserved and always null today). components: securitySchemes: @@ -55,6 +63,7 @@ components: scopes: read:packages: Read package detail read:stewardships: Read package stewardship data + read:maintainer-roles: Read security contacts (interim scope for Contacts; see the Contacts tag) schemas: Error: @@ -356,6 +365,215 @@ components: allOf: - $ref: '#/components/schemas/AdvisoryDetail' + ContactConfidenceBand: + type: string + enum: [PRIMARY, SECONDARY, FALLBACK, NONE] + description: Per-contact confidence — mirrors the internal SecurityContact confidence enum. + + OverallConfidenceBand: + type: string + enum: [HIGH, MEDIUM, LOW, NONE] + description: > + Aggregate confidence, derived from the highest-scoring contact. + UNCONFIRMED CROSSWALK: the internal aggregate band is + PRIMARY/SECONDARY/FALLBACK/NONE; this HIGH/MEDIUM/LOW/NONE scale is a + 4→4 tier rename (PRIMARY→HIGH, …) pending confirmation with Akrites — + see OVERALL_CONFIDENCE_CROSSWALK in akritesExternalContactDetail.ts. + + SecurityContact: + type: object + required: [channel, value, role, confidenceBand, confidenceScore] + properties: + channel: + type: string + enum: [email, github-pvr, github-handle, url, web-form] + description: Mirrors the internal SecurityContact.channel enum; passed through verbatim. + value: + type: string + example: security@example.org + role: + type: string + enum: [security-team, maintainer, admin, committer, org-owner] + description: Mirrors the internal SecurityContact.role enum; passed through verbatim. + confidenceBand: + $ref: '#/components/schemas/ContactConfidenceBand' + confidenceScore: + type: number + format: float + minimum: 0 + maximum: 1 + + ReportingMethod: + type: object + nullable: true + description: Reserved shape — always null until SECURITY.md parsing ships. + required: [methodId, type, status, description, actionRequired] + properties: + methodId: + type: integer + type: + type: string + status: + type: string + enum: [preferred, fallback] + recipient: + type: string + nullable: true + description: + type: string + actionRequired: + type: string + + ReportingGuidelines: + type: object + nullable: true + description: Reserved shape — always null until SECURITY.md parsing ships. + required: [generalPrinciples, qualityRequirements] + properties: + generalPrinciples: + type: array + items: + type: string + qualityRequirements: + type: object + required: [avoid, recommend] + properties: + avoid: + type: array + items: + type: string + recommend: + type: array + items: + type: object + required: [scenario, action] + properties: + scenario: + type: string + action: + type: string + + IntegrationHints: + type: object + nullable: true + description: Reserved shape — always null until computed. + required: [primaryEndpoint, fallbackEndpoint, validationHints] + properties: + primaryEndpoint: + type: object + nullable: true + required: [type, target] + properties: + type: + type: string + target: + type: string + fallbackEndpoint: + type: object + nullable: true + required: [type, target] + properties: + type: + type: string + target: + type: string + validationHints: + type: object + nullable: true + required: [falsePositiveRules, requirePocForSeverity, debounce] + properties: + falsePositiveRules: + type: array + items: + type: string + requirePocForSeverity: + type: array + items: + type: string + debounce: + type: string + nullable: true + + ContactDetail: + type: object + description: > + NOTE: shape not final — still under discussion upstream, in particular + how reportingMethods / reportingGuidelines / integrationHints will be + derived from SECURITY.md parsing. Those three, plus targetOrganizationName + and bugBountyProgramFlag, are always null today (not stored). Expect this + schema to change before the contract is frozen. + required: + - purl + - name + - ecosystem + - overallConfidenceBand + - contacts + - securityPolicyUrl + - vulnerabilityReportingUrl + - bugBountyUrl + - pvrEnabled + - targetOrganizationName + - bugBountyProgramFlag + - reportingMethods + - reportingGuidelines + - integrationHints + properties: + purl: + type: string + name: + type: string + ecosystem: + $ref: '#/components/schemas/Ecosystem' + overallConfidenceBand: + $ref: '#/components/schemas/OverallConfidenceBand' + contacts: + type: array + items: + $ref: '#/components/schemas/SecurityContact' + securityPolicyUrl: + type: string + nullable: true + vulnerabilityReportingUrl: + type: string + nullable: true + bugBountyUrl: + type: string + nullable: true + pvrEnabled: + type: boolean + nullable: true + targetOrganizationName: + type: string + nullable: true + description: Reserved — not stored today, always null. + bugBountyProgramFlag: + type: boolean + nullable: true + description: Reserved — not stored today, always null. + reportingMethods: + type: array + nullable: true + items: + $ref: '#/components/schemas/ReportingMethod' + reportingGuidelines: + $ref: '#/components/schemas/ReportingGuidelines' + integrationHints: + $ref: '#/components/schemas/IntegrationHints' + + ContactDetailBulkEntry: + type: object + required: [requestedPurl, found, contact] + properties: + requestedPurl: + type: string + found: + type: boolean + contact: + type: object + nullable: true + allOf: + - $ref: '#/components/schemas/ContactDetail' + paths: /akrites-external/packages/detail: get: @@ -560,3 +778,107 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + + /akrites-external/contacts/detail: + get: + operationId: getContactDetail + summary: Get security contact detail by PURL + description: > + Requires read:maintainer-roles (interim for the contract's cdp:maintainers:read). + Never reachable with the packages scope. + tags: [Contacts] + security: + - M2MBearer: + - read:maintainer-roles + parameters: + - name: purl + in: query + required: true + schema: + type: string + example: pkg:npm/%40angular/core + responses: + '200': + description: Security contact detail (empty contacts + NONE band when the package has none). + content: + application/json: + schema: + $ref: '#/components/schemas/ContactDetail' + '400': + description: Malformed purl. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Token missing read:maintainer-roles scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Package not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /akrites-external/contacts/detail:batch: + post: + operationId: getContactDetailBatch + summary: Bulk security contact lookup + tags: [Contacts] + security: + - M2MBearer: + - read:maintainer-roles + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [purls] + properties: + purls: + type: array + minItems: 1 + maxItems: 100 + items: + type: string + responses: + '200': + description: Results in the same order as the request. + content: + application/json: + schema: + type: object + required: [results] + properties: + results: + type: array + items: + $ref: '#/components/schemas/ContactDetailBulkEntry' + '400': + description: Validation error (empty array, >100 items, malformed purl). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Token missing read:maintainer-roles scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/backend/src/api/public/v1/packages/akritesExternalContactDetail.test.ts b/backend/src/api/public/v1/packages/akritesExternalContactDetail.test.ts new file mode 100644 index 0000000000..f07da596a9 --- /dev/null +++ b/backend/src/api/public/v1/packages/akritesExternalContactDetail.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' + +import type { AkritesExternalContactDetailRow } from '@crowd/data-access-layer' + +import { toAkritesExternalContactDetail } from './akritesExternalContactDetail' + +function baseRow( + overrides: Partial = {}, +): AkritesExternalContactDetailRow { + return { + purl: 'pkg:npm/lodash', + name: 'lodash', + ecosystem: 'npm', + securityPolicyUrl: 'https://example.org/SECURITY.md', + vulnerabilityReportingUrl: null, + bugBountyUrl: null, + pvrEnabled: true, + securityContacts: [ + { + channel: 'email', + value: 'security@example.org', + role: 'security-team', + confidence: 'SECONDARY', + score: 0.735, + }, + { + channel: 'github-pvr', + value: 'https://example.org/advisories/new', + role: 'security-team', + confidence: 'PRIMARY', + score: 0.94, + }, + ], + ...overrides, + } +} + +describe('toAkritesExternalContactDetail', () => { + it('maps contacts with the confidence/score field renames', () => { + const result = toAkritesExternalContactDetail(baseRow()) + expect(result.purl).toBe('pkg:npm/lodash') + expect(result.contacts).toEqual([ + { + channel: 'email', + value: 'security@example.org', + role: 'security-team', + confidenceBand: 'SECONDARY', + confidenceScore: 0.735, + }, + { + channel: 'github-pvr', + value: 'https://example.org/advisories/new', + role: 'security-team', + confidenceBand: 'PRIMARY', + confidenceScore: 0.94, + }, + ]) + }) + + it('derives overallConfidenceBand from the highest-scoring contact and crosswalks it', () => { + // max score 0.94 -> PRIMARY -> HIGH (not SECONDARY/MEDIUM from the other contact). + expect(toAkritesExternalContactDetail(baseRow()).overallConfidenceBand).toBe('HIGH') + }) + + it('crosswalks each internal band to the contract scale', () => { + const band = (confidence: string, score: number) => + toAkritesExternalContactDetail( + baseRow({ + securityContacts: [ + { + channel: 'email', + value: 'x@y.z', + role: 'maintainer', + confidence: confidence as never, + score, + }, + ], + }), + ).overallConfidenceBand + expect(band('SECONDARY', 0.6)).toBe('MEDIUM') + expect(band('FALLBACK', 0.4)).toBe('LOW') + }) + + it('returns NONE band and an empty array when there are no contacts', () => { + const nullContacts = toAkritesExternalContactDetail(baseRow({ securityContacts: null })) + expect(nullContacts.contacts).toEqual([]) + expect(nullContacts.overallConfidenceBand).toBe('NONE') + + const emptyContacts = toAkritesExternalContactDetail(baseRow({ securityContacts: [] })) + expect(emptyContacts.overallConfidenceBand).toBe('NONE') + }) + + it('always returns the reserved fields as null', () => { + const result = toAkritesExternalContactDetail(baseRow()) + expect(result.targetOrganizationName).toBeNull() + expect(result.bugBountyProgramFlag).toBeNull() + expect(result.reportingMethods).toBeNull() + expect(result.reportingGuidelines).toBeNull() + expect(result.integrationHints).toBeNull() + }) + + it('passes through the repo-sourced policy fields', () => { + const result = toAkritesExternalContactDetail( + baseRow({ vulnerabilityReportingUrl: 'https://example.org/report', pvrEnabled: false }), + ) + expect(result.securityPolicyUrl).toBe('https://example.org/SECURITY.md') + expect(result.vulnerabilityReportingUrl).toBe('https://example.org/report') + expect(result.pvrEnabled).toBe(false) + }) +}) diff --git a/backend/src/api/public/v1/packages/akritesExternalContactDetail.ts b/backend/src/api/public/v1/packages/akritesExternalContactDetail.ts new file mode 100644 index 0000000000..d353671e20 --- /dev/null +++ b/backend/src/api/public/v1/packages/akritesExternalContactDetail.ts @@ -0,0 +1,96 @@ +import { + type AkritesExternalContactDetailRow, + type SecurityContactConfidence, + securityContactConfidenceBand, +} from '@crowd/data-access-layer' + +// Per-contact band mirrors the internal SecurityContactConfidence enum verbatim. +export type AkritesExternalContactConfidenceBand = SecurityContactConfidence +// Aggregate band is a DIFFERENT scale in the contract (HIGH/MEDIUM/LOW/NONE). +export type AkritesExternalOverallConfidenceBand = 'HIGH' | 'MEDIUM' | 'LOW' | 'NONE' + +export interface AkritesExternalSecurityContact { + channel: string + value: string + role: string + confidenceBand: AkritesExternalContactConfidenceBand + confidenceScore: number +} + +export interface AkritesExternalContactDetail { + purl: string + name: string + ecosystem: string + overallConfidenceBand: AkritesExternalOverallConfidenceBand + contacts: AkritesExternalSecurityContact[] + securityPolicyUrl: string | null + vulnerabilityReportingUrl: string | null + bugBountyUrl: string | null + pvrEnabled: boolean | null + // Reserved by the contract but not stored today — always null (see the akrites-external + // OpenAPI notes). Typed as null so a future non-null shape is an explicit, reviewed change. + targetOrganizationName: null + bugBountyProgramFlag: null + reportingMethods: null + reportingGuidelines: null + integrationHints: null +} + +export interface ContactDetailBulkEntry { + requestedPurl: string + found: boolean + contact: AkritesExternalContactDetail | null +} + +// UNCONFIRMED CROSSWALK — the internal aggregate band is PRIMARY/SECONDARY/FALLBACK/NONE +// (securityContactConfidenceBand), but the Akrites contract's overallConfidenceBand uses +// HIGH/MEDIUM/LOW/NONE. This is a clean 4→4 tier rename, but the contract flags it as an +// open question (there is no existing function producing the HIGH/… scale). Confirm with +// Akrites/product; if they'd rather reuse PRIMARY/SECONDARY/FALLBACK/NONE, drop this table. +const OVERALL_CONFIDENCE_CROSSWALK: Record< + SecurityContactConfidence, + AkritesExternalOverallConfidenceBand +> = { + PRIMARY: 'HIGH', + SECONDARY: 'MEDIUM', + FALLBACK: 'LOW', + NONE: 'NONE', +} + +export function toAkritesExternalContactDetail( + row: AkritesExternalContactDetailRow, +): AkritesExternalContactDetail { + const contacts: AkritesExternalSecurityContact[] = (row.securityContacts ?? []).map((c) => ({ + channel: c.channel, + value: c.value, + role: c.role, + confidenceBand: c.confidence, + confidenceScore: Number(c.score), + })) + + // Aggregate band derives from the highest-scoring contact (same rule as the internal + // packageConfidence), then crosswalked to the contract's scale. NONE when there are none. + const overallConfidenceBand = + contacts.length > 0 + ? OVERALL_CONFIDENCE_CROSSWALK[ + securityContactConfidenceBand(Math.max(...contacts.map((c) => c.confidenceScore))) + ] + : 'NONE' + + return { + purl: row.purl, + name: row.name, + ecosystem: row.ecosystem, + overallConfidenceBand, + contacts, + securityPolicyUrl: row.securityPolicyUrl ?? null, + vulnerabilityReportingUrl: row.vulnerabilityReportingUrl ?? null, + bugBountyUrl: row.bugBountyUrl ?? null, + pvrEnabled: row.pvrEnabled ?? null, + targetOrganizationName: null, + bugBountyProgramFlag: null, + reportingMethods: null, + reportingGuidelines: null, + integrationHints: null, + } +} diff --git a/backend/src/api/public/v1/packages/getAkritesExternalContactDetail.ts b/backend/src/api/public/v1/packages/getAkritesExternalContactDetail.ts new file mode 100644 index 0000000000..0c06dfff10 --- /dev/null +++ b/backend/src/api/public/v1/packages/getAkritesExternalContactDetail.ts @@ -0,0 +1,24 @@ +import type { Request, Response } from 'express' + +import { NotFoundError } from '@crowd/common' +import { getContactDetailsByPurls } from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { toAkritesExternalContactDetail } from './akritesExternalContactDetail' +import { purlQuerySchema } from './purl' + +export async function getAkritesExternalContactDetail(req: Request, res: Response): Promise { + const { purl } = validateOrThrow(purlQuerySchema, req.query) + + const qx = await getPackagesQx() + const [row] = await getContactDetailsByPurls(qx, [purl]) + + if (!row) { + throw new NotFoundError() + } + + ok(res, toAkritesExternalContactDetail(row)) +} diff --git a/backend/src/api/public/v1/packages/getAkritesExternalContactDetailBatch.ts b/backend/src/api/public/v1/packages/getAkritesExternalContactDetailBatch.ts new file mode 100644 index 0000000000..f7282468b3 --- /dev/null +++ b/backend/src/api/public/v1/packages/getAkritesExternalContactDetailBatch.ts @@ -0,0 +1,44 @@ +import type { Request, Response } from 'express' + +import { + type AkritesExternalContactDetailRow, + getContactDetailsByPurls, +} from '@crowd/data-access-layer' + +import { getPackagesQx } from '@/db/packagesDb' +import { ok } from '@/utils/api' +import { validateOrThrow } from '@/utils/validation' + +import { + type ContactDetailBulkEntry, + toAkritesExternalContactDetail, +} from './akritesExternalContactDetail' +import { normalizePurl, purlsBodySchema } from './purl' + +const bodySchema = purlsBodySchema() + +export async function getAkritesExternalContactDetailBatch( + req: Request, + res: Response, +): Promise { + const { purls: rawPurls } = validateOrThrow(bodySchema, req.body) + // Normalize after parsing (not in the schema) so rawPurls keeps the client's + // original form — echoed back as requestedPurl so callers can self-correlate. + const normalizedPurls = rawPurls.map(normalizePurl) + + const qx = await getPackagesQx() + const rows = await getContactDetailsByPurls(qx, normalizedPurls) + + const byPurl = new Map(rows.map((r) => [r.purl, r])) + + const results: ContactDetailBulkEntry[] = rawPurls.map((requestedPurl, i) => { + const row = byPurl.get(normalizedPurls[i]) + return { + requestedPurl, + found: row !== undefined, + contact: row ? toAkritesExternalContactDetail(row) : null, + } + }) + + ok(res, { results }) +} diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 359e45a939..8b95019514 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -1,4 +1,9 @@ -import type { PackageDbRow } from '@crowd/types' +import type { + PackageDbRow, + RepoDbRow, + SecurityContactConfidence, + SecurityContactDbRow, +} from '@crowd/types' import { QueryExecutor } from '../queryExecutor' @@ -8,11 +13,17 @@ import { BEST_REPO_LINK_JOIN, DOWNLOADS_LAST_30D_SUBQUERY, MAINTAINER_COUNT_SUBQUERY, + SECURITY_CONTACTS_SUBQUERY, SEVERITY_RANK_EXPR, STEWARD_DISPLAY_NAME_METADATA, STEWARD_MENTIONED_JOIN, } from './sqlFragments' +// Re-exported so existing callers (e.g. the akrites-external contacts mapper and +// packages_worker) keep importing this from @crowd/data-access-layer without a +// churn-only import change now that the canonical type lives in @crowd/types (ADR-0006). +export type { SecurityContactConfidence } from '@crowd/types' + export interface PackageMetrics { totalPackages: number criticalPackages: number @@ -612,15 +623,12 @@ export async function listPackagesForApi( return { rows, total } } -export type SecurityContactConfidence = 'PRIMARY' | 'SECONDARY' | 'FALLBACK' | 'NONE' - -export interface SecurityContactRow { - channel: string - value: string - role: string - confidence: SecurityContactConfidence - score: number -} +// Per ADR-0006, sourced from the security_contacts row type in @crowd/types instead of +// hand-declaring the shape again next to this query. +export type SecurityContactRow = Pick< + SecurityContactDbRow, + 'channel' | 'value' | 'role' | 'confidence' | 'score' +> export interface PackageDetailRow { id: string @@ -727,16 +735,7 @@ export async function getPackageDetailByPurl( r.vulnerability_reporting_url AS "vulnerabilityReportingUrl", r.bug_bounty_url AS "bugBountyUrl", r.contacts_last_refreshed AS "contactsLastRefreshed", - ( - SELECT json_agg(sc ORDER BY sc.score DESC) - FROM ( - SELECT channel, value, role, confidence, score - FROM security_contacts - WHERE repo_id = pr.repo_id AND deleted_at IS NULL - ORDER BY score DESC - LIMIT 5 - ) sc - ) AS "securityContacts", + ${SECURITY_CONTACTS_SUBQUERY} AS "securityContacts", -- latest 30-day download count ${DOWNLOADS_LAST_30D_SUBQUERY} AS "downloadsLast30d", ${MAINTAINER_COUNT_SUBQUERY} AS "maintainerCount", @@ -871,6 +870,43 @@ export async function getPackageDetailsByPurls( ) } +// Fields the akrites-external Contacts contract needs, one row per package. Reuses the +// same best-repo join + security_contacts subquery as getPackageDetailByPurl (contacts +// live on the repo, not the package). A missing purl yields no row → "not found"; a found +// package with no contacts yields a row with securityContacts null → resolves to []. +export interface AkritesExternalContactDetailRow + extends Pick, + Pick< + RepoDbRow, + 'securityPolicyUrl' | 'vulnerabilityReportingUrl' | 'bugBountyUrl' | 'pvrEnabled' + > { + securityContacts: SecurityContactRow[] | null +} + +export async function getContactDetailsByPurls( + qx: QueryExecutor, + purls: string[], +): Promise { + if (purls.length === 0) return [] + return qx.select( + ` + SELECT + p.purl, + p.name, + p.ecosystem, + r.security_policy_url AS "securityPolicyUrl", + r.vulnerability_reporting_url AS "vulnerabilityReportingUrl", + r.bug_bounty_url AS "bugBountyUrl", + r.pvr_enabled AS "pvrEnabled", + ${SECURITY_CONTACTS_SUBQUERY} AS "securityContacts" + FROM packages p + ${BEST_REPO_LINK_JOIN} + WHERE p.purl = ANY($(purls)) + `, + { purls }, + ) +} + export interface ScatterPoint { purl: string name: string diff --git a/services/libs/data-access-layer/src/osspckgs/getContactDetailsByPurls.integration.test.ts b/services/libs/data-access-layer/src/osspckgs/getContactDetailsByPurls.integration.test.ts new file mode 100644 index 0000000000..5838dd732c --- /dev/null +++ b/services/libs/data-access-layer/src/osspckgs/getContactDetailsByPurls.integration.test.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { getDbConnection } from '@crowd/database' + +import type { QueryExecutor } from '../queryExecutor' +import { pgpQx } from '../queryExecutor' + +import { getContactDetailsByPurls } from './api' + +// Integration test: hits the running packages-db. Skipped automatically when +// any of the DB env vars are missing so unit-test runs in CI stay green. +const HAVE_DB = + !!process.env.CROWD_PACKAGES_DB_WRITE_HOST && + !!process.env.CROWD_PACKAGES_DB_PORT && + !!process.env.CROWD_PACKAGES_DB_USERNAME && + !!process.env.CROWD_PACKAGES_DB_DATABASE && + !!process.env.CROWD_PACKAGES_DB_PASSWORD + +const FIXTURE_TAG = 'akrites-external-contact-fixture' +const repoUrl = `https://github.com/example/${FIXTURE_TAG}-repo` + +describe.skipIf(!HAVE_DB)('getContactDetailsByPurls — real packages-db', () => { + let qx: QueryExecutor + + const withContactsPurl = `pkg:test-fixture/${FIXTURE_TAG}/with-contacts` + const noContactsPurl = `pkg:test-fixture/${FIXTURE_TAG}/no-contacts` + const missingPurl = `pkg:test-fixture/${FIXTURE_TAG}/does-not-exist` + + async function cleanupFixtures(): Promise { + await qx.result( + `DELETE FROM security_contacts WHERE repo_id IN (SELECT id FROM repos WHERE url = $(url))`, + { url: repoUrl }, + ) + await qx.result( + `DELETE FROM package_repos WHERE package_id IN (SELECT id FROM packages WHERE ingestion_source = $(tag))`, + { tag: FIXTURE_TAG }, + ) + await qx.result(`DELETE FROM packages WHERE ingestion_source = $(tag)`, { tag: FIXTURE_TAG }) + await qx.result(`DELETE FROM repos WHERE url = $(url)`, { url: repoUrl }) + } + + beforeAll(async () => { + const conn = await getDbConnection({ + host: process.env.CROWD_PACKAGES_DB_WRITE_HOST ?? '', + port: parseInt(process.env.CROWD_PACKAGES_DB_PORT ?? '0', 10), + database: process.env.CROWD_PACKAGES_DB_DATABASE ?? '', + user: process.env.CROWD_PACKAGES_DB_USERNAME ?? '', + password: process.env.CROWD_PACKAGES_DB_PASSWORD ?? '', + }) + qx = pgpQx(conn) + await cleanupFixtures() + + const withRow = await qx.selectOne( + `INSERT INTO packages (purl, ecosystem, name, ingestion_source) + VALUES ($(purl), 'npm', 'with-contacts-fixture', $(tag)) + RETURNING id`, + { purl: withContactsPurl, tag: FIXTURE_TAG }, + ) + const withPackageId = withRow.id as string + + await qx.result( + `INSERT INTO packages (purl, ecosystem, name, ingestion_source) + VALUES ($(purl), 'npm', 'no-contacts-fixture', $(tag))`, + { purl: noContactsPurl, tag: FIXTURE_TAG }, + ) + + const repoRow = await qx.selectOne( + `INSERT INTO repos (url, security_policy_url, pvr_enabled, vulnerability_reporting_url, bug_bounty_url) + VALUES ($(url), 'https://example.org/SECURITY.md', true, 'https://example.org/report-vulnerability', 'https://example.org/bug-bounty') + RETURNING id`, + { url: repoUrl }, + ) + const repoId = repoRow.id as string + + await qx.result( + `INSERT INTO package_repos (package_id, repo_id, source, confidence) + VALUES ($(packageId), $(repoId), 'declared', 0.9)`, + { packageId: withPackageId, repoId }, + ) + + await qx.result( + `INSERT INTO security_contacts (repo_id, channel, value, role, confidence, score) + VALUES ($(repoId), 'github-pvr', 'https://example.org/advisories/new', 'security-team', 'PRIMARY', 0.94)`, + { repoId }, + ) + await qx.result( + `INSERT INTO security_contacts (repo_id, channel, value, role, confidence, score) + VALUES ($(repoId), 'email', 'security@example.org', 'security-team', 'SECONDARY', 0.735)`, + { repoId }, + ) + }, 30_000) + + afterAll(async () => { + if (qx) await cleanupFixtures() + }) + + it('returns no row for a purl that does not resolve to a package', async () => { + const rows = await getContactDetailsByPurls(qx, [missingPurl]) + expect(rows).toHaveLength(0) + }) + + it('returns a row with null securityContacts for a package with no linked repo/contacts', async () => { + const [row] = await getContactDetailsByPurls(qx, [noContactsPurl]) + expect(row.purl).toBe(noContactsPurl) + expect(row.securityContacts).toBeNull() + }) + + it('returns the security contacts (score-ordered) and repo policy fields when present', async () => { + const [row] = await getContactDetailsByPurls(qx, [withContactsPurl]) + expect(row.securityPolicyUrl).toBe('https://example.org/SECURITY.md') + expect(row.pvrEnabled).toBe(true) + expect(row.vulnerabilityReportingUrl).toBe('https://example.org/report-vulnerability') + expect(row.bugBountyUrl).toBe('https://example.org/bug-bounty') + expect(row.securityContacts).not.toBeNull() + expect(row.securityContacts).toHaveLength(2) + // json_agg ORDER BY score DESC → PRIMARY (0.94) first. + expect(row.securityContacts?.[0].confidence).toBe('PRIMARY') + }) + + it('batches many purls, omitting the misses', async () => { + const rows = await getContactDetailsByPurls(qx, [missingPurl, noContactsPurl, withContactsPurl]) + const purls = new Set(rows.map((r) => r.purl)) + expect(purls.has(missingPurl)).toBe(false) + expect(purls.has(noContactsPurl)).toBe(true) + expect(purls.has(withContactsPurl)).toBe(true) + }) +}) diff --git a/services/libs/data-access-layer/src/osspckgs/getPackageDetailsByPurls.integration.test.ts b/services/libs/data-access-layer/src/osspckgs/getPackageDetailsByPurls.integration.test.ts index 204dd17703..828d9f3586 100644 --- a/services/libs/data-access-layer/src/osspckgs/getPackageDetailsByPurls.integration.test.ts +++ b/services/libs/data-access-layer/src/osspckgs/getPackageDetailsByPurls.integration.test.ts @@ -55,25 +55,25 @@ describe.skipIf(!HAVE_DB)('getPackageDetailsByPurls — real packages-db', () => await cleanupFixtures() const linkedRow = await qx.selectOne( - `INSERT INTO packages (purl, ecosystem, name, declared_repository_url, repository_url, ingestion_source, status) - VALUES ($(purl), 'npm', 'linked-fixture', $(declared), $(canonical), $(tag), 'active') + `INSERT INTO packages (purl, ecosystem, name, declared_repository_url, repository_url, ingestion_source) + VALUES ($(purl), 'npm', 'linked-fixture', $(declaredUrl), $(repoUrl), $(tag)) RETURNING id`, { purl: linkedPurl, - declared: 'https://github.com/example/linked-fixture', - canonical: 'https://github.com/example/linked-fixture', + declaredUrl: 'https://github.com/example/linked-fixture', + repoUrl: 'https://github.com/example/linked-fixture', tag: FIXTURE_TAG, }, ) linkedPackageId = linkedRow.id as string await qx.result( - `INSERT INTO packages (purl, ecosystem, name, declared_repository_url, repository_url, ingestion_source, status) - VALUES ($(purl), 'npm', 'unlinked-fixture', $(declared), $(canonical), $(tag), 'active')`, + `INSERT INTO packages (purl, ecosystem, name, declared_repository_url, repository_url, ingestion_source) + VALUES ($(purl), 'npm', 'unlinked-fixture', $(declaredUrl), $(repoUrl), $(tag))`, { purl: unlinkedPurl, - declared: 'https://github.com/example/unlinked-fixture-raw', - canonical: 'https://github.com/example/unlinked-fixture-canonical', + declaredUrl: 'https://github.com/example/unlinked-fixture-raw', + repoUrl: 'https://github.com/example/unlinked-fixture-canonical', tag: FIXTURE_TAG, }, ) @@ -81,11 +81,12 @@ describe.skipIf(!HAVE_DB)('getPackageDetailsByPurls — real packages-db', () => const repoRow = await qx.selectOne(`INSERT INTO repos (url) VALUES ($(url)) RETURNING id`, { url: repoUrl, }) + const repoId = repoRow.id as string await qx.result( `INSERT INTO package_repos (package_id, repo_id, source, confidence) - VALUES ($(packageId), $(repoId), 'declared', 0.90)`, - { packageId: linkedPackageId, repoId: repoRow.id }, + VALUES ($(packageId), $(repoId), 'declared', 0.9)`, + { packageId: linkedPackageId, repoId }, ) for (const username of ['maintainer-one', 'maintainer-two']) { diff --git a/services/libs/data-access-layer/src/osspckgs/sqlFragments.ts b/services/libs/data-access-layer/src/osspckgs/sqlFragments.ts index 1f13eb8771..a738222a0c 100644 --- a/services/libs/data-access-layer/src/osspckgs/sqlFragments.ts +++ b/services/libs/data-access-layer/src/osspckgs/sqlFragments.ts @@ -32,6 +32,20 @@ export const BEST_REPO_LINK_JOIN = `LEFT JOIN LATERAL ( // Expects a `packages p` in the outer FROM clause. export const MAINTAINER_COUNT_SUBQUERY = `(SELECT COUNT(*)::int FROM package_maintainers pm WHERE pm.package_id = p.id)` +// Top 5 active security contacts (highest score first) for a package's best repo link. +// Expects the `pr` alias from BEST_REPO_LINK_JOIN (pr.repo_id) in scope. Returns a JSON +// array or NULL when the repo has no contacts. +export const SECURITY_CONTACTS_SUBQUERY = `( + SELECT json_agg(sc ORDER BY sc.score DESC) + FROM ( + SELECT channel, value, role, confidence, score + FROM security_contacts + WHERE repo_id = pr.repo_id AND deleted_at IS NULL + ORDER BY score DESC + LIMIT 5 + ) sc + )` + // Most recent 30-day download count for a package. Expects a `packages p` in the outer FROM clause. export const DOWNLOADS_LAST_30D_SUBQUERY = `( SELECT d.count::text diff --git a/services/libs/types/src/db/index.ts b/services/libs/types/src/db/index.ts index 4cba12643a..5c6ce2e138 100644 --- a/services/libs/types/src/db/index.ts +++ b/services/libs/types/src/db/index.ts @@ -7,4 +7,6 @@ export * from './member-segment-affiliations' export * from './organizations' export * from './organization-identities' export * from './packages' +export * from './repos' +export * from './security-contacts' export * from './segments' diff --git a/services/libs/types/src/db/repos.ts b/services/libs/types/src/db/repos.ts new file mode 100644 index 0000000000..b0851fa0be --- /dev/null +++ b/services/libs/types/src/db/repos.ts @@ -0,0 +1,49 @@ +// repos: one row per resolved code repository. Base shape from +// backend/src/osspckgs/migrations/V1779710880__initial_schema.sql; remaining fields +// added by later ALTER TABLE migrations under backend/src/osspckgs/migrations/ +// (scorecard, branch-protection, security/contacts columns). +export interface RepoDbRow { + id: string + url: string + host: string | null + owner: string | null + name: string | null + description: string | null + primaryLanguage: string | null + topics: string[] | null + stars: number | null + forks: number | null + watchers: number | null + openIssues: number | null + lastCommitAt: string | null + archived: boolean | null + disabled: boolean | null + isFork: boolean | null + createdAt: string | null + homepage: string | null + rawProjectType: string | null + rawProjectName: string | null + // NUMERIC(3,1) — parsed to a number by @crowd/database's OID 1700 type-parser override, + // same convention as SecurityContactDbRow.score. + scorecardScore: number | null + scorecardLastRunAt: string | null + lastSyncedAt: string | null + // -- added by later migrations -- + branchProtectionAllowsForcePush: boolean | null + branchProtectionEnabled: boolean | null + branchProtectionRequiredReviews: number | null + branchProtectionRequiresStatusChecks: boolean | null + securityFileEnabled: boolean | null + securityPolicyEnabled: boolean | null + skipEnrichment: boolean + snapshotAt: string | null + // -- added by V1782950400__security_contacts.sql -- + pvrEnabled: boolean | null + securityPolicyUrl: string | null + vulnerabilityReportingUrl: string | null + bugBountyUrl: string | null + securityTxtUrl: string | null + contactsLastRefreshed: string | null + updatedAt: string + dockerCheckedAt: string | null +} diff --git a/services/libs/types/src/db/security-contacts.ts b/services/libs/types/src/db/security-contacts.ts new file mode 100644 index 0000000000..389adcd4e5 --- /dev/null +++ b/services/libs/types/src/db/security-contacts.ts @@ -0,0 +1,23 @@ +// security_contacts: per-repo security-contact rows (channel/value/role), scored and +// banded. See backend/src/osspckgs/migrations/V1782950400__security_contacts.sql and +// V1783036800__security_contacts_soft_delete.sql (adds deleted_at). +export type SecurityContactConfidence = 'PRIMARY' | 'SECONDARY' | 'FALLBACK' | 'NONE' + +export interface SecurityContactDbRow { + id: string + repoId: string + channel: string + value: string + role: string + name: string | null + // NUMERIC(4,3) — @crowd/database registers a global type-parser override for OID 1700 + // (parseFloat), so this comes back as a number, not the raw-string pg-promise default + // (same convention as PackageDbRow.impact — a NUMERIC column typed as number | null). + score: number + confidence: SecurityContactConfidence + provenance: unknown[] + lastRefreshed: string + createdAt: string + updatedAt: string + deletedAt: string | null +}