diff --git a/.github/workflows/sentry-contract.yml b/.github/workflows/sentry-contract.yml new file mode 100644 index 0000000000..f4bb2bcebb --- /dev/null +++ b/.github/workflows/sentry-contract.yml @@ -0,0 +1,62 @@ +name: Sentry SDK contract + +on: + pull_request: + paths: + - .github/workflows/sentry-contract.yml + - server/package.json + - server/src/sentry*.ts + - server/src/peer-version-check.ts + - server/src/__tests__/*sentry*.test.ts + push: + branches: [master] + paths: + - .github/workflows/sentry-contract.yml + - server/package.json + - server/src/sentry*.ts + - server/src/peer-version-check.ts + - server/src/__tests__/*sentry*.test.ts + +permissions: + contents: read + +concurrency: + group: sentry-contract-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + sentry-contract: + name: Real Sentry SDK isolation + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + package-manager-cache: false + + - name: Setup pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Install the audited optional SDK outside the workspace + shell: bash + run: | + sentry_version=$(node -p 'require("./server/package.json").peerDependencies["@sentry/node"]') + npm install --prefix "$RUNNER_TEMP/sentry-contract-sdk" --ignore-scripts --no-audit --no-fund --package-lock=false "@sentry/node@$sentry_version" + + - name: Verify run failure isolation with the real SDK + env: + NODE_PATH: ${{ runner.temp }}/sentry-contract-sdk/node_modules + PAPERCLIP_REQUIRE_SENTRY_TEST_SDK: "1" + run: pnpm --filter @paperclipai/server exec vitest run src/__tests__/run-failure-sentry-real-sdk.test.ts diff --git a/server/src/__tests__/http-log-redaction.test.ts b/server/src/__tests__/http-log-redaction.test.ts index a16e56e69a..62def738d8 100644 --- a/server/src/__tests__/http-log-redaction.test.ts +++ b/server/src/__tests__/http-log-redaction.test.ts @@ -399,6 +399,35 @@ describe("HTTP logger redaction", () => { expect(log.res.headers["set-cookie"]).toBe("[Redacted]"); }); + it.each([200, 403, 500])("redacts runtime GitHub capabilities from HTTP %i logs", async (status) => { + const capability = "runtime-github-capability-canary"; + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + const app = express(); + app.use(createHttpLogger(pino({ redact: [...HTTP_LOG_REDACT_PATHS] }, stream))); + app.post("/runtime-tools/github/credentials", (_req, res) => { + res.status(status).json({ status }); + }); + + await request(app) + .post("/runtime-tools/github/credentials") + .set("X-Paperclip-Github-Capability", capability) + .send({}) + .expect(status); + + const output = chunks.join(""); + expect(output).not.toContain(capability); + const log = JSON.parse(output.trim()); + expect(log.req.headers["x-paperclip-github-capability"]).toBe("[Redacted]"); + expect(log.req.url).toBe("/runtime-tools/github/credentials"); + expect(log.res.statusCode).toBe(status); + }); + it("drops OAuth callback query data from the message and structured request", async () => { const chunks: string[] = []; const stream = new Writable({ diff --git a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts index 75f4707edf..524b52445c 100644 --- a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts +++ b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts @@ -20,6 +20,10 @@ const sentryPackage = (() => { } })(); +if (process.env.PAPERCLIP_REQUIRE_SENTRY_TEST_SDK === "1" && !sentryPackage) { + throw new Error("The Sentry SDK contract job requires the audited optional peer"); +} + afterEach(async () => { await sentryPackage?.close(2000); vi.unstubAllEnvs(); diff --git a/server/src/middleware/http-log-redaction.ts b/server/src/middleware/http-log-redaction.ts index a7d7ed05f2..8b50c5cf37 100644 --- a/server/src/middleware/http-log-redaction.ts +++ b/server/src/middleware/http-log-redaction.ts @@ -10,6 +10,8 @@ export const HTTP_LOG_REDACT_PATHS = [ 'req.headers["x-csrf-token"]', 'req.headers["x-xsrf-token"]', 'req.headers["x-api-key"]', + // Runtime GitHub capabilities authorize credential acquisition for a live run. + 'req.headers["x-paperclip-github-capability"]', // Telegram's optional webhook verification header is a reusable bearer // secret sent on every provider callback. 'req.headers["x-telegram-bot-api-secret-token"]',