Skip to content
Open
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
14 changes: 14 additions & 0 deletions backend/src/api/public/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import { Router } from 'express'

import { NotFoundError } from '@crowd/common'

import { safeWrap } from '@/middlewares/errorMiddleware'
import { SCOPES } from '@/security/scopes'

import { AUTH0_CONFIG } from '../../../conf'
import { oauth2Middleware } from '../middlewares/oauth2Middleware'
import { requireScopes } from '../middlewares/requireScopes'
import { staticApiKeyMiddleware } from '../middlewares/staticApiKeyMiddleware'
Comment on lines +6 to 10

import { memberOrganizationAffiliationsRouter } from './affiliations'
import { membersRouter } from './members'
import { organizationsRouter } from './organizations'
import { packagesRouter } from './packages'
import { batchGetStewardship } from './packages/batchGetStewardship'

export function v1Router(): Router {
const router = Router()
Expand All @@ -17,6 +23,14 @@ export function v1Router(): Router {
router.use('/organizations', oauth2Middleware(AUTH0_CONFIG), organizationsRouter())
router.use('/affiliations', staticApiKeyMiddleware(), memberOrganizationAffiliationsRouter())

router.post(
'/packages\\:batch-stewardship',
oauth2Middleware(AUTH0_CONFIG),
requireScopes([SCOPES.READ_STEWARDSHIPS]),
safeWrap(batchGetStewardship),
)
router.use('/packages', oauth2Middleware(AUTH0_CONFIG), packagesRouter())

router.use(() => {
throw new NotFoundError()
})
Expand Down
42 changes: 42 additions & 0 deletions backend/src/api/public/v1/packages/batchGetStewardship.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const MAX_PURLS = 100

const bodySchema = z.object({
purls: z
.array(z.string().trim().min(1))
.min(1)
.max(MAX_PURLS, `Maximum ${MAX_PURLS} purls per request`),
})

// TODO: replace with real DB queries once stewardship tables land
export async function batchGetStewardship(req: Request, res: Response): Promise<void> {
const { purls } = validateOrThrow(bodySchema, req.body)

const packages: Record<string, object> = {}
for (const purl of purls) {
const name = purl.split('/').pop()?.split('@')[0] ?? purl
let ecosystem = 'unknown'
if (purl.startsWith('pkg:npm')) ecosystem = 'npm'
else if (purl.startsWith('pkg:maven')) ecosystem = 'maven'
packages[purl] = {
name,
ecosystem,
lifecycle: null,
health: null,
impact: null,
openVulns: null,

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.

openVulns can be fetched from the advisories table:
I'm imagining something like this for openVulns:

{
   low: lowCount,
   medium: mediumCount,
   high: highCount,
   critical: criticalCount
}
  • advisories — one row per OSV advisory, holds the severity label and CVSS score.
  • advisory_packages — joins advisories to packages (one advisory can affect many packages).
  • advisory_affected_ranges — version ranges where the vulnerability applies. You need this if you want to restrict to packages whose current version is still vulnerable (i.e.
    fixed_version IS NULL = unpatched). Otherwise, skip it for a raw count. -> Let's have all open vulns for now skipping version.

E.g. To check data


  SELECT
    ap.package_id,
    COUNT(*) FILTER (WHERE a.severity = 'LOW')      AS low,
    COUNT(*) FILTER (WHERE a.severity = 'MEDIUM')   AS medium,
    COUNT(*) FILTER (WHERE a.severity = 'HIGH')     AS high,
    COUNT(*) FILTER (WHERE a.severity = 'CRITICAL') AS critical
  FROM advisory_packages ap
  JOIN advisories a ON a.id = ap.advisory_id
  WHERE ap.package_id IS NOT NULL
  GROUP BY ap.package_id

status: 'unassigned',

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.

Let's call this stewardship instead of status

origin: 'auto_imported',

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.

No need to return this in the response payload

stewards: [],

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.

I think it will only be one value. So I would say to keep it as null for now.

lastActivityAt: null,
lastActivityDescription: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Batch and detail stewardship mismatch

Medium Severity

For the same purl, POST /packages:batch-stewardship always returns status: unassigned with empty stewards, while GET /packages/:purl returns stewardship.status: escalated with stewards and activity. List-plus-drawer flows that merge batch summaries with detail will show conflicting stewardship state for one package.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c79f906. Configure here.

}
Comment on lines +20 to +36
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Batch never returns null entries

Medium Severity

The batch handler builds a stewardship object for every requested purl, but the OpenAPI for this endpoint specifies unknown CDP packages map to null (e.g. pkg:pypi/requests). Clients implementing merge logic against the spec cannot exercise missing-package handling with this mock.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c79f906. Configure here.


ok(res, { packages })
}
223 changes: 223 additions & 0 deletions backend/src/api/public/v1/packages/getPackage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import type { Request, Response } from 'express'
import { z } from 'zod'

import { NotFoundError } from '@crowd/common'

import { ok } from '@/utils/api'
import { validateOrThrow } from '@/utils/validation'

const paramsSchema = z.object({
purl: z.string().trim().min(1),
})

// TODO: replace with real DB queries once stewardship tables land
export async function getPackage(req: Request, res: Response): Promise<void> {
const { purl: rawPurl } = validateOrThrow(paramsSchema, req.params)
const purl = decodeURIComponent(rawPurl)

if (!purl.startsWith('pkg:')) {
throw new NotFoundError()
}
Comment on lines +15 to +20

ok(res, mockPackage(purl))
}

function mockPackage(purl: string) {

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.

We can discuss with Gasper but I would suggest to have a simpler endpoints/response payload. We would have a filter/query param in this endpoint by tab.
So by overview, assessment, security, provenance and history.

We would only return the needed data for each.

const name = purl.split('/').pop()?.split('@')[0] ?? purl
let ecosystem = 'unknown'
if (purl.startsWith('pkg:npm')) ecosystem = 'npm'
else if (purl.startsWith('pkg:maven')) ecosystem = 'maven'

return {

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.

I think there are some inconsistencies with the design.
Latest version: https://linuxfoundation.slack.com/archives/C0B6TK0QJ00/p1780999390218909
Suggestion on how to return data:

{
   purl,
   name,
   ecosystem,
   general: {
      healthScore: {
         maintainerHealth (e.g. 4),
         securitySupplyChain (e.g. 8),
         developmentActivity (e.g. 6),
         total (e.g. 18),
      },
      impact: {
         impactScore,
         downloadsLastMonth,
         dependentPackages,
         dependentRepos,
         transitiveReach,
      },
      riskSignals: {
         lifecycle,
         maintainerBusFactor,
         lastRelease,
         hasSecurityFile,
         openSSFScorecard,
      },
   },
   assessment: {},
   security: {
      securityContacts: [
         {
            email,
            name,
         }
      ],
      advisories: [
         osvId,
         severity,
         resolution, (TBD)
      ],
      cvd: {
         isPvrEnabled,
         hasSecurityPolicyEnabled,
         tier0Steward,
         criticalVulnerabilityFlag
      }
   },
   provenance: {
      repositoryMapping: {
         declaredRepo,
         mappingConfidence,
         lastCommitAt
      },
      supplyChainIntegrity: {
         buildProvenance,
         signedReleases,
      }
   },
   history: {}
}

If we split the response by tab, then we would return only the data points for each one of the objects

purl,
name,
ecosystem,
latestVersion: '4.17.21',
latestReleaseAt: '2021-02-20T00:00:00Z',
downloads: ecosystem === 'maven' ? null : 52142891,
dependentPackagesCount: 142312,
dependentReposCount: 39104,
lifecycle: 'declining',
health: 18,
healthBreakdown: {
maintainerHealth: 4,
securityAndSupplyChain: 8,
developmentActivity: 6,
},
impact: 71,
transitiveReach: 'Top 0.4%',
busFactor: 1,
openVulns: { count: 1, severity: 'high' },
repository: {
url: `https://github.com/example/${name}`,
lastCommitAt: '2024-09-14T00:00:00Z',
scorecardScore: 5.2,
isArchived: false,
},
maintainers: [
{
username: 'jdalton',
displayName: 'John-David Dalton',
emailHash: null,
url: 'https://github.com/jdalton',
},
],
securityContact: null,
advisories: [
{
id: 'CVE-2021-44906',
severity: 'high',
summary: 'Prototype pollution via constructor',
status: 'open',
cvss: 9.8,
publishedAt: '2022-03-17T00:00:00Z',
affectedVersionRange: {
introduced: '0.0.1',
fixed: null,
lastAffected: '4.17.21',
},
},
],
scorecardChecks: [
{
checkName: 'Branch-Protection',
score: 3.0,
reason: 'Branch protection not enabled for default branch',
},
{ checkName: 'Security-Policy', score: 0.0, reason: 'security policy file not detected' },
{
checkName: 'Maintained',
score: 5.0,
reason: 'repo was created 9 years ago, has 337 open issues',
},
],
disclosureReadiness: {
pvrEnabled: null,
securityMdPresent: null,
tier0StewardName: null,
hasCriticalAdvisory: true,
},
provenanceMappings: [
{
repoUrl: `https://github.com/example/${name}`,
confidence: 0.98,
source: 'declared',
verified: true,
},
],
supplyChainIntegrity: {
buildProvenance: null,
signedReleases: null,
},
stewardship: {
status: 'escalated',
origin: 'auto_imported',
stewards: [
{ userId: 'jdoe', name: 'Jonathan R.', role: 'lead', assignedAt: '2025-05-15T00:00:00Z' },
Comment on lines +102 to +106
],
lastActivityAt: '2025-06-08T00:00:00Z',
lastActivityDescription: 'Escalated — recommending consortium fork',
assessment: {
posture: 'Critical',
summary: 'Single maintainer, bus factor 1, no security process in place.',
draft: false,
reviewed: false,
flagged: false,
flagNote: null,
monitoringPlan: 'Watch for new maintainer activity\nWatch registry for ownership transfer',
completedAt: '2025-05-18T00:00:00Z',
completedBy: 'jdoe',
},
findings: [
{
id: 1,
dimension: 'maintainer_health',
severity: 'critical',
finding: 'Single maintainer, no response to issue filed 6 weeks ago. Bus factor 1.',
evidence: null,
},
{
id: 2,
dimension: 'release_health',
severity: 'high',
finding: 'Last release 5 years ago. 180+ open issues. Active community forks exist.',
evidence: null,
},
{
id: 3,
dimension: 'security_posture',
severity: 'high',
finding: 'No SECURITY.md, no disclosure process, releases unsigned.',
evidence: null,
},
],
remediationActions: [
{
id: 1,
findingId: 3,
action: 'Filed issue requesting SECURITY.md + disclosure process (no response)',
status: 'done',
url: null,
notes: null,
completedAt: '2025-05-20T00:00:00Z',
},
{
id: 2,
findingId: null,
action: 'Opened PR re-enabling branch protection',
status: 'done',
url: null,
notes: null,
completedAt: '2025-05-22T00:00:00Z',
},
{
id: 3,
findingId: null,
action: 'Recommended escalation — vendor LTS or consortium fork',
status: 'pending',
url: null,
notes: null,
completedAt: null,
},
],
activity: [
{
id: 4,
actorUserId: 'jdoe',
actorType: 'user',
activityType: 'escalation',
content: 'Escalated — recommending consortium fork',
metadata: { from: 'active', to: 'escalated' },
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lastActivityAt disagrees with activity

Low Severity

stewardship.lastActivityAt is fixed at 2025-06-08T00:00:00Z, while the newest stewardship.activity entry uses createdAt from Date.now() minus one day. Those fields usually represent the same event but drift on every request and no longer match each other.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c79f906. Configure here.

},
{
id: 3,
actorUserId: 'jdoe',
actorType: 'user',
activityType: 'remediation_logged',
content: 'Jonathan R. logged: maintainer unresponsive after 3 attempts',
metadata: null,
createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 2,
actorUserId: 'jdoe',
actorType: 'user',
activityType: 'assessment_completed',
content: 'Assessment completed — posture Critical',
metadata: { posture: 'Critical' },
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
},
{
id: 1,
actorUserId: null,
actorType: 'system',
activityType: 'steward_added',
content: 'Assigned to Jonathan R.',
metadata: { userId: 'jdoe', role: 'lead' },
createdAt: new Date(Date.now() - 24 * 24 * 60 * 60 * 1000).toISOString(),
},
],
},
}
}
24 changes: 24 additions & 0 deletions backend/src/api/public/v1/packages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Router } from 'express'

import { createRateLimiter } from '@/api/apiRateLimiter'
import { requireScopes } from '@/api/public/middlewares/requireScopes'
import { safeWrap } from '@/middlewares/errorMiddleware'
import { SCOPES } from '@/security/scopes'

import { getPackage } from './getPackage'

const rateLimiter = createRateLimiter({ max: 60, windowMs: 60 * 1000 })

export function packagesRouter(): Router {

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.

Let's add 2 extra endpoints:

  • One for the list page (not filtered by ids/purls). Ability to have filters, sorters and pagination
  • One for the overview metrics on top of the list page:
    • For now should return: totalPackages and criticalPackages count.

const router = Router()

router.use(rateLimiter)

router.get(
'/:purl',
requireScopes([SCOPES.READ_PACKAGES, SCOPES.READ_STEWARDSHIPS]),
safeWrap(getPackage),
)

return router
}
Loading
Loading