{"record":{"id":"4a0774c6114b36f8","repo":"paperclipai/paperclip","slug":"unsafe-protocol-eval-campaign-id","errorCode":null,"errorMessage":"Unsafe protocol eval campaign ID","messagePattern":"Unsafe protocol eval campaign ID","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs","lineNumber":248,"sourceCode":"      );\n  }\n  const campaign = await loadObject(join(root, \"campaign.json\"));\n  if (\n    campaign.schema !== \"paperclip.runner-protocol-eval.campaign/v1\" ||\n    !SAFE_CAMPAIGN.test(String(campaign.campaignId ?? \"\"))\n  ) {\n    throw new Error(\"Public report campaign metadata is invalid\");\n  }\n  return { files, campaign };\n}\n\nexport async function createProtocolEvalBundleManifest(\n  reportRoot,\n  campaignId,\n  { viewerRoot } = {},\n) {\n  if (!SAFE_CAMPAIGN.test(campaignId))\n    throw new Error(\"Unsafe protocol eval campaign ID\");\n  const { files, campaign } = await validatePublicProtocolEvalReport(\n    reportRoot,\n    { viewerRoot },\n  );\n  if (campaign.campaignId !== campaignId)\n    throw new Error(\"Report campaign ID does not match publication target\");\n  const entries = await Promise.all(\n    files.map(async (file) => {\n      const absolute = resolve(reportRoot, ...file.split(\"/\"));\n      const [content, metadata] = await Promise.all([\n        readFile(absolute),\n        stat(absolute),\n      ]);\n      return {\n        path: file,\n        sha256: createHash(\"sha256\").update(content).digest(\"hex\"),\n        bytes: metadata.size,\n      };","sourceCodeStart":230,"sourceCodeEnd":266,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs#L230-L266","documentation":"createProtocolEvalBundleManifest validates the campaignId argument against SAFE_CAMPAIGN before doing any work, because the ID becomes an S3 key segment and a public URL path. An ID with path separators, dots, or unexpected characters is rejected as unsafe. The report's own campaign.json is validated separately, so this guard fires on the caller-supplied argument.","triggerScenarios":"Calling createProtocolEvalBundleManifest(reportRoot, campaignId) with a campaignId like '../evil', 'gha_1_1', 'gha-1-1/report', or an empty string; programmatic callers deriving the ID from an untrusted source.","commonSituations":"Wrapping the publisher in custom CI tooling and passing a raw branch/PR name as the campaign ID; typos in environment interpolation producing empty or malformed IDs.","solutions":["Pass a campaignId matching /^gha-[1-9][0-9]*-[1-9][0-9]*(-report-[a-z0-9][a-z0-9-]{0,39})?$/","Derive the ID from GITHUB_RUN_ID/GITHUB_RUN_ATTEMPT instead of free-form names","Pre-validate with the SAFE_CAMPAIGN regex before calling","Sanitize a slug suffix: lowercase, strip non [a-z0-9-], cap at 39 chars after 'report-'"],"exampleFix":"// before\nawait createProtocolEvalBundleManifest(reportRoot, process.env.CAMPAIGN_ID);\n// after\nconst campaignId = `gha-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT ?? 1}`;\nif (!/^gha-[1-9][0-9]*-[1-9][0-9]*(-report-[a-z0-9][a-z0-9-]{0,39})?$/.test(campaignId))\n  throw new Error(`bad campaign id: ${campaignId}`);\nawait createProtocolEvalBundleManifest(reportRoot, campaignId);","handlingStrategy":"validation","validationCode":"const SAFE_CAMPAIGN = /^gha-[1-9][0-9]*-[1-9][0-9]*(-report-[a-z0-9][a-z0-9-]{0,39})?$/;\nif (!SAFE_CAMPAIGN.test(campaignId)) throw new Error(`refusing unsafe campaign id: ${campaignId}`);","typeGuard":"const isSafeCampaignId = (v) => typeof v === \"string\" && /^gha-[1-9][0-9]*-[1-9][0-9]*(-report-[a-z0-9][a-z0-9-]{0,39})?$/.test(v);","tryCatchPattern":"try {\n  await createProtocolEvalBundleManifest(reportRoot, campaignId);\n} catch (e) {\n  if (String(e.message).includes(\"Unsafe protocol eval campaign ID\")) {\n    throw new Error(`campaignId '${campaignId}' must match gha-<run>-<attempt>[-report-<slug>]`);\n  }\n  throw e;\n}","preventionTips":["Derive campaign IDs from GITHUB_RUN_ID/GITHUB_RUN_ATTEMPT, never from branch or PR names","Regex-validate any externally sourced ID before passing it in","Sanitize slug suffixes to lowercase [a-z0-9-], max 39 chars"],"tags":["validation","security","path-traversal"],"backgroundTag":"invalid-identifier-format","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}