Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion backend/src/api/public/v1/akrites/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
27 changes: 25 additions & 2 deletions backend/src/api/public/v1/packages/getPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getAdvisoriesByPackageId,
getPackageDetailByPurl,
getStewardshipSummary,
securityContactConfidenceBand,
} from '@crowd/data-access-layer'

import { getPackagesQx } from '@/db/packagesDb'
Expand Down Expand Up @@ -50,6 +51,21 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
const mappingConfidence =
pkg.repoMappingConfidence != null ? Number(pkg.repoMappingConfidence) : null

const securityContacts =
pkg.contactsLastRefreshed == null
? null
: (pkg.securityContacts ?? []).map((c) => ({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed sweep shows empty contacts

Medium Severity

securityContacts is derived only from whether contactsLastRefreshed is set, but the worker also sets that timestamp on failed extractor or repo runs via markRepoAttempted without persisting contacts. Clients then get an empty array, which the OpenAPI text describes as a successful sweep with no contacts, not a failed attempt.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0b72429. Configure here.

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,
Expand Down Expand Up @@ -92,15 +108,22 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
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,
resolution: a.resolution,
isCritical: a.isCritical,
})),
cvd: {
isPvrEnabled: null,
isPvrEnabled: pkg.pvrEnabled ?? null,
tier0Steward: null,
criticalVulnerabilityFlag: pkg.hasCriticalVulnerability,
},
Expand Down
18 changes: 8 additions & 10 deletions services/apps/packages_worker/src/security-contacts/score.ts
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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) +
Expand All @@ -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) }
}
6 changes: 3 additions & 3 deletions services/apps/packages_worker/src/security-contacts/types.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -29,7 +29,7 @@ export interface RawContact {

export interface ScoredContact extends RawContact {
score: number
confidence: ConfidenceBand
confidence: SecurityContactConfidence
}

export interface RepoPolicies {
Expand Down
38 changes: 38 additions & 0 deletions services/libs/data-access-layer/src/osspckgs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,16 @@ export async function listPackagesForApi(
return { rows, total }
}

export type SecurityContactConfidence = 'PRIMARY' | 'SECONDARY' | 'FALLBACK' | 'NONE'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is also a duplication


export interface SecurityContactRow {
channel: string
value: string
role: string
confidence: SecurityContactConfidence
score: number
}

export interface PackageDetailRow {
id: string
purl: string
Expand Down Expand Up @@ -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
Expand All @@ -639,6 +655,13 @@ export interface PackageDetailRow {
signalCoverageHealth: Record<string, unknown> | 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'
}
Comment thread
mbani01 marked this conversation as resolved.
Comment on lines +658 to +663

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason why we are rewriting this function ? can we use the existing one ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which existing one are you referring to?
score.ts's confidenceBand is only a wrapper calling securityContactConfidenceBand from the DAL


export interface AdvisoryRow {
osvId: string
severity: string
Expand Down Expand Up @@ -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
Expand Down
Loading