Skip to content

Commit 95dcf42

Browse files
committed
fix: code review fixes
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent f295c3a commit 95dcf42

7 files changed

Lines changed: 40 additions & 32 deletions

File tree

services/apps/packages_worker/src/security-contacts/extractors/http.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export function registryHeaders(userAgent: string): Record<string, string> {
77
return { 'User-Agent': userAgent }
88
}
99

10+
// Genuinely-absent → null body; every other non-200 throws so transient failures (429/5xx/...)
11+
// are treated as failures and the pipeline preserves existing data instead of wiping it.
12+
const ABSENT_STATUSES = new Set([404, 410])
13+
1014
export interface FetchTextResult {
1115
status: number
1216
text: string | null
@@ -21,8 +25,9 @@ export async function fetchText(
2125
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
2226
try {
2327
const res = await fetch(url, { headers, signal: controller.signal })
24-
if (res.status !== 200) return { status: res.status, text: null }
25-
return { status: 200, text: await res.text() }
28+
if (res.status === 200) return { status: 200, text: await res.text() }
29+
if (ABSENT_STATUSES.has(res.status)) return { status: res.status, text: null }
30+
throw new Error(`fetchText ${url} failed: HTTP ${res.status}`)
2631
} finally {
2732
clearTimeout(timeoutId)
2833
}
@@ -42,8 +47,9 @@ export async function fetchJson(
4247
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
4348
try {
4449
const res = await fetch(url, { headers, signal: controller.signal })
45-
if (res.status !== 200) return { status: res.status, json: null }
46-
return { status: 200, json: await res.json() }
50+
if (res.status === 200) return { status: 200, json: await res.json() }
51+
if (ABSENT_STATUSES.has(res.status)) return { status: res.status, json: null }
52+
throw new Error(`fetchJson ${url} failed: HTTP ${res.status}`)
4753
} finally {
4854
clearTimeout(timeoutId)
4955
}

services/apps/packages_worker/src/security-contacts/extractors/securityContactsFile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,11 @@ export const extractSecurityContactsFile: Extractor = async (target, deps) => {
7373
const email =
7474
entry.email ?? (await resolvePublicEmail(entry.handle, deps.fetchTimeoutMs, token))
7575
if (email) {
76-
// name = handle so the reconciler can identity-link this to a bare handle from another source
76+
// handle = the username this email was resolved from (used for identity-linking).
7777
contacts.push({
7878
channel: 'email',
7979
value: email,
80-
name: entry.handle,
80+
handle: entry.handle,
8181
role: 'security-team',
8282
tier: 'A',
8383
provenance: prov(),

services/apps/packages_worker/src/security-contacts/extractors/securityTxt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ const PLATFORM_HOSTS = new Set(['github.com', 'www.github.com', 'gitlab.com', 'b
99

1010
const FIELD_RE = /^([A-Za-z-]+):\s*(.+?)\s*$/
1111

12+
// target.homepage is externally-sourced. Requiring https already blocks the classic SSRF target
13+
// (cloud-metadata IMDS is http-only); we also reject obvious loopback/localhost.
14+
function isBlockedHost(h: string): boolean {
15+
return h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.startsWith('127.')
16+
}
17+
1218
export function parseSecurityTxt(
1319
text: string,
1420
sourceUrl: string,
@@ -65,12 +71,13 @@ export const extractSecurityTxt: Extractor = async (target, deps) => {
6571
let host: string
6672
try {
6773
const u = new URL(target.homepage)
74+
if (u.protocol !== 'https:') return { contacts: [], policies: {} }
6875
origin = u.origin
6976
host = u.hostname.toLowerCase()
7077
} catch {
7178
return { contacts: [], policies: {} }
7279
}
73-
if (PLATFORM_HOSTS.has(host)) return { contacts: [], policies: {} }
80+
if (PLATFORM_HOSTS.has(host) || isBlockedHost(host)) return { contacts: [], policies: {} }
7481

7582
const url = `${origin}/.well-known/security.txt`
7683
const { text } = await fetchText(url, deps.fetchTimeoutMs)

services/apps/packages_worker/src/security-contacts/processBatch.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
22
import { getServiceChildLogger } from '@crowd/logging'
33

44
import { getSecurityContactsConfig } from '../config'
5+
import { mapWithConcurrency } from '../utils/concurrency'
56

67
import { extractPvr } from './extractors/pvr'
78
import { extractManifest } from './extractors/registry'
@@ -19,7 +20,7 @@ import {
1920
RepoPolicies,
2021
RepoTarget,
2122
} from './types'
22-
import { writeContacts } from './writeContacts'
23+
import { markRepoAttempted, writeContacts } from './writeContacts'
2324

2425
const log = getServiceChildLogger('security-contacts')
2526

@@ -101,22 +102,6 @@ function toTarget(row: SweepRow): RepoTarget {
101102
return { repoId: row.id, url: row.url, homepage: row.homepage, packages: row.packages ?? [] }
102103
}
103104

104-
async function runWithConcurrency<T>(
105-
items: T[],
106-
concurrency: number,
107-
task: (item: T) => Promise<void>,
108-
): Promise<void> {
109-
let idx = 0
110-
await Promise.all(
111-
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
112-
while (idx < items.length) {
113-
const item = items[idx++]
114-
await task(item)
115-
}
116-
}),
117-
)
118-
}
119-
120105
async function processRepo(
121106
target: RepoTarget,
122107
deps: ExtractorDeps,
@@ -125,10 +110,10 @@ async function processRepo(
125110
// One failing extractor must not sink the repo.
126111
const results = await Promise.allSettled(EXTRACTORS.map((extract) => extract(target, deps)))
127112

128-
// If every extractor failed (e.g. a transient network outage), skip the write entirely so we
129-
// don't delete previously good contacts/policies; leaving contacts_last_refreshed retries next sweep.
113+
// Every extractor failed (transient outage) — preserve existing data, just record the attempt.
130114
if (results.every((r) => r.status === 'rejected')) {
131-
log.warn({ repoId: target.repoId }, 'All extractors failed — skipping write to preserve data')
115+
log.warn({ repoId: target.repoId }, 'All extractors failed — preserving existing data')
116+
await markRepoAttempted(qx, target.repoId)
132117
return
133118
}
134119

@@ -168,7 +153,7 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
168153
}
169154

170155
const targets = batch.map(toTarget)
171-
await runWithConcurrency(targets, CONCURRENCY, (target) => processRepo(target, deps, qx))
156+
await mapWithConcurrency(targets, CONCURRENCY, (target) => processRepo(target, deps, qx))
172157

173158
log.info({ processed: targets.length }, 'Security contacts batch complete')
174159
return { processed: targets.length }

services/apps/packages_worker/src/security-contacts/reconcile.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function mergeInto(target: RawContact, src: RawContact): void {
5050
target.role = higherRole(target.role, src.role)
5151
target.tier = higherTier(target.tier, src.tier)
5252
if (!target.name && src.name) target.name = src.name
53+
if (!target.handle && src.handle) target.handle = src.handle
5354
}
5455

5556
function exactMatchMerge(contacts: RawContact[]): RawContact[] {
@@ -66,13 +67,12 @@ function exactMatchMerge(contacts: RawContact[]): RawContact[] {
6667
return [...byKey.values()]
6768
}
6869

69-
// Collapse a resolved handle into its email: extractors set the email contact's
70-
// `name` to the originating handle, so a github-handle whose value matches becomes
71-
// redundant once the email is known. The email (higher-quality) channel is kept.
70+
// Collapse a bare github-handle into the email A3 resolved it from, matched via the explicit
71+
// `handle` field (never the display name, to avoid merging unrelated people who share a name).
7272
function identityLinkMerge(contacts: RawContact[]): RawContact[] {
7373
const emailByHandle = new Map<string, RawContact>()
7474
for (const c of contacts) {
75-
if (c.channel === 'email' && c.name) emailByHandle.set(c.name.toLowerCase(), c)
75+
if (c.channel === 'email' && c.handle) emailByHandle.set(c.handle.toLowerCase(), c)
7676
}
7777

7878
const out: RawContact[] = []

services/apps/packages_worker/src/security-contacts/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface RawContact {
2525
value: string
2626
role: ContactRole
2727
name?: string
28+
/** Username an A3 email was resolved from — used only to identity-link a bare github-handle. */
29+
handle?: string
2830
tier: SourceTier
2931
provenance: ProvenanceEntry[]
3032
}

services/apps/packages_worker/src/security-contacts/writeContacts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { RepoPolicies, ScoredContact } from './types'
77
* the policy columns in one transaction. Policy columns use COALESCE so a run that doesn't
88
* rediscover a field (partial/failed extractor pass) never clears a previously known value.
99
*/
10+
// Records the attempt without touching contacts/policies — preserves existing data on total
11+
// failure while advancing contacts_last_refreshed so the repo isn't reprocessed this sweep.
12+
export async function markRepoAttempted(qx: QueryExecutor, repoId: string): Promise<void> {
13+
await qx.result('UPDATE repos SET contacts_last_refreshed = NOW() WHERE id = $(repoId)', {
14+
repoId,
15+
})
16+
}
17+
1018
export async function writeContacts(
1119
qx: QueryExecutor,
1220
repoId: string,

0 commit comments

Comments
 (0)