Skip to content

feat(project-profiling): vuln reporting protocol [CM-1331] - #4413

Open
mbani01 wants to merge 12 commits into
mainfrom
feat/vuln-reporting-protocol
Open

feat(project-profiling): vuln reporting protocol [CM-1331]#4413
mbani01 wants to merge 12 commits into
mainfrom
feat/vuln-reporting-protocol

Conversation

@mbani01

@mbani01 mbani01 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a new system for parsing and assembling per-repository vulnerability reporting protocols, alongside dependency and configuration updates to support this feature. The main changes include new database tables, configuration and activity wiring, and dependency additions for AWS Bedrock LLM integration.

Reporting Protocol Feature:

  • Added new database tables: security_policy_parses (content-keyed parse cache for security policy files and linked pages) and repo_reporting_protocols (assembled per-repo reporting protocol), as described in ADR-0010 addendum.
  • Documented the reporting protocol data model, parsing, LLM contract, and assembly process in the ADR, including rationale for deterministic and LLM-based parsing, merge rules, and scheduling.

Worker Implementation:

  • Added configuration for reporting protocol batch sizes, concurrency, timeouts, and LLM credentials in getReportingProtocolConfig. [1] [2]
  • Implemented new Temporal activities: runProtocolParseBatch and runProtocolAssembleBatch, with fixed-cadence heartbeats to avoid timeouts during long LLM calls. [1] [2] [3]

Dependency and Package Updates:

  • Added @aws-sdk/client-bedrock-runtime dependency for direct AWS Bedrock LLM calls. [1] [2]
  • Updated several transitive dependencies and lockfile entries, including minor updates to http-errors, qs, and statuses. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]

These changes lay the groundwork for robust, scalable ingestion and assembly of vulnerability reporting protocols across repositories, leveraging both deterministic and LLM-based parsing.

mbani01 added 9 commits July 28, 2026 16:18
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Copilot AI review requested due to automatic review settings July 29, 2026 11:21
@mbani01 mbani01 self-assigned this Jul 29, 2026
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New LLM-backed extraction and outbound fetches for policy URLs affect security-reporting guidance quality; SSRF mitigations are partial (DNS rebinding noted). No public API in this PR, but wrong protocols could mislead future consumers.

Overview
Adds a vulnerability reporting protocol pipeline alongside security contacts: per repo it records how to report (methods, guidelines, provenance), not who to contact.

Storage: New security_policy_parses (content-keyed parse cache for security files and linked pages) and repo_reporting_protocols (assembled methods / guidelines / sources per repo_id). ADR-0010 documents the addendum.

Ingestion: The security-contacts worker gains a parse stage (unparsed security blobs from repo_well_known_files → GitHub blob fetch → deterministic classifier, else Bedrock Haiku with a validator that blocks invented endpoints) and an assemble stage (merge parses, apply pvr_enabled veto/add, resolve github-pvr and security.txt sentinels, dedupe; only if undeclared, up to three inferred methods from security_contacts). Pointer-only policies fetch up to three linked pages with SSRF guards before marking the blob done.

Ops: New Temporal workflow ingestReportingProtocols (parse batches then assemble, continueAsNew while work remains) on schedule reporting-protocol-ingestion (daily 07:00, SKIP overlap), registered from security-contacts-worker. getReportingProtocolConfig and @aws-sdk/client-bedrock-runtime support LLM calls. A read-only validate-reporting-protocol.sql script is included for prod checks. API exposure is deferred in the ADR.

Reviewed by Cursor Bugbot for commit a492849. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts Outdated
Comment thread services/apps/packages_worker/src/config.ts

Copilot AI left a comment

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.

Pull request overview

Adds a Temporal pipeline that parses repository security policies and assembles vulnerability-reporting protocols.

Changes:

  • Adds deterministic and Bedrock-assisted policy parsing.
  • Adds protocol assembly, persistence, scheduling, and validation tooling.
  • Documents the architecture and updates dependencies.

Metadata: PR title should end with (CM-1331), not [CM-1331].

Reviewed changes

Copilot reviewed 18 out of 20 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
services/apps/packages_worker/src/workflows/index.ts Exports the workflow.
services/apps/packages_worker/src/tmp/validate-reporting-protocol.sql Adds production validation queries.
services/apps/packages_worker/src/security-contacts/workflows.ts Drains parse and assembly batches.
services/apps/packages_worker/src/security-contacts/protocol/validate.ts Validates parsed LLM output.
services/apps/packages_worker/src/security-contacts/protocol/types.ts Defines protocol contracts.
services/apps/packages_worker/src/security-contacts/protocol/schedule.ts Schedules daily ingestion.
services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts Implements policy parsing.
services/apps/packages_worker/src/security-contacts/protocol/llmExtract.ts Integrates AWS Bedrock.
services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts Fetches policy content.
services/apps/packages_worker/src/security-contacts/protocol/classify.ts Adds deterministic classification.
services/apps/packages_worker/src/security-contacts/protocol/assembleStage.ts Selects and persists assembled protocols.
services/apps/packages_worker/src/security-contacts/protocol/assemble.ts Implements protocol merge rules.
services/apps/packages_worker/src/security-contacts/activities.ts Exposes Temporal activities.
services/apps/packages_worker/src/config.ts Adds pipeline configuration.
services/apps/packages_worker/src/bin/security-contacts-worker.ts Registers the new schedule.
services/apps/packages_worker/src/activities.ts Exports protocol activities.
services/apps/packages_worker/package.json Adds the Bedrock SDK.
pnpm-lock.yaml Locks dependency updates.
docs/adr/0010-security-contacts-worker.md Documents the architecture.
backend/src/osspckgs/migrations/V1785283200__reporting_protocol.sql Creates protocol tables and index.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (3)

services/apps/packages_worker/src/security-contacts/protocol/classify.ts:92

  • GitHub PVR is excluded from negation handling, so text such as “do not use GitHub private vulnerability reporting” is classified as an accepted/preferred method. This contradicts the documented rule that negation on a method's own line becomes prohibited and can publish the exact channel a project forbids.
  for (const hit of hits) {
    if (NEGATION_RE.test(hit.line) && hit.method.type !== 'github-pvr') {
      hit.method.status = 'prohibited'
    }

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:121

  • The pvrEnabled === false veto is applied before inferred fallbacks are built, so a live security_contacts row with channel github-pvr reintroduces PVR for a repo whose authoritative flag disables it. The added validation query explicitly expects such repos to contain no GitHub PVR method.
  if (!declared) {
    finalMethods = input.fallbackContacts
      .filter((c) => FALLBACK_CHANNEL_MAP[c.channel])
      .sort((a, b) => b.score - a.score)
      .slice(0, 3)

services/apps/packages_worker/src/tmp/validate-reporting-protocol.sql:39

  • As with the type check, SQL NOT IN does not count a missing status because the predicate becomes NULL. Explicitly test for NULL so malformed methods are reported.
SELECT 'bad_status', COUNT(*)
FROM repo_reporting_protocols rp,
     jsonb_array_elements(rp.methods) m
WHERE m->>'status' NOT IN ('preferred','accepted','fallback','prohibited')

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts Outdated
Comment thread services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts Outdated
Comment thread services/apps/packages_worker/src/config.ts
Comment thread services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts Outdated
Comment thread services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts Outdated
Comment thread services/apps/packages_worker/src/tmp/validate-reporting-protocol.sql Outdated
return `https://github.com/${owner}/${name}/security/advisories/new`
}

export function assembleProtocol(input: AssembleInput): AssembledProtocol {

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.

The merge policy is covered: assemble.test.ts has focused Vitest cases for exactly these contracts — PVR veto, pvr-adds-when-silent, declared-vs-inferred fallback (cap + score order), dedup, single-preferred demotion, prohibited-last ordering, provenance stamping, and degraded-contributes-nothing. The team's current workflow keeps test files out of feature commits (they run locally/in dev), which is why they aren't visible in this PR — the suite is 47 tests green as of 424a10e.

return null
}

export function classifySecurityPolicy(text: string): ClassifierVerdict {

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.

The classifier has focused coverage in classify.test.ts: 10 behavioral cases spanning template fingerprint, negation→prohibited, preference cuing, conditional routing→residue, URL/email/bounty/issue-tracker classification, acknowledgement-section suppression, and pointer-only linkedUrls capture. Tests are kept out of feature commits under the team's current workflow, so they don't appear in this PR diff.

Comment on lines +117 to +121
export async function runParseStage(
qx: QueryExecutor,
deps: ParseStageDeps,
cfg: Cfg,
): Promise<ParseStageResult> {

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.

parseStage.test.ts covers the batch state machine with mocked deps: deterministic path, LLM-validated path, degraded path, pointer-only success (page row before file row), pointer-only all-pages-failed (no rows, counted failed, reachable next sweep), blob fetch failure, and the LLM concurrency cap. Tests are kept out of feature commits under the team's current workflow, so they aren't visible in this PR diff — 47 green as of 424a10e.

Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Copilot AI review requested due to automatic review settings July 29, 2026 12:49

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 18 out of 20 changed files in this pull request and generated 1 comment.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (16)

services/apps/packages_worker/src/security-contacts/protocol/classify.ts:44

  • This branch-heavy deterministic classifier has no automated coverage, despite packages_worker using colocated Vitest tests for comparable pure parsers (for example src/osv/__tests__/parseOsvRecord.test.ts and src/maven/__tests__/normalize.test.ts). Add fixtures covering preference, negation, conditional routing, templates, and linked URLs so protocol classifications cannot regress silently.
export function classifySecurityPolicy(text: string): ClassifierVerdict {

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:49

  • The merge/veto/fallback behavior introduced here has no unit tests, although packages_worker colocates Vitest coverage for similar pure logic. Add cases for PVR true/false, conflicting methods, degraded parses, inferred fallback ordering, deduplication, and preferred normalization; these rules directly determine the persisted public data.
export function assembleProtocol(input: AssembleInput): AssembledProtocol {

services/apps/packages_worker/src/security-contacts/protocol/assembleStage.ts:46

  • Staleness is based only on sp.parsed_at, so repository-level file changes are missed when a file is deleted or when a repo starts referencing an already-cached blob whose parse predates its protocol row. The existing protocol then keeps obsolete methods (or omits the new file) until an unrelated contacts refresh. Include repo_well_known_files.change_detected_at for both live and deleted security rows.
          OR EXISTS (
               SELECT 1 FROM repo_well_known_files w
               JOIN security_policy_parses sp ON sp.blob_oid = w.blob_oid
               WHERE w.repo_id = r.id AND w.file_type = 'security'
                 AND w.deleted_at IS NULL AND sp.parsed_at > rp.assembled_at)

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:121

  • The PVR veto is applied only to declared methods. When there are no declared methods, a live or stale security_contacts row with channel github-pvr is mapped back into finalMethods even when pvrEnabled === false, violating the stated invariant that such repos carry no GitHub PVR method. Apply the veto to fallback contacts as well.
    finalMethods = input.fallbackContacts
      .filter((c) => FALLBACK_CHANNEL_MAP[c.channel])
      .sort((a, b) => b.score - a.score)
      .slice(0, 3)

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:71

  • Sentinel endpoints bypass the source check entirely. An LLM response can therefore invent github-pvr or security-txt for text that mentions neither channel and still be persisted as an ok parse, contradicting the validator's no-invented-channel contract. Require source evidence for the sentinel's method type before accepting it.
    if (SENTINEL_ENDPOINT_BY_TYPE[m.type] === m.endpoint) continue

services/apps/packages_worker/src/security-contacts/protocol/classify.ts:92

  • GitHub PVR hits are exempted from negation, so text such as “Do not use private vulnerability reporting” is classified as one usable method, promoted to preferred, and stored deterministically without reaching the LLM. PVR plus negation needs to be treated as prohibited or ambiguous rather than clean.
  for (const hit of hits) {
    if (NEGATION_RE.test(hit.line) && hit.method.type !== 'github-pvr') {
      hit.method.status = 'prohibited'
    }

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:32

  • The runtime validator accepts a missing guidelines property even though the emitted JSON contract marks it required. Because Bedrock does not enforce the prompt schema, an object without this key can be stored as ok, leaving persisted JSON outside ParsedProtocol. Accept null, but reject undefined.
function hasValidGuidelinesShape(parsed: ParsedProtocol): boolean {
  const g = parsed.guidelines
  if (g === null || g === undefined) return true
  return (

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:65

  • This condition explicitly accepts an omitted condition, although the LLM contract requires every method to contain condition as either a string or null. A model response missing the field therefore passes validation and later produces protocol methods with an invalid persisted shape.
    if (m.condition !== null && m.condition !== undefined && typeof m.condition !== 'string') {
      reasons.push(`malformed condition on: ${m.type}`)
    }

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:58

  • Deduplication keeps the first matching method, but both json_agg inputs in assembleStage.ts are unordered. If two files/pages declare the same endpoint with different statuses, PostgreSQL row order decides whether it is preferred, accepted, or prohibited, so repeated assemblies can produce different protocols. Define deterministic source ordering and conflict precedence before dropping duplicates.
  const push = (m: ProtocolMethod) => {
    const key = `${m.type}:${m.endpoint.toLowerCase()}`
    if (seen.has(key)) return
    seen.add(key)
    methods.push(m)

services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts:41

  • DISTINCT ON always chooses the lowest repo id as the sole fetch location for a shared blob. If that repository is disabled, deleted, renamed, or otherwise inaccessible while another repo still exposes the same blob, every retry uses the permanently failing URL and all repositories sharing that policy remain unparsed. Retain multiple candidate repos or retry the blob through another live association.
       SELECT DISTINCT ON (w.blob_oid) w.blob_oid, r.url
       FROM repo_well_known_files w
       JOIN repos r ON r.id = w.repo_id AND r.host = 'github'
       JOIN package_repos pr ON pr.repo_id = w.repo_id
       JOIN packages p ON p.id = pr.package_id AND p.is_critical
       LEFT JOIN security_policy_parses sp
              ON sp.blob_oid = w.blob_oid AND sp.parser_version = $(parserVersion)
       WHERE w.file_type = 'security' AND w.deleted_at IS NULL AND sp.blob_oid IS NULL
       ORDER BY w.blob_oid, w.repo_id

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

  • Heartbeat failures are swallowed without checking Temporal's cancellationSignal, unlike the existing processBatch.ts worker loop. After a heartbeat timeout supersedes an attempt, the old attempt can continue scheduling fetches/LLM calls and writing rows concurrently with its retry, duplicating Bedrock cost and creating races. Propagate cancellation into both stage loops and stop scheduling work when aborted.
async function withHeartbeat<T>(fn: () => Promise<T>): Promise<T> {
  const heartbeatTimer = setInterval(() => {
    try {
      heartbeat()
    } catch (err) {
      log.warn({ errMsg: (err as Error).message }, 'Heartbeat failed')
    }

backend/src/osspckgs/migrations/V1785283200__reporting_protocol.sql:4

  • This column comment says linked-page keys hash extracted text, but processLinkedPages stores sha256Hex(url) and the ADR also specifies URL-keying. The migration documentation should describe the actual primary-key semantics used for joins and cache behavior.
    blob_oid       TEXT PRIMARY KEY, -- git blob oid for files; sha256 of extracted text for linked pages

services/apps/packages_worker/src/security-contacts/protocol/parseStage.ts:32

  • This new service module embeds selection, insert/upsert, and cache lookup SQL directly in the worker. The repository convention is that database query functions live in services/libs/data-access-layer (CLAUDE.md:32-34), so keeping these here creates another data-access path that cannot be reused or tested with the DAL. Move the query functions behind a DAL module.
async function selectUnparsedBlobs(qx: QueryExecutor, limit: number): Promise<BlobJob[]> {
  return qx.select(
    `SELECT * FROM (

services/apps/packages_worker/src/security-contacts/protocol/assembleStage.ts:34

  • The new assembly selection/upsert data access is implemented inside the worker, while CLAUDE.md:32-34 establishes services/libs/data-access-layer as the home for all database query functions. Move this SQL and persistence operation into a DAL module so schema access stays centralized and reusable.
async function selectReposToAssemble(qx: QueryExecutor, limit: number): Promise<RepoRow[]> {
  return qx.select(
    `WITH stale AS (

services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts:43

  • This HTML conversion discards anchor href values and replaces numeric entities such as &#64; with spaces. A linked policy like <a href="https://hackerone.com/acme">report here</a> or security&#64;example.com therefore loses the actual endpoint before classification, and the LLM validator cannot recover it because endpoints must occur in the extracted source. Preserve link targets and decode entities with an HTML parser/entity decoder.
    .replace(/<[^>]+>/g, ' ')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#?(?!amp;)\w+;/g, ' ')

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:44

  • The deterministic validator is the only gate between untrusted LLM JSON and persisted ok rows, but it has no unit tests. Add Vitest cases for missing/extra fields, invalid enums and guideline shapes, endpoint grounding (including obfuscated email), sentinel grounding, and preferred-count limits; comparable packages_worker parsers are covered under colocated __tests__ directories.
export function validateParsedProtocol(
  parsed: ParsedProtocol,
  sourceText: string,
): { ok: boolean; reasons: string[] } {

@mbani01

mbani01 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed (round 1)

Commit: 424a10e

Changes Made

  • fetchContent.ts: entity decoding reordered (no double-unescape) + spaced end-tag regex (per github-advanced-security); SSRF guard — http(s) only, private/loopback/link-local/metadata hosts blocked, redirects revalidated per hop; body cap enforced while streaming (per cursor, copilot-pull-request-reviewer)
  • parseStage.ts: pointer-only blobs marked done only after all linked pages parse → durable retry (per cursor, copilot-pull-request-reviewer); randomized batch order prevents queue starvation; linked-page rows keyed by sha256(url) to preserve URL association; LLM semaphore honoring llmConcurrency (per cursor, copilot-pull-request-reviewer)
  • validate.ts: structural shape guard on LLM output; non-empty endpoints; type-matched sentinel exemption (per copilot-pull-request-reviewer)
  • validate-reporting-protocol.sql: NULL-type/status checks + corrected healthy-state wording (per copilot-pull-request-reviewer)
  • docs/adr/0010: addendum updated for the above semantics

No Change Needed

  • 3 test-coverage threads (copilot-pull-request-reviewer): the classifier/assemble/parseStage suites exist (47 tests green incl. a live-DB integration test) but are kept out of feature commits under the team's current workflow — left unresolved for visibility.

Threads Resolved

15 of 18 addressed and resolved in this iteration.

Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
Copilot AI review requested due to automatic review settings July 29, 2026 13:26
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HTML entities wipe encoded emails

Medium Severity

htmlToText replaces numeric and named character references with spaces before linked pages are classified or sent to the LLM. Addresses written as &#64; / similar entities never become real emails, so reporting channels on HTML policy pages are dropped or fail endpoint validation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit be30d28. Configure here.

@mbani01

mbani01 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed — Round 2

Commit: be30d28

Changes Made

  • fetchContent.ts: broadened the script/style end-tag regexes to <\/script\b[^>]*> / <\/style\b[^>]*> so end tags with trailing whitespace/attributes (e.g. </script\t\n bar>) are stripped, without swallowing unrelated tags (per github-advanced-security[bot] / CodeQL)
  • fetchContent.ts: added DNS resolution + private-range validation on every redirect hop — a public hostname resolving to loopback/RFC1918/link-local/metadata (IPv4 and IPv6, including IPv4-mapped and ULA), or one that fails to resolve, is now refused before connecting (per copilot[bot])
  • assemble.ts / validate.ts: security-txt sentinel endpoints are rewritten to the real repos.security_txt_url when known, and dropped when no URL is discoverable, so the actionable methods list never carries a bare security.txt; the sentinel constant is now shared from validate.ts (per cursor[bot])

No Change Needed / Still Open

  • assemble.ts:50, classify.ts:44, parseStage.ts:177 — coverage flags (flagged by copilot[bot]). Unit tests for the merge policy, the deterministic classifier, and the batch state machine (partial/all-failure, linked-page retry, concurrency cap) do exist under protocol/__tests__/ — 53 passing — but are not part of the committed diff. Left open pending the test-commit decision.

Threads Resolved

4 of 4 code-change threads from this round addressed (SSRF/DNS resolved here; the CodeQL and security-txt threads were auto-resolved by their bots after the push and carry a documenting reply). The 3 coverage threads remain open by design.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 18 out of 20 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (8)

services/apps/packages_worker/src/security-contacts/protocol/assembleStage.ts:46

  • A deleted security file never makes the repo stale: this subquery only considers live rows joined to a parse, while repo_well_known_files.change_detected_at is explicitly bumped on disappearance. The persisted protocol can therefore continue advertising methods from a removed SECURITY file until an unrelated contacts refresh happens. Include file inventory changes (including soft deletions) in the stale predicate.
          OR EXISTS (
               SELECT 1 FROM repo_well_known_files w
               JOIN security_policy_parses sp ON sp.blob_oid = w.blob_oid
               WHERE w.repo_id = r.id AND w.file_type = 'security'
                 AND w.deleted_at IS NULL AND sp.parsed_at > rp.assembled_at)

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:71

  • Matching sentinel endpoints bypasses all source grounding. An LLM response can emit a valid-looking github-pvr/github-pvr or security-txt/security.txt method for text that never mentions that channel, and this validator accepts it as ok, contradicting the stated guarantee that the LLM cannot invent channels. Require type-specific source evidence before exempting a sentinel.
    if (SENTINEL_ENDPOINT_BY_TYPE[m.type] === m.endpoint) continue

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

  • The default parse batch can take 200 × 60s / 4 = 50 minutes when LLM calls reach their timeout, but these activities use the shared 30-minute start-to-close timeout. Temporal can retry the batch while the timed-out attempt keeps running (the heartbeat wrapper suppresses cancellation and the parser does not check cancellationSignal), duplicating Bedrock calls and concurrent writes. Use a protocol-specific timeout sized for the configured worst case and honor activity cancellation.
// Fixed-cadence heartbeat, same rationale as processBatch.ts: a slow blob (LLM call) can
// outlast the 2-minute heartbeatTimeout on the shared activity proxy.
async function withHeartbeat<T>(fn: () => Promise<T>): Promise<T> {

services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts:34

  • Unlike linked pages, repository-controlled security blobs have no body-size cap. githubApiGet materializes the entire raw blob as a string before classification or the 100k LLM truncation; GitHub permits blobs large enough that 10 concurrent jobs can consume substantial worker memory. Enforce a streaming/content-length limit before materializing the response.
  const { text } = await deps.githubGet(`/repos/${owner}/${name}/git/blobs/${blobOid}`, timeoutMs, {
    raw: true,
  })
  return text

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:58

  • Deduplication is first-wins, but file_parses and page_parses are produced by json_agg without an ORDER BY. When multiple policy sources emit the same type/endpoint with different statuses or provenance, reassembly can arbitrarily retain either version and flip persisted output without any input change. Define deterministic source/status precedence before discarding duplicates.
  const push = (m: ProtocolMethod) => {
    const key = `${m.type}:${m.endpoint.toLowerCase()}`
    if (seen.has(key)) return
    seen.add(key)

backend/src/osspckgs/migrations/V1785283200__reporting_protocol.sql:4

  • This comment contradicts the implementation and ADR: linked-page keys are SHA-256 hashes of the URL, not of extracted text. Keeping the schema documentation accurate matters because the key determines cache and join semantics.
    blob_oid       TEXT PRIMARY KEY, -- git blob oid for files; sha256 of extracted text for linked pages

services/apps/packages_worker/src/security-contacts/protocol/classify.ts:93

  • GitHub PVR is the only method excluded from line-local negation, contrary to the documented classifier rule. For example, “Do not use GitHub Security Advisories; please use security@example.com” is considered clean and persists PVR as accepted (unless the separate API flag happens to be false), so the assembled protocol advertises a channel the policy prohibits.
  for (const hit of hits) {
    if (NEGATION_RE.test(hit.line) && hit.method.type !== 'github-pvr') {
      hit.method.status = 'prohibited'
    }

services/apps/packages_worker/src/security-contacts/protocol/assemble.ts:165

  • This selects the first guidelines object from inputs whose SQL json_agg order is unspecified. Repositories with multiple live security files can therefore persist different guidelines on successive assemblies even when their inputs are unchanged. Apply an explicit source precedence/order (or merge the guideline sets) before choosing one.
  const guidelines =
    [...input.fileParses, ...input.pageParses].find(
      (p) => p.status !== 'degraded' && p.parsed.guidelines,
    )?.parsed.guidelines ?? null

Copilot AI review requested due to automatic review settings July 29, 2026 13:55

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a492849. Configure here.

.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HTML strip drops link targets

High Severity

htmlToText strips all tags before any attribute extraction, so mailto: addresses and href URLs that appear only in attributes never reach the classifier or LLM. Linked policy pages commonly put the actionable endpoint only in the anchor, so pointer-only parses can miss the declared channel entirely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a492849. Configure here.

const HEADING_RE = /^#{1,6}\s+(.*)$/
const KEYWORD_RE =
/\b(report|security|vulnerabilit(?:y|ies)|disclosure|disclose|contact|advisor(?:y|ies))\b/i
const NEGATIVE_SECTION_RE = /acknowledg|hall of fame|thanks|credits|honou?r|researchers/i

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Researchers heading over-suppressed

Medium Severity

NEGATIVE_SECTION_RE matches bare researchers, so headings like "For security researchers" cause every following line to be skipped until the next heading. Real reporting instructions often live under those sections, so declared methods are dropped before LLM fallback can see them on the clean path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a492849. Configure here.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 18 out of 20 changed files in this pull request and generated 7 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (7)

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:65

  • The required condition field can also be omitted because undefined is exempted from validation. Such a method is stored as ok and later emitted without the promised condition: string | null property. Reject undefined; only a string or explicit null satisfies the runtime contract.
    if (m.condition !== null && m.condition !== undefined && typeof m.condition !== 'string') {
      reasons.push(`malformed condition on: ${m.type}`)
    }

services/apps/packages_worker/src/security-contacts/protocol/assembleStage.ts:70

  • This fallback query includes contacts already classified as reachable = false. Those rows are intentionally retained by reconcile, so a high-scoring invalid/reserved email can become one of the three inferred reporting methods and direct reporters to a known unusable endpoint. Filter fallback candidates to reachable contacts, matching the worker's existing definition of a usable contact.
       (SELECT json_agg(json_build_object(
            'channel', sc.channel, 'value', sc.value, 'score', sc.score))
          FROM security_contacts sc
         WHERE sc.repo_id = r.id AND sc.deleted_at IS NULL
       ) AS fallback_contacts

backend/src/osspckgs/migrations/V1785283200__reporting_protocol.sql:4

  • This schema comment says linked-page keys hash extracted text, but processLinkedPages keys them with sha256(url) and the ADR documents URL hashing. Keeping the migration comment aligned is important because it describes the persisted primary-key contract.
    blob_oid       TEXT PRIMARY KEY, -- git blob oid for files; sha256 of extracted text for linked pages

services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts:41

  • Stripping every HTML tag also removes href values, so a linked policy page containing <a href="https://hackerone.com/acme">report here</a> reaches both classifiers as only “report here.” Web-form, bounty, and mailto: endpoints commonly exist only in links, causing the assembled protocol to miss the declared reporting method. Preserve actionable anchor URLs while converting linked HTML to text.
export function htmlToText(html: string): string {
  return html
    .replace(/<script[\s\S]*?<\/script\b[^>]*>/gi, ' ')
    .replace(/<style[\s\S]*?<\/style\b[^>]*>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')

services/apps/packages_worker/src/security-contacts/protocol/validate.ts:33

  • A response that omits the required guidelines field is treated as valid because undefined is accepted alongside the explicit null value. That malformed LLM output is therefore persisted with status='ok' even though both ParsedProtocol and the prompt schema require the field. Only explicit null should bypass shape validation.

This issue also appears on line 63 of the same file.

function hasValidGuidelinesShape(parsed: ParsedProtocol): boolean {
  const g = parsed.guidelines
  if (g === null || g === undefined) return true
  return (
    typeof g === 'object' &&

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

  • Heartbeating does not make the work cancellation-aware. Unlike the existing contacts batch (processBatch.ts:256), the new parse worker never checks Temporal's cancellationSignal, and its fetch/Bedrock calls use unrelated abort controllers. If the workflow or an activity attempt is canceled, this attempt can keep processing the remaining 200 blobs and issuing paid LLM requests while a retry/new run proceeds. Propagate the activity cancellation signal and stop scheduling work when it aborts.
// Fixed-cadence heartbeat, same rationale as processBatch.ts: a slow blob (LLM call) can
// outlast the 2-minute heartbeatTimeout on the shared activity proxy.
async function withHeartbeat<T>(fn: () => Promise<T>): Promise<T> {
  const heartbeatTimer = setInterval(() => {
    try {

services/apps/packages_worker/src/security-contacts/protocol/fetchContent.ts:45

  • All HTML entities except three hard-coded ones are replaced with spaces. Commonly obfuscated addresses such as security&#64;example&#46;org or security&commat;example.org therefore lose the endpoint before classification/LLM validation and cannot become a reporting method. Decode numeric and named entities instead of discarding them.
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#?(?!amp;)\w+;/g, ' ')
    .replace(/&amp;/g, '&')

Comment on lines +89 to +92
for (const hit of hits) {
if (NEGATION_RE.test(hit.line) && hit.method.type !== 'github-pvr') {
hit.method.status = 'prohibited'
}
Comment on lines +44 to +47
export async function ingestReportingProtocols(): Promise<void> {
const parsed = await acts.runProtocolParseBatch()
if (parsed.blobsParsed > 0) {
await continueAsNew<typeof ingestReportingProtocols>()
Comment on lines +42 to +46
OR EXISTS (
SELECT 1 FROM repo_well_known_files w
JOIN security_policy_parses sp ON sp.blob_oid = w.blob_oid
WHERE w.repo_id = r.id AND w.file_type = 'security'
AND w.deleted_at IS NULL AND sp.parsed_at > rp.assembled_at)
Comment on lines +31 to +34
const { text } = await deps.githubGet(`/repos/${owner}/${name}/git/blobs/${blobOid}`, timeoutMs, {
raw: true,
})
return text
Comment on lines +33 to +41
SELECT DISTINCT ON (w.blob_oid) w.blob_oid, r.url
FROM repo_well_known_files w
JOIN repos r ON r.id = w.repo_id AND r.host = 'github'
JOIN package_repos pr ON pr.repo_id = w.repo_id
JOIN packages p ON p.id = pr.package_id AND p.is_critical
LEFT JOIN security_policy_parses sp
ON sp.blob_oid = w.blob_oid AND sp.parser_version = $(parserVersion)
WHERE w.file_type = 'security' AND w.deleted_at IS NULL AND sp.blob_oid IS NULL
ORDER BY w.blob_oid, w.repo_id
Comment on lines +133 to +137
let finalMethods = declaredMethods
if (!declared) {
finalMethods = input.fallbackContacts
.filter((c) => FALLBACK_CHANNEL_MAP[c.channel])
.sort((a, b) => b.score - a.score)
Comment on lines +103 to +105
if (!cfg.accessKeyId || !cfg.secretAccessKey) {
log.warn({ modelId: cfg.modelId }, 'Missing Bedrock credentials — skipping LLM extraction')
return null
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants