diff --git a/backend/src/api/public/v1/akrites/openapi.yaml b/backend/src/api/public/v1/akrites/openapi.yaml index e56ce2128c..cddbb908d1 100644 --- a/backend/src/api/public/v1/akrites/openapi.yaml +++ b/backend/src/api/public/v1/akrites/openapi.yaml @@ -397,6 +397,31 @@ components: type: integer description: Total packages marked as critical (is_critical = true). + SecurityContactConfidence: + type: string + enum: [PRIMARY, SECONDARY, FALLBACK, NONE] + + SecurityContact: + type: object + required: [channel, value, role, confidence, score] + properties: + channel: + type: string + enum: [email, github-pvr, url, github-handle, web-form] + value: + type: string + example: security@expressjs.com + role: + type: string + enum: [security-team, maintainer, admin, committer, org-owner] + confidence: + $ref: '#/components/schemas/SecurityContactConfidence' + score: + type: number + format: float + minimum: 0 + maximum: 1 + Advisory: type: object required: [osvId, severity, resolution, isCritical] @@ -522,8 +547,32 @@ components: securityContacts: type: array nullable: true + description: >- + null when the linked repo has not yet been swept by the security-contacts + pipeline; empty array when swept with no contacts found. Provenance and + internal scoring metadata are never included. items: - type: string + $ref: '#/components/schemas/SecurityContact' + packageConfidence: + allOf: + - $ref: '#/components/schemas/SecurityContactConfidence' + nullable: true + description: Confidence band of the highest-scoring contact in securityContacts. + securityPolicies: + type: object + properties: + securityPolicyUrl: + type: string + nullable: true + vulnerabilityReportingUrl: + type: string + nullable: true + bugBountyUrl: + type: string + nullable: true + pvrEnabled: + type: boolean + nullable: true advisories: type: array items: diff --git a/backend/src/api/public/v1/packages/getPackage.ts b/backend/src/api/public/v1/packages/getPackage.ts index 44c51445a5..44c61ca50f 100644 --- a/backend/src/api/public/v1/packages/getPackage.ts +++ b/backend/src/api/public/v1/packages/getPackage.ts @@ -6,6 +6,7 @@ import { getAdvisoriesByPackageId, getPackageDetailByPurl, getStewardshipSummary, + securityContactConfidenceBand, } from '@crowd/data-access-layer' import { getPackagesQx } from '@/db/packagesDb' @@ -50,6 +51,21 @@ export async function getPackage(req: Request, res: Response): Promise { const mappingConfidence = pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null + const securityContacts = + pkg.contactsLastRefreshed == null + ? null + : (pkg.securityContacts ?? []).map((c) => ({ + channel: c.channel, + value: c.value, + role: c.role, + confidence: c.confidence, + score: Number(c.score), + })) + const packageConfidence = + securityContacts && securityContacts.length > 0 + ? securityContactConfidenceBand(Math.max(...securityContacts.map((c) => c.score))) + : null + ok(res, { purl: pkg.purl, name: pkg.name, @@ -92,7 +108,14 @@ export async function getPackage(req: Request, res: Response): Promise { signalCoverageHealth: snakeToCamelKeys(pkg.signalCoverageHealth), assessment: null, security: { - securityContacts: null, + securityContacts, + packageConfidence, + securityPolicies: { + securityPolicyUrl: pkg.securityPolicyUrl ?? null, + vulnerabilityReportingUrl: pkg.vulnerabilityReportingUrl ?? null, + bugBountyUrl: pkg.bugBountyUrl ?? null, + pvrEnabled: pkg.pvrEnabled ?? null, + }, advisories: advisories.map((a) => ({ osvId: a.osvId, severity: a.severity, @@ -100,7 +123,7 @@ export async function getPackage(req: Request, res: Response): Promise { isCritical: a.isCritical, })), cvd: { - isPvrEnabled: null, + isPvrEnabled: pkg.pvrEnabled ?? null, tier0Steward: null, criticalVulnerabilityFlag: pkg.hasCriticalVulnerability, }, diff --git a/services/apps/packages_worker/src/security-contacts/score.ts b/services/apps/packages_worker/src/security-contacts/score.ts index 1d14aa4578..f8d861c629 100644 --- a/services/apps/packages_worker/src/security-contacts/score.ts +++ b/services/apps/packages_worker/src/security-contacts/score.ts @@ -1,4 +1,9 @@ -import { ConfidenceBand, ContactChannel, ProvenanceEntry, RawContact, SourceTier } from './types' +import { + SecurityContactConfidence, + securityContactConfidenceBand, +} from '@crowd/data-access-layer/src/osspckgs/api' + +import { ContactChannel, ProvenanceEntry, RawContact, SourceTier } from './types' const WEIGHTS = { tier: 0.55, channel: 0.2, freshness: 0.15, corroboration: 0.1 } @@ -84,17 +89,10 @@ function corroborationScore(provenance: ProvenanceEntry[]): number { return 0 } -export function confidenceBand(score: number): ConfidenceBand { - if (score >= 0.8) return 'PRIMARY' - if (score >= 0.55) return 'SECONDARY' - if (score >= 0.3) return 'FALLBACK' - return 'NONE' -} - export function scoreContact( contact: RawContact, now: Date = new Date(), -): { score: number; confidence: ConfidenceBand } { +): { score: number; confidence: SecurityContactConfidence } { const raw = WEIGHTS.tier * TIER_SCORE[contact.tier] + WEIGHTS.channel * channelQuality(contact.channel, contact.value) + @@ -103,5 +101,5 @@ export function scoreContact( (contact.channel === 'github-handle' ? HANDLE_ONLY_PENALTY : 0) const score = Math.round(Math.min(1, Math.max(0, raw)) * 1000) / 1000 - return { score, confidence: confidenceBand(score) } + return { score, confidence: securityContactConfidenceBand(score) } } diff --git a/services/apps/packages_worker/src/security-contacts/types.ts b/services/apps/packages_worker/src/security-contacts/types.ts index aad43d4155..0c451d76e3 100644 --- a/services/apps/packages_worker/src/security-contacts/types.ts +++ b/services/apps/packages_worker/src/security-contacts/types.ts @@ -1,9 +1,9 @@ +import type { SecurityContactConfidence } from '@crowd/data-access-layer/src/osspckgs/api' + export type ContactChannel = 'email' | 'github-pvr' | 'url' | 'github-handle' | 'web-form' export type ContactRole = 'security-team' | 'maintainer' | 'admin' | 'committer' | 'org-owner' -export type ConfidenceBand = 'PRIMARY' | 'SECONDARY' | 'FALLBACK' | 'NONE' - export type SourceTier = 'A' | 'B' | 'C' | 'D' /** Where a single contact value was observed, for auditability and corroboration scoring. */ @@ -29,7 +29,7 @@ export interface RawContact { export interface ScoredContact extends RawContact { score: number - confidence: ConfidenceBand + confidence: SecurityContactConfidence } export interface RepoPolicies { diff --git a/services/libs/data-access-layer/src/osspckgs/api.ts b/services/libs/data-access-layer/src/osspckgs/api.ts index 778413ff3b..31acdbd876 100644 --- a/services/libs/data-access-layer/src/osspckgs/api.ts +++ b/services/libs/data-access-layer/src/osspckgs/api.ts @@ -595,6 +595,16 @@ 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 +} + export interface PackageDetailRow { id: string purl: string @@ -625,6 +635,12 @@ export interface PackageDetailRow { hasSecurityFile: boolean | null hasSecurityPolicy: boolean | null branchProtectionEnabled: boolean | null + pvrEnabled: boolean | null + securityPolicyUrl: string | null + vulnerabilityReportingUrl: string | null + bugBountyUrl: string | null + contactsLastRefreshed: Date | null + securityContacts: SecurityContactRow[] | null // from downloads_last_30d downloadsLast30d: string | null maintainerCount: number @@ -639,6 +655,13 @@ export interface PackageDetailRow { signalCoverageHealth: Record | null } +export function securityContactConfidenceBand(score: number): SecurityContactConfidence { + if (score >= 0.8) return 'PRIMARY' + if (score >= 0.55) return 'SECONDARY' + if (score >= 0.3) return 'FALLBACK' + return 'NONE' +} + export interface AdvisoryRow { osvId: string severity: string @@ -682,6 +705,21 @@ export async function getPackageDetailByPurl( r.security_file_enabled AS "hasSecurityFile", r.security_policy_enabled AS "hasSecurityPolicy", r.branch_protection_enabled AS "branchProtectionEnabled", + r.pvr_enabled AS "pvrEnabled", + r.security_policy_url AS "securityPolicyUrl", + 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", -- latest 30-day download count ( SELECT d.count::text