Skip to content

Commit 979bba0

Browse files
authored
feat(project-profiling): vuln reporting protocol [CM-1331] (#4413)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent cc2be05 commit 979bba0

21 files changed

Lines changed: 1556 additions & 15 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Content-keyed parse cache for declared security policies (files + linked pages),
2+
-- and the assembled per-repo reporting protocol. See ADR-0010 addendum.
3+
CREATE TABLE IF NOT EXISTS security_policy_parses (
4+
blob_oid TEXT PRIMARY KEY, -- git blob oid for files; sha256 of extracted text for linked pages
5+
source_kind TEXT NOT NULL, -- 'security-file' | 'linked-page'
6+
url TEXT, -- linked-page rows only: the fetched URL (join key from linked_urls)
7+
parser TEXT NOT NULL, -- 'deterministic' | 'llm'
8+
parser_version INT NOT NULL,
9+
status TEXT NOT NULL, -- 'ok' | 'template' | 'degraded'
10+
parsed JSONB NOT NULL DEFAULT '{}',
11+
linked_urls TEXT[] NOT NULL DEFAULT '{}',
12+
parsed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
13+
);
14+
15+
CREATE INDEX IF NOT EXISTS security_policy_parses_linked_page_url_idx
16+
ON security_policy_parses (url)
17+
WHERE source_kind = 'linked-page';
18+
19+
CREATE TABLE IF NOT EXISTS repo_reporting_protocols (
20+
repo_id BIGINT PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE,
21+
declared BOOLEAN NOT NULL,
22+
methods JSONB NOT NULL DEFAULT '[]',
23+
guidelines JSONB,
24+
sources JSONB NOT NULL DEFAULT '[]',
25+
assembled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
26+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Persist Bedrock LLM cost (USD) per parse so spend is measurable per file.
2+
-- Null for deterministic rows (no LLM call); set for every parser='llm' row,
3+
-- including degraded ones (a failed extraction still bills tokens).
4+
ALTER TABLE security_policy_parses
5+
ADD COLUMN IF NOT EXISTS llm_cost_usd NUMERIC(12, 6);

docs/adr/0010-security-contacts-worker.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,108 @@ derived at read time as the band of the highest contact score, not stored.
281281
- **Source-format drift** — SECURITY-INSIGHTS schema versions, registry API shapes, and GitHub's
282282
`stats/contributors` 202-polling behavior all change over time. Extractor isolation limits blast
283283
radius to one source; fixture-based tests catch parser regressions.
284+
285+
286+
## Addendum (2026-07-29): Vulnerability reporting protocol
287+
288+
Adds a sister data model answering "**how** does this project expect external vulnerability
289+
reporting?" per repo — distinct from security contacts, which answer *who*. The source of truth
290+
is what the project itself declared: security files from the enricher's `repo_well_known_files`
291+
inventory, the pages they link to, and the authoritative `pvr_enabled` flag. Inferred contacts
292+
from `security_contacts` never blend in as if declared; they appear only as clearly-labeled
293+
fallback when nothing was declared.
294+
295+
### Volume and parser split (prod analysis, 2026-07-28)
296+
297+
Of 114,045 critical GitHub repos, 10,349 (9.1%) have a security file: 10,495 files collapsing
298+
to 6,125 distinct blobs (top-20 shared blobs cover ~1,900 repos of boilerplate). A probe over
299+
all 6,120 reachable blobs showed **69.2% deterministically resolvable** (a single declared
300+
method, or several with exactly one preference-cued), **14% pointer-only** (the file is just a
301+
link to an external policy page), **21.6% with conditional routing** ("only email if a GHSA is
302+
not possible"), **53% with negation language** ("do NOT open a public issue"), 2.3% GitHub
303+
default template. Volume is not the constraint; precision is — hence **hybrid,
304+
deterministic-first**: the classifier fully settles clean blobs, an LLM handles the residue and
305+
prose fields, and a deterministic validator gates every LLM write.
306+
307+
### Data model
308+
309+
- **`security_policy_parses`** — content-keyed parse cache. PK `blob_oid` (git blob oid for
310+
files; sha256 of the URL for linked pages, so two URLs with identical content stay
311+
independently joinable from `linked_urls`), `source_kind`
312+
(`security-file`/`linked-page`), `url` (linked-page rows), `parser`
313+
(`deterministic`/`llm`), `parser_version`, `status` (`ok`/`template`/`degraded`), `parsed`
314+
JSONB (methods + guidelines), `linked_urls`. Identical content across repos is parsed once,
315+
ever; a `parser_version` bump is a targeted re-parse, not a migration.
316+
- **`repo_reporting_protocols`** — assembled per-repo answer. PK `repo_id`, `declared`,
317+
`methods` JSONB (ordered array of `{type, status, endpoint, condition, confidence,
318+
provenance}`), `guidelines` JSONB, `sources` JSONB, `assembled_at`. Method `type` ∈
319+
github-pvr | email | web-form | bounty-platform | security-txt | mailing-list; `status`
320+
preferred | accepted | fallback | prohibited (`prohibited` captures negation language);
321+
`confidence` ∈ declared | inferred. Plain upsert — fully derived and recomputable, no
322+
soft delete.
323+
324+
### Parse stage (blob-driven)
325+
326+
`repo_well_known_files` is the work queue (live `security` rows for critical GitHub repos whose
327+
`blob_oid` lacks a parse at the current version); this pipeline never probes repos for files.
328+
Blobs are fetched once by oid through the shared GitHub gateway. The classifier (same
329+
windowing family as the B1 extractor) emits a `clean` verdict — single usable method, or
330+
exactly one preference-cued among several, no conditional language, negation on a method's own
331+
line marks it `prohibited` — which is stored as-is. Residue goes to the LLM; the validator
332+
requires every emitted endpoint to appear in the source (URLs verbatim; emails also via
333+
deobfuscation normalization — "security at python dot org"), valid enums, and at most one
334+
`preferred` — failures are stored `status='degraded'` (classifier partials, no guidelines).
335+
The LLM can never invent a channel. Pointer-only parses record up to 3 linked URLs; each
336+
linked page is fetched once per URL (SSRF-guarded: http(s) only, private/loopback/link-local
337+
and metadata hosts blocked, redirects revalidated per hop, body capped at 500 KB while
338+
streaming) and parsed as a `linked-page` row. For a pointer-only blob the file row is written
339+
only after every linked page has a parse row, so a transient page failure leaves the blob
340+
unmarked and the next daily sweep retries the whole unit. Batches are drawn in random order so
341+
permanently failing blobs cannot starve the queue.
342+
343+
### Assembly
344+
345+
Repos are re-assembled when inputs change (no protocol row, `contacts_last_refreshed` newer
346+
than `assembled_at`, or a newer parse for one of their blobs). Merge rules: `ok`/`template`
347+
parses contribute methods and guidelines with provenance — **`degraded` parses contribute
348+
nothing**; `pvr_enabled = true` adds a `github-pvr` method when the files are silent, and
349+
`pvr_enabled = false` **vetoes** a declared github-pvr method (the A2-vetoes-B1 rule applied
350+
to the protocol); github-pvr sentinel endpoints are rewritten per repo to
351+
`…/security/advisories/new`; dedup on type+endpoint; at most one `preferred`; sort preferred >
352+
accepted > fallback > prohibited. Only when nothing is declared: up to 3 `inferred`/`fallback`
353+
methods derived from live `security_contacts` (email, github-pvr, web-form channels, by score).
354+
Every repo in the population gets a row — `declared=false` with an empty `methods` array for
355+
the ~89 no-signal repos.
356+
357+
### LLM contract
358+
359+
Direct AWS Bedrock calls (`@aws-sdk/client-bedrock-runtime`, module-local in `llmExtract.ts`)
360+
— deliberately **not** the legacy class-based `LlmService` in `common_services` (class pattern
361+
+ prompt-history DB coupling) and **not** a shared provider-agnostic lib speaking to a LiteLLM
362+
proxy (built during implementation, then dropped: no LiteLLM infra today; revisit if CDP
363+
standardizes multi-provider LLM infrastructure — schema and prompt carry over unchanged).
364+
Existing `CROWD_AWS_BEDROCK_ACCESS_KEY_ID`/`CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY` credentials;
365+
default `LlmModelType.CLAUDE_HAIKU_4_5` with region from `LLM_MODEL_REGION_MAP`. The JSON
366+
schema is embedded in the system prompt (Bedrock InvokeModel has no structured-output mode);
367+
`parseLlmJson` parses the answer. Missing credentials or any failure → `degraded` parse, never
368+
a thrown error. No prompt-history persistence.
369+
370+
### Scheduling
371+
372+
Own Temporal schedule `reporting-protocol-ingestion` (daily 07:00, `SKIP` overlap, 24 h
373+
execution timeout) inside the security-contacts worker, independent of the contacts schedule so
374+
a slow LLM pass never stalls contact ingestion. The workflow drains parsing first
375+
(`continueAsNew` while a batch parsed anything; an all-failed batch falls through to assembly
376+
instead of recursing — failed blobs get no row and retry on the next daily tick), then drains
377+
assembly. Batch sizes: 200 blobs (parse), 2,000 repos (assemble). Both activities ride the
378+
shared 30-minute proxy and heartbeat on a fixed 30 s cadence under its 2-minute
379+
`heartbeatTimeout`.
380+
381+
### Deferred
382+
383+
Other interaction-profile domains (contribution intake, governance, maintainer roster,
384+
communication channels, code of conduct — the content-keyed cache and section pattern extend
385+
to them); org-level `.github` default files (GitHub serves them for repos without their own
386+
SECURITY.md; the inventory doesn't capture them — measure the gap first); non-GitHub declared
387+
parsing (no file inventory; such repos assemble as `declared=false` + inferred fallback); API
388+
exposure on the akrites endpoints.

pnpm-lock.yaml

Lines changed: 17 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

services/apps/packages_worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"@crowd/slack": "workspace:*",
9292
"@crowd/types": "workspace:*",
9393
"@anthropic-ai/claude-agent-sdk": "^0.3.216",
94+
"@aws-sdk/client-bedrock-runtime": "^3.572.0",
9495
"@dsnp/parquetjs": "^1.7.0",
9596
"@google-cloud/bigquery": "^8.3.1",
9697
"@google-cloud/storage": "7.19.0",

services/apps/packages_worker/src/activities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export { processRubyGemsCoreBatch, processRubyGemsCriticalBatch } from './rubyge
5252
export {
5353
processSecurityContactsBatch,
5454
ingestSecurityContactsForPurlActivity,
55+
runProtocolParseBatch,
56+
runProtocolAssembleBatch,
5557
} from './security-contacts/activities'
5658
export {
5759
blastRadiusStart,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { scheduleReportingProtocolIngestion } from '../security-contacts/protocol/schedule'
12
import { scheduleSecurityContactsIngestion } from '../security-contacts/schedule'
23
import { svc } from '../service'
34

45
setImmediate(async () => {
56
await svc.init()
67
await scheduleSecurityContactsIngestion()
8+
await scheduleReportingProtocolIngestion()
79
await svc.start()
810
})

services/apps/packages_worker/src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { LlmModelType } from '@crowd/types'
2+
13
function requireEnv(name: string): string {
24
const val = process.env[name]
35
if (!val) throw new Error(`Missing required environment variable: ${name}`)
@@ -119,3 +121,17 @@ export function getDockerhubConfig() {
119121
idleSleepSec: requireEnvInt('DOCKERHUB_IDLE_SLEEP_SEC'),
120122
}
121123
}
124+
125+
export function getReportingProtocolConfig() {
126+
return {
127+
parseBatchSize: parseInt(process.env.REPORTING_PROTOCOL_PARSE_BATCH_SIZE ?? '200', 10),
128+
assembleBatchSize: parseInt(process.env.REPORTING_PROTOCOL_ASSEMBLE_BATCH_SIZE ?? '2000', 10),
129+
concurrency: parseInt(process.env.REPORTING_PROTOCOL_CONCURRENCY ?? '10', 10),
130+
fetchTimeoutMs: parseInt(process.env.REPORTING_PROTOCOL_FETCH_TIMEOUT_MS ?? '15000', 10),
131+
llmModelId: process.env.REPORTING_PROTOCOL_LLM_MODEL_ID ?? LlmModelType.CLAUDE_HAIKU_4_5,
132+
llmTimeoutMs: parseInt(process.env.REPORTING_PROTOCOL_LLM_TIMEOUT_MS ?? '60000', 10),
133+
llmConcurrency: parseInt(process.env.REPORTING_PROTOCOL_LLM_CONCURRENCY ?? '4', 10),
134+
llmAccessKeyId: process.env.CROWD_AWS_BEDROCK_ACCESS_KEY_ID,
135+
llmSecretAccessKey: process.env.CROWD_AWS_BEDROCK_SECRET_ACCESS_KEY,
136+
}
137+
}

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import { heartbeat } from '@temporalio/activity'
2+
13
import { getServiceChildLogger } from '@crowd/logging'
24

3-
import { getSecurityContactsConfig } from '../config'
5+
import { getReportingProtocolConfig, getSecurityContactsConfig } from '../config'
46
import { getCdpDb, getPackagesDb } from '../db'
57

8+
import { githubApiGet } from './githubToken'
69
import { IngestSingleResult, ingestSecurityContactsForPurl } from './ingestSingle'
710
import { BatchResult, processBatch } from './processBatch'
11+
import { runAssembleStage } from './protocol/assembleStage'
12+
import { fetchLinkedPage } from './protocol/fetchContent'
13+
import { llmExtractProtocol } from './protocol/llmExtract'
14+
import { runParseStage } from './protocol/parseStage'
15+
import { AssembleStageResult, ParseStageResult } from './protocol/types'
816

917
const log = getServiceChildLogger('security-contacts-activity')
1018

@@ -29,3 +37,42 @@ export async function ingestSecurityContactsForPurlActivity(
2937
log.info({ purl, ...result }, 'On-demand security contacts ingest activity complete')
3038
return result
3139
}
40+
41+
// Fixed-cadence heartbeat, same rationale as processBatch.ts: a slow blob (LLM call) can
42+
// outlast the 2-minute heartbeatTimeout on the shared activity proxy.
43+
async function withHeartbeat<T>(fn: () => Promise<T>): Promise<T> {
44+
const heartbeatTimer = setInterval(() => {
45+
try {
46+
heartbeat()
47+
} catch (err) {
48+
log.warn({ errMsg: (err as Error).message }, 'Heartbeat failed')
49+
}
50+
}, 30_000)
51+
try {
52+
return await fn()
53+
} finally {
54+
clearInterval(heartbeatTimer)
55+
}
56+
}
57+
58+
export async function runProtocolParseBatch(): Promise<ParseStageResult> {
59+
const cfg = getReportingProtocolConfig()
60+
const qx = await getPackagesDb()
61+
const result = await withHeartbeat(() =>
62+
runParseStage(
63+
qx,
64+
{ githubGet: githubApiGet, fetchPage: fetchLinkedPage, llmExtract: llmExtractProtocol },
65+
cfg,
66+
),
67+
)
68+
log.info({ ...result }, 'Reporting protocol parse batch activity complete')
69+
return result
70+
}
71+
72+
export async function runProtocolAssembleBatch(): Promise<AssembleStageResult> {
73+
const cfg = getReportingProtocolConfig()
74+
const qx = await getPackagesDb()
75+
const result = await withHeartbeat(() => runAssembleStage(qx, cfg))
76+
log.info({ ...result }, 'Reporting protocol assemble batch activity complete')
77+
return result
78+
}

0 commit comments

Comments
 (0)