Skip to content

Commit 1b7ca03

Browse files
authored
Merge branch 'feat/CM-361-part-1' into feat/CM-361-part-2
2 parents 7d72f22 + c2fa5ac commit 1b7ca03

13 files changed

Lines changed: 210 additions & 60 deletions

File tree

backend/src/api/public/v1/akrites/openapi.yaml

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,31 @@ components:
397397
type: integer
398398
description: Total packages marked as critical (is_critical = true).
399399

400+
SecurityContactConfidence:
401+
type: string
402+
enum: [PRIMARY, SECONDARY, FALLBACK, NONE]
403+
404+
SecurityContact:
405+
type: object
406+
required: [channel, value, role, confidence, score]
407+
properties:
408+
channel:
409+
type: string
410+
enum: [email, github-pvr, url, github-handle, web-form]
411+
value:
412+
type: string
413+
example: security@expressjs.com
414+
role:
415+
type: string
416+
enum: [security-team, maintainer, admin, committer, org-owner]
417+
confidence:
418+
$ref: '#/components/schemas/SecurityContactConfidence'
419+
score:
420+
type: number
421+
format: float
422+
minimum: 0
423+
maximum: 1
424+
400425
Advisory:
401426
type: object
402427
required: [osvId, severity, resolution, isCritical]
@@ -522,8 +547,32 @@ components:
522547
securityContacts:
523548
type: array
524549
nullable: true
550+
description: >-
551+
null when the linked repo has not yet been swept by the security-contacts
552+
pipeline; empty array when swept with no contacts found. Provenance and
553+
internal scoring metadata are never included.
525554
items:
526-
type: string
555+
$ref: '#/components/schemas/SecurityContact'
556+
packageConfidence:
557+
allOf:
558+
- $ref: '#/components/schemas/SecurityContactConfidence'
559+
nullable: true
560+
description: Confidence band of the highest-scoring contact in securityContacts.
561+
securityPolicies:
562+
type: object
563+
properties:
564+
securityPolicyUrl:
565+
type: string
566+
nullable: true
567+
vulnerabilityReportingUrl:
568+
type: string
569+
nullable: true
570+
bugBountyUrl:
571+
type: string
572+
nullable: true
573+
pvrEnabled:
574+
type: boolean
575+
nullable: true
527576
advisories:
528577
type: array
529578
items:

backend/src/api/public/v1/packages/getPackage.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getAdvisoriesByPackageId,
77
getPackageDetailByPurl,
88
getStewardshipSummary,
9+
securityContactConfidenceBand,
910
} from '@crowd/data-access-layer'
1011

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

54+
const securityContacts =
55+
pkg.contactsLastRefreshed == null
56+
? null
57+
: (pkg.securityContacts ?? []).map((c) => ({
58+
channel: c.channel,
59+
value: c.value,
60+
role: c.role,
61+
confidence: c.confidence,
62+
score: Number(c.score),
63+
}))
64+
const packageConfidence =
65+
securityContacts && securityContacts.length > 0
66+
? securityContactConfidenceBand(Math.max(...securityContacts.map((c) => c.score)))
67+
: null
68+
5369
ok(res, {
5470
purl: pkg.purl,
5571
name: pkg.name,
@@ -92,15 +108,22 @@ export async function getPackage(req: Request, res: Response): Promise<void> {
92108
signalCoverageHealth: snakeToCamelKeys(pkg.signalCoverageHealth),
93109
assessment: null,
94110
security: {
95-
securityContacts: null,
111+
securityContacts,
112+
packageConfidence,
113+
securityPolicies: {
114+
securityPolicyUrl: pkg.securityPolicyUrl ?? null,
115+
vulnerabilityReportingUrl: pkg.vulnerabilityReportingUrl ?? null,
116+
bugBountyUrl: pkg.bugBountyUrl ?? null,
117+
pvrEnabled: pkg.pvrEnabled ?? null,
118+
},
96119
advisories: advisories.map((a) => ({
97120
osvId: a.osvId,
98121
severity: a.severity,
99122
resolution: a.resolution,
100123
isCritical: a.isCritical,
101124
})),
102125
cvd: {
103-
isPvrEnabled: null,
126+
isPvrEnabled: pkg.pvrEnabled ?? null,
104127
tier0Steward: null,
105128
criticalVulnerabilityFlag: pkg.hasCriticalVulnerability,
106129
},

backend/src/database/repositories/memberRepository.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ class MemberRepository {
244244

245245
const subprojectIds = await getSegmentSubprojectIds(qx, currentSegments)
246246

247+
if (subprojectIds.length === 0) {
248+
return
249+
}
250+
247251
await seq.query(bulkDeleteMemberSegments, {
248252
replacements: {
249253
memberIds,

backend/src/database/repositories/organizationRepository.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ class OrganizationRepository {
192192
const qx = SequelizeRepository.getQueryExecutor(options)
193193
const subprojectIds = await getSegmentSubprojectIds(qx, currentSegments)
194194

195+
if (subprojectIds.length === 0) {
196+
return
197+
}
198+
195199
await seq.query(bulkDeleteOrganizationSegments, {
196200
replacements: {
197201
organizationIds,

services/apps/git_integration/src/crowdgit/database/crud.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,30 +667,32 @@ async def find_many_organization_ids_by_identities(identities: list[dict]) -> li
667667
param_index = 1
668668
for idx, identity in enumerate(identities):
669669
values_parts.append(
670-
f"(${param_index}::int, ${param_index + 1}::text,"
671-
f" ${param_index + 2}::boolean, ${param_index + 3}::text)"
670+
f"(${param_index}::int, ${param_index + 1}::text, ${param_index + 2}::boolean,"
671+
f" ${param_index + 3}::text, ${param_index + 4}::text)"
672672
)
673673
params.extend(
674674
[
675675
idx,
676676
identity["type"],
677677
identity.get("verified", True),
678+
identity["platform"],
678679
identity["value"],
679680
]
680681
)
681-
param_index += 4
682+
param_index += 5
682683

683684
matches_by_idx: dict[int, set[str]] = {}
684685
rows = await query(
685686
f"""
686-
WITH input_identities (idx, identity_type, verified, value) AS (
687+
WITH input_identities (idx, identity_type, verified, platform, value) AS (
687688
VALUES {", ".join(values_parts)}
688689
)
689690
SELECT i.idx, oi."organizationId"
690691
FROM input_identities i
691692
LEFT JOIN "organizationIdentities" oi
692693
ON oi.type = i.identity_type
693694
AND oi.verified = i.verified
695+
AND oi.platform = i.platform
694696
AND lower(oi.value) = lower(i.value)
695697
ORDER BY i.idx
696698
""",
@@ -708,6 +710,7 @@ async def find_many_organization_ids_by_identities(identities: list[dict]) -> li
708710
results.append(
709711
{
710712
"type": identity["type"],
713+
"platform": identity["platform"],
711714
"value": identity["value"],
712715
"verified": identity.get("verified", True),
713716
"organization_id": organization_id,

services/apps/git_integration/src/crowdgit/services/affiliation/affiliation_service.py

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,25 @@ def get_file_picker_prompt(
162162
- contributors grouped under the organization they belong to
163163
- explicit domain/email-pattern rules that the file defines for assigning
164164
contributors to organizations
165+
166+
What decides a match is the content, not the file's name or purpose. Any
167+
file qualifies when it states an organization per person — including
168+
governance or ownership files (e.g. OWNERS, MAINTAINERS) when they carry
169+
an explicit organization/employer for each person. Try to capture these.
165170
</what_to_find>
166171
167172
<what_to_reject>
168-
Reject candidates whose preview does not explicitly associate contributors
169-
with organizations, including:
170-
- Lists of names, emails, or usernames with no stated organization
171-
(e.g. AUTHORS, CONTRIBUTORS, CREDITS).
172-
- Identity or alias mappings such as .mailmap.
173-
- Governance or ownership files that name people but not their employer
174-
(e.g. OWNERS, CODEOWNERS, MAINTAINERS without organization information).
175-
- Source code, scripts, or configuration files.
176-
177-
Email addresses and email domains alone do not make a file a match, unless
178-
the file explicitly defines those domains or patterns as affiliation rules.
173+
The deciding factor is whether the file states an organization per person.
174+
Reject a candidate when it only identifies people without that, including:
175+
- lists of names, emails, or usernames with no organization
176+
(e.g. AUTHORS, CONTRIBUTORS, CREDITS)
177+
- identity or alias mappings (e.g. .mailmap)
178+
- role or ownership files that name people but not their employer
179+
(e.g. OWNERS, CODEOWNERS, MAINTAINERS without organization information)
180+
- source code, scripts, or configuration
181+
182+
An email address or its domain is not an organization, unless the file
183+
explicitly defines that domain or pattern as an affiliation rule.
179184
</what_to_reject>
180185
181186
<candidates>
@@ -298,9 +303,16 @@ def get_extraction_prompt(self, content_to_analyze: str) -> str:
298303
It is valid to use an email/domain pattern only when the file itself explicitly
299304
defines that pattern as an affiliation rule.
300305
- name: the organization name the file states, else null.
301-
- domain: use a domain the file states; otherwise infer it from the stated
302-
organization name only when confident (e.g. "Google" -> google.com), else null.
303-
Never infer a domain from an email.
306+
- domain: choose the organization's domain in this order:
307+
1. a domain the file explicitly states for the organization;
308+
2. a domain you can infer confidently from the stated organization name
309+
(e.g. "Google" -> google.com);
310+
3. only when the file explicitly ties an organization to this contributor
311+
AND provides that same contributor's email, the domain of that email
312+
(e.g. company "Ericsson Software Technology" + "john@est.tech" -> est.tech).
313+
Otherwise null. An email domain is a domain source only for an organization
314+
the file has already named for that person — never use it to invent or guess
315+
an organization that the file does not state.
304316
- isUnaffiliated: set true only when the file explicitly marks the person as
305317
independent / unaffiliated / personal / no employer — not as a fallback when
306318
the organization is merely missing. When true, set name and domain to null.
@@ -409,7 +421,7 @@ def group_parse_rows(
409421
if is_unaffiliated:
410422
stint = AffiliationOrganizationStint(
411423
name="Individual",
412-
domain="individual-noaccount.com",
424+
domain="nonameaccount.com",
413425
date_start=cls._parse_optional_date(organization.date_start),
414426
date_end=cls._parse_optional_date(organization.date_end),
415427
is_unaffiliated=True,
@@ -607,13 +619,35 @@ def has_existing_stint(
607619

608620
@staticmethod
609621
def affiliation_stint_key(
610-
contributor: AffiliationContributor, domain: str
611-
) -> tuple[str, str, str] | None:
612-
domain = domain.lower()
622+
contributor: AffiliationContributor,
623+
organization: AffiliationOrganizationStint,
624+
) -> tuple[str, str, str, date | None, date | None, bool] | None:
625+
domain = organization.domain.lower()
626+
date_start = organization.date_start
627+
date_end = organization.date_end
628+
if isinstance(date_start, datetime):
629+
date_start = date_start.date()
630+
if isinstance(date_end, datetime):
631+
date_end = date_end.date()
632+
613633
if contributor.email:
614-
return ("email", contributor.email.lower(), domain)
634+
return (
635+
"email",
636+
contributor.email.lower(),
637+
domain,
638+
date_start,
639+
date_end,
640+
organization.is_unaffiliated,
641+
)
615642
if contributor.github:
616-
return ("github", contributor.github.lower(), domain)
643+
return (
644+
"github",
645+
contributor.github.lower(),
646+
domain,
647+
date_start,
648+
date_end,
649+
organization.is_unaffiliated,
650+
)
617651
return None
618652

619653
async def exclude_parent_repo_affiliations(
@@ -633,16 +667,15 @@ async def exclude_parent_repo_affiliations(
633667
key
634668
for entry in parent_snapshot
635669
for organization in entry.organizations
636-
if (key := self.affiliation_stint_key(entry.contributor, organization.domain))
670+
if (key := self.affiliation_stint_key(entry.contributor, organization))
637671
}
638672

639673
fork_entries: list[AffiliationContributorEntry] = []
640674
for entry in extracted_affiliations:
641675
organizations = [
642676
organization
643677
for organization in entry.organizations
644-
if (key := self.affiliation_stint_key(entry.contributor, organization.domain))
645-
is None
678+
if (key := self.affiliation_stint_key(entry.contributor, organization)) is None
646679
or key not in parent_stint_keys
647680
]
648681
if organizations:
@@ -720,6 +753,7 @@ async def apply_affiliations(
720753
organization_identity_inputs.append(
721754
{
722755
"type": "primary-domain",
756+
"platform": "email",
723757
"value": organization.domain,
724758
"verified": True,
725759
}

services/apps/packages_worker/src/nuget/normalize.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ function isPrerelease(version: string): boolean {
1919
return version.includes('-')
2020
}
2121

22+
// NuGet stamps unlisted versions with 1900-01-01T00:00:00Z as a sentinel — treat as absent.
23+
function parsePublishedDate(published: string | undefined): Date | null {
24+
if (!published) return null
25+
const date = new Date(published)
26+
return !isNaN(date.getTime()) && date.getUTCFullYear() > 1900 ? date : null
27+
}
28+
2229
const SCM_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org']
2330

2431
function normalizeRepoUrl(url: string | undefined): string | null {
@@ -105,25 +112,15 @@ export function normalizeNuGetPackage(
105112
status = 'active'
106113
}
107114

108-
// NuGet stamps unlisted versions with 1900-01-01T00:00:00Z as a sentinel — exclude them.
109115
const publishedDates = allEntries
110-
.filter((e) => e.published)
111-
.map((e) => new Date(e.published as string))
112-
.filter((d) => !isNaN(d.getTime()) && d.getUTCFullYear() > 1900)
116+
.map((e) => parsePublishedDate(e.published))
117+
.filter((d): d is Date => d !== null)
113118
.sort((a, b) => a.getTime() - b.getTime())
114119

115120
const firstReleaseAt = publishedDates.length > 0 ? publishedDates[0] : null
116121

117122
const latestEntry4Date = latestListedEntry ?? latestEntry
118-
const latestReleaseAtRaw = latestEntry4Date?.published
119-
? new Date(latestEntry4Date.published)
120-
: null
121-
const latestReleaseAt =
122-
latestReleaseAtRaw &&
123-
!isNaN(latestReleaseAtRaw.getTime()) &&
124-
latestReleaseAtRaw.getUTCFullYear() > 1900
125-
? latestReleaseAtRaw
126-
: null
123+
const latestReleaseAt = parsePublishedDate(latestEntry4Date?.published)
127124

128125
const totalDownloads = searchResult?.totalDownloads ?? 0
129126

@@ -144,7 +141,7 @@ export function normalizeNuGetPackage(
144141
const { licenses: vLicenses } = parseLicense(entry.licenseExpression, entry.licenseUrl)
145142
return {
146143
number: ver,
147-
publishedAt: entry.published ? new Date(entry.published) : null,
144+
publishedAt: parsePublishedDate(entry.published),
148145
isLatest: ver === latestVersion,
149146
isPrerelease: isPrerelease(ver),
150147
isYanked: entry.listed === false,

0 commit comments

Comments
 (0)