diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 51516b2c6af..e95f7771260 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,6 +5,7 @@ Here is a small checklist of actions to get you started with this PR. You may re Checklist : - Check the contribution guide at https://github.com/secdev/scapy/blob/master/CONTRIBUTING.md (esp. section submitting-pull-requests) +- Sign the Contributor License Agreement at https://github.com/secdev/scapy/blob/master/CLA.md (a bot will comment on this PR to tell you how) - Have good commit hygiene. They must have the `AI-Assisted` tag as explained in the contributing guide. Please squash commits that belong together, and split commits that contain multiple features. - AI: You must make sure that you understood the internal concepts of Scapy and have good test coverage (like >90%). Please review ALL the code you generated. - Add unit tests or explain why they are not relevant. diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000000..d7b34a98d94 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,296 @@ +name: CLA + +on: + pull_request_target: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +# Serialize per pull request, so a push and a signature racing on the same pull +# request cannot report out of order. Runs for different pull requests stay +# independent on purpose: a single global group would make GitHub cancel queued +# runs, which would leave stale statuses behind. Concurrent writes to the +# shared signature file are handled by the retry loop in the script instead. +concurrency: + group: cla-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: false + +permissions: {} + +env: + CLA_VERSION: "1.0" + CLA_DOCUMENT: https://github.com/secdev/scapy/blob/master/CLA.md + SIGNATURES_BRANCH: cla-signatures + SIGNATURES_PATH: signatures/v1/cla.json + SIGN_PHRASE: I have read the Scapy CLA Document and I hereby sign the CLA + # Logins that never need to sign, in addition to bot accounts (which are + # detected automatically). Use this to grandfather in contributors who + # agreed to the CLA out of band. + ALLOWLIST: "" + +jobs: + cla: + name: Verify CLA signatures + runs-on: ubuntu-latest + if: >- + github.event_name == 'pull_request_target' || + (github.event.issue.pull_request != null && + (github.event.comment.body == 'recheck' || + contains(github.event.comment.body, + 'I have read the Scapy CLA Document and I hereby sign the CLA'))) + permissions: + contents: write # append signatures to the signatures branch + pull-requests: write # post and update the CLA comment + statuses: write # report the CLA status on the pull request head + steps: + # This job deliberately never checks out the pull request. It runs with + # write permissions via pull_request_target, so it must not execute any + # code coming from the fork. Untrusted values (comment bodies, logins) + # are read through process.env and never interpolated into the script. + - name: Check signatures + uses: actions/github-script@v9 + with: + script: | + const { owner, repo } = context.repo; + const branch = process.env.SIGNATURES_BRANCH; + const path = process.env.SIGNATURES_PATH; + const document = process.env.CLA_DOCUMENT; + const phrase = process.env.SIGN_PHRASE; + const allowlist = new Set( + (process.env.ALLOWLIST || '') + .split(',').map(s => s.trim().toLowerCase()).filter(Boolean) + ); + const marker = ''; + + const isBot = (user) => + !user || user.type === 'Bot' || + user.login.endsWith('[bot]') || user.login === 'web-flow'; + + // --- Locate the pull request ----------------------------------- + const fromComment = context.eventName === 'issue_comment'; + const number = fromComment + ? context.payload.issue.number + : context.payload.pull_request.number; + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: number, + }); + + // --- Collect the authors of every commit in the pull request --- + // Note: GitHub caps this endpoint at 250 commits, and Co-authored-by + // trailers are not tracked. Both need a maintainer's eye. + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number: number, per_page: 100, + }); + + const authors = new Map(); + const unlinked = new Set(); + for (const commit of commits) { + if (!commit.author) { + // The commit e-mail is not attached to any GitHub account, so + // there is nobody we can hold the agreement against. + unlinked.add(commit.sha.substring(0, 7)); + continue; + } + if (isBot(commit.author)) continue; + if (allowlist.has(commit.author.login.toLowerCase())) continue; + authors.set(commit.author.login.toLowerCase(), commit.author.login); + } + + // --- Read the signatures on file, together with the commit they + // came from, so that writing back can be a compare-and-swap ------ + const readSignatures = async () => { + let parent = null; + try { + const { data: ref } = await github.rest.git.getRef({ + owner, repo, ref: `heads/${branch}`, + }); + parent = ref.object.sha; + } catch (error) { + if (error.status !== 404) throw error; + } + + let store = { + version: process.env.CLA_VERSION, signedContributors: [], + }; + if (parent) { + try { + const { data } = await github.rest.repos.getContent({ + owner, repo, path, ref: parent, + }); + store = JSON.parse( + Buffer.from(data.content, 'base64').toString('utf8') + ); + } catch (error) { + if (error.status !== 404) throw error; + } + } + return { store, parent }; + }; + + // Writes the file back onto `parent`, creating the branch on first + // use. updateRef is not forced, so GitHub rejects the write if the + // branch moved since it was read. + const writeSignatures = async (store, parent, message) => { + const body = JSON.stringify(store, null, 2) + '\n'; + const { data: blob } = await github.rest.git.createBlob({ + owner, repo, + content: Buffer.from(body).toString('base64'), + encoding: 'base64', + }); + + let baseTree; + if (parent) { + const { data: head } = await github.rest.git.getCommit({ + owner, repo, commit_sha: parent, + }); + baseTree = head.tree.sha; + } + + const { data: tree } = await github.rest.git.createTree({ + owner, repo, + tree: [{ path, mode: '100644', type: 'blob', sha: blob.sha }], + ...(baseTree ? { base_tree: baseTree } : {}), + }); + const { data: commit } = await github.rest.git.createCommit({ + owner, repo, message, tree: tree.sha, + parents: parent ? [parent] : [], + }); + + if (parent) { + await github.rest.git.updateRef({ + owner, repo, ref: `heads/${branch}`, sha: commit.sha, + }); + } else { + // Orphan branch: the signature log shares no history with master. + await github.rest.git.createRef({ + owner, repo, ref: `refs/heads/${branch}`, sha: commit.sha, + }); + } + }; + + let { store } = await readSignatures(); + let signed = new Set( + store.signedContributors.map(s => s.name.toLowerCase()) + ); + + // --- Record a signature, if that is what triggered this run ----- + if (fromComment) { + const comment = context.payload.comment; + const signer = comment.user; + const key = signer.login.toLowerCase(); + const agrees = comment.body.trim() === phrase; + + if (agrees && !isBot(signer) && !authors.has(key)) { + core.warning( + `${signer.login} tried to sign but authored no commit here.` + ); + } else if (agrees && !isBot(signer) && !signed.has(key)) { + // Two people can sign different pull requests at the same + // moment. The loser of that race re-reads the file and applies + // its signature on top rather than overwriting the winner's. + for (let attempt = 1; attempt <= 5; attempt++) { + const current = await readSignatures(); + store = current.store; + if (store.signedContributors.some( + s => s.name.toLowerCase() === key)) break; + store.signedContributors.push({ + name: signer.login, + id: signer.id, + pullRequestNo: number, + comment_id: comment.id, + created_at: new Date().toISOString(), + cla_version: process.env.CLA_VERSION, + }); + try { + await writeSignatures( + store, current.parent, + `${signer.login} signed the CLA (${owner}/${repo}#${number})` + ); + core.info(`Recorded a CLA signature for ${signer.login}`); + break; + } catch (error) { + const raced = [409, 422].includes(error.status); + if (!raced || attempt === 5) throw error; + core.info(`Signature file changed, retrying (${attempt}/5)`); + await new Promise(done => setTimeout(done, 500 * attempt)); + } + } + signed = new Set( + store.signedContributors.map(s => s.name.toLowerCase()) + ); + } + } + + // --- Decide ---------------------------------------------------- + const pending = [...authors.entries()] + .filter(([key]) => !signed.has(key)) + .map(([, login]) => login); + const passing = pending.length === 0 && unlinked.size === 0; + + // --- Report on the pull request -------------------------------- + let body = `${marker}\n## Contributor License Agreement\n\n`; + if (passing) { + body += 'All contributors to this pull request have signed the ' + + `[Scapy CLA](${document}). Thank you!\n`; + } else { + body += 'Thanks for your pull request! Before it can be merged, ' + + 'everyone who authored a commit in it has to sign the ' + + `[Scapy Contributor License Agreement](${document}).\n\n`; + if (pending.length) { + body += '**Waiting for a signature from:** ' + + pending.map(login => `@${login}`).join(', ') + '\n\n' + + 'To sign, read the CLA and then post a **new comment on this ' + + 'pull request** whose entire body is:\n\n' + + '```text\n' + phrase + '\n```\n\n'; + } + if (unlinked.size) { + body += '**These commits are not attached to a GitHub account:** ' + + [...unlinked].join(', ') + '\n\n' + + 'We cannot tell who authored them, so they cannot be covered ' + + 'by a signature. Add the commit e-mail address to your GitHub ' + + 'account, or amend the commits to use an address that is ' + + 'already on it, and push again.\n\n'; + } + body += 'Once every author has signed, comment `recheck` if this ' + + 'message does not update by itself.\n'; + } + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: number, per_page: 100, + }); + const existing = comments.find( + c => isBot(c.user) && c.body.includes(marker) + ); + if (existing) { + if (existing.body !== body) { + await github.rest.issues.updateComment({ + owner, repo, comment_id: existing.id, body, + }); + } + } else if (!passing) { + // Stay quiet on pull requests that were compliant from the start. + await github.rest.issues.createComment({ + owner, repo, issue_number: number, body, + }); + } + + // --- Report as a commit status --------------------------------- + // The status is what branch protection should require: unlike this + // job's own check, it lands on the pull request head commit even + // when the run was triggered by a comment. + const description = passing + ? 'All contributors have signed the CLA' + : (pending.length + ? `Waiting for: ${pending.join(', ')}` + : 'Some commits are not attached to a GitHub account'); + await github.rest.repos.createCommitStatus({ + owner, repo, sha: pr.head.sha, + state: passing ? 'success' : 'failure', + context: 'CLA', + description: description.substring(0, 140), + target_url: document, + }); + + if (!passing) { + core.setFailed(description); + } diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000000..4d5d0be6672 --- /dev/null +++ b/CLA.md @@ -0,0 +1,144 @@ +# Scapy Individual Contributor License Agreement + +**Version 1.0** + +> [!IMPORTANT] +> **This document is a draft and has not been reviewed by a lawyer.** It is +> adapted from the Apache Software Foundation Individual Contributor License +> Agreement v2.0 and is provided as a starting point only. The Scapy +> maintainers must have it reviewed by qualified legal counsel before the CLA +> check is made a required status check. Delete this note once that review has +> happened. + +Thank you for your interest in Scapy (the "Project"), maintained by the Scapy +community and its maintainers (collectively, "We" or "Us"). + +In order to clarify the intellectual property license granted with +Contributions from any person or entity, We must have on file a signed +Contributor License Agreement ("Agreement") from each Contributor, indicating +agreement to the license terms below. This Agreement is for your protection as +a Contributor as well as the protection of the Project and its users. **It does +not change your rights to use your own Contributions for any other purpose.** + +You accept and agree to the following terms and conditions for Your present and +future Contributions submitted to Us. Except for the licenses granted herein to +Us and to recipients of software distributed by Us, You reserve all right, +title, and interest in and to Your Contributions. + +## 1. Definitions + +**"You"** (or **"Your"**) means the copyright owner, or the legal entity +authorized by the copyright owner, that is entering into this Agreement with +Us. For legal entities, the entity making a Contribution and all other entities +that control, are controlled by, or are under common control with that entity +are considered to be a single Contributor. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the direction or +management of such entity, whether by contract or otherwise, or (ii) ownership +of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +**"Contribution"** means any original work of authorship, including any +modifications or additions to an existing work, that is intentionally submitted +by You to Us for inclusion in, or documentation of, the Project. For the +purposes of this definition, "submitted" means any form of electronic, verbal, +or written communication sent to Us or Our representatives, including but not +limited to communication on pull requests, issues, and mailing lists managed by +or on behalf of Us for the purpose of discussing and improving the Project, but +excluding communication that is conspicuously marked or otherwise designated in +writing by You as "Not a Contribution". + +## 2. Grant of Copyright License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us +and to recipients of software distributed by Us a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable copyright license to +reproduce, prepare derivative works of, publicly display, publicly perform, +sublicense, and distribute Your Contributions and such derivative works. + +## 3. Grant of Patent License + +Subject to the terms and conditions of this Agreement, You hereby grant to Us +and to recipients of software distributed by Us a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, import, +and otherwise transfer the Project, where such license applies only to those +patent claims licensable by You that are necessarily infringed by Your +Contribution alone or by combination of Your Contribution with the Project to +which such Contribution was submitted. + +If any entity institutes patent litigation against You or any other entity +(including a cross-claim or counterclaim in a lawsuit) alleging that Your +Contribution, or the Project to which You have contributed, constitutes direct +or contributory patent infringement, then any patent licenses granted to that +entity under this Agreement for that Contribution or Project shall terminate as +of the date such litigation is filed. + +## 4. Licensing of the Project + +You acknowledge that the Project is currently distributed under the GNU General +Public License, version 2 (see [`LICENSE`](LICENSE)), and that Your +Contributions will be distributed under that license. The license granted in +Section 2 additionally permits Us to distribute Your Contributions under other +license terms, should the Project's maintainers decide to change or supplement +the Project's license in the future. + +## 5. Your Representations + +You represent that: + +1. You are legally entitled to grant the above licenses. +2. Each of Your Contributions is Your original creation. +3. If Your employer has rights to intellectual property that You create, + including Your Contributions, You have received permission to make + Contributions on behalf of that employer, or Your employer has waived such + rights for Your Contributions to Us, or Your employer has executed a + separate Corporate Contributor License Agreement with Us. +4. Your Contributions include complete details of any third-party license or + other restriction (including, but not limited to, related patents and + trademarks) of which You are personally aware and which are associated with + any part of Your Contributions. + +## 6. Disclaimer + +You are not expected to provide support for Your Contributions, except to the +extent You desire to provide support. You may provide support for free, for a +fee, or not at all. Unless required by applicable law or agreed to in writing, +You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, +MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +## 7. Submitting Work That Is Not Your Own + +Should You wish to submit work that is not Your original creation, You may +submit it separately from any Contribution, identifying the complete details of +its source and of any license or other restriction (including, but not limited +to, related patents, trademarks, and license agreements) of which You are +personally aware, and conspicuously marking the work as "Submitted on behalf of +a third party: [named here]". + +## 8. Notification + +You agree to notify Us of any facts or circumstances of which You become aware +that would make these representations inaccurate in any respect. + +## How to sign + +Signing is handled automatically on GitHub. When you open your first pull +request against [secdev/scapy](https://github.com/secdev/scapy), a bot will +comment asking you to sign. To sign, post a **new comment on that pull +request** whose entire body is exactly: + +```text +I have read the Scapy CLA Document and I hereby sign the CLA +``` + +Your GitHub username and the time of signing are then recorded in +`signatures/v1/cla.json` on the `cla-signatures` branch of the repository. You +only need to do this once; later pull requests are checked against that record. + +If the check does not update after you sign, comment `recheck` on the pull +request. + +If you are contributing on behalf of a company and your employer requires a +Corporate CLA, please open an issue so the maintainers can arrange one. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13879cf58da..fc0bf02d651 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,17 @@ of function calls, packet creations, etc.). ## Submitting pull requests +### Contributor License Agreement + +Before your first pull request can be merged, you need to sign the +[Scapy Contributor License Agreement](CLA.md). It confirms that you are allowed +to contribute the code and that we may distribute it; it does not take any +rights to your own work away from you. + +There is nothing to do up front. A bot comments on your pull request and tells +you how to sign, which is a matter of posting one comment. You only ever sign +once, and later pull requests are checked against that signature automatically. + ### Coding style & conventions - All commits should include the `AI-Assisted: (yes/no) [tool]` tag. This is used to disclose the AI tools that are used when authoring. You must check the commits you produce, or your PR might be closed. The tag may look like such: