Skip to content

Commit 354b1ad

Browse files
committed
fix(resolver): enforce terminal Git trailer block and noreply recipient safety
- Require Signed-off-by to be in the terminal Git trailer block, rejecting sign-offs followed by later body text - Never resolve recipient email solely from matching contributor-controlled names when commit author uses @users.noreply.github.com - Preserve exact noreply DCO attribution (dcoVerified: true) while keeping recipient unresolved (resolvedEmail: null) - Add regression tests for terminal trailer block parsing and noreply spoofing prevention Signed-off-by: Parth Gartan <parthgartan26feb@gmail.com>
1 parent c676fcf commit 354b1ad

2 files changed

Lines changed: 123 additions & 15 deletions

File tree

‎utils/identity-resolver.js‎

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,57 @@ function maskEmail(email) {
3636
}
3737

3838
/**
39-
* Extracts all valid Signed-off-by trailers from a commit message.
39+
* Extracts all valid Signed-off-by trailers from the terminal Git trailer block of a commit message.
4040
* Formats supported: "Signed-off-by: First Last <email@domain.com>"
41-
* Strictly validates trailer syntax and email format.
41+
*
42+
* Terminal Trailer Block Rules (Git interpret-trailers specification):
43+
* 1. Must be located in the terminal paragraph at the end of the commit message.
44+
* 2. Every line in the terminal block must be a valid trailer line ("Token: Value").
45+
* 3. No subsequent body text may follow the trailer block.
46+
* 4. Strictly validates trailer syntax and email format.
4247
*
4348
* @param {string} message Commit message
4449
* @returns {Array<{ name: string, email: string }>}
4550
*/
4651
function extractDcoTrailers(message) {
4752
if (!message || typeof message !== 'string') return [];
53+
54+
// Normalize line breaks and trim trailing whitespace
55+
const normalized = message.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
56+
if (!normalized) return [];
57+
58+
// Split into paragraphs separated by one or more blank lines
59+
const paragraphs = normalized.split(/\n[ \t]*\n+/);
60+
const terminalParagraph = paragraphs[paragraphs.length - 1].trim();
61+
if (!terminalParagraph) return [];
62+
63+
const lines = terminalParagraph.split('\n');
64+
65+
// Verify that EVERY line in the terminal paragraph is a valid trailer line
66+
// (e.g. "Signed-off-by: ...", "Co-authored-by: ...", "Fixes: ...", "Token: Value")
67+
const genericTrailerRegex = /^[ \t]*[A-Za-z0-9-_]+:[ \t]*.*$/;
68+
for (const line of lines) {
69+
if (!genericTrailerRegex.test(line)) {
70+
// If there is regular body text in the terminal block, it is not a valid trailer block
71+
return [];
72+
}
73+
}
74+
75+
// Parse Signed-off-by trailers from the terminal trailer block
76+
const dcoTrailerRegex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/i;
4877
const trailers = [];
49-
const regex = /^[ \t]*Signed-off-by:[ \t]*([^<\r\n]+)<([^>\r\n]+)>[ \t]*$/gim;
50-
let match;
51-
while ((match = regex.exec(message)) !== null) {
52-
const name = match[1].trim();
53-
const rawEmail = match[2].trim();
54-
if (name && isValidEmail(rawEmail)) {
55-
trailers.push({ name, email: rawEmail.toLowerCase() });
78+
79+
for (const line of lines) {
80+
const match = dcoTrailerRegex.exec(line);
81+
if (match) {
82+
const name = match[1].trim();
83+
const rawEmail = match[2].trim();
84+
if (name && isValidEmail(rawEmail)) {
85+
trailers.push({ name, email: rawEmail.toLowerCase() });
86+
}
5687
}
5788
}
89+
5890
return trailers;
5991
}
6092

@@ -107,6 +139,12 @@ function isTrailerAttributableToAuthor(trailer, gitAuthor) {
107139
* → DCO Signed-off-by trailer deterministically attributable to that commit author
108140
* → verified RFC-compliant email
109141
*
142+
* Security & Anti-Spoofing Invariant:
143+
* When a commit author uses a GitHub noreply email (@users.noreply.github.com),
144+
* a real recipient email is NEVER resolved solely from matching contributor-controlled names.
145+
* Exact noreply DCO attribution is preserved (dcoVerified: true), but resolvedEmail
146+
* remains null.
147+
*
110148
* Privacy Invariant:
111149
* The returned reason string NEVER contains plaintext contributor email addresses.
112150
*
@@ -148,13 +186,18 @@ function resolveIdentity(prAuthor, commits) {
148186
}
149187

150188
const commitEmails = [];
189+
let hasNoreplyAuthor = false;
151190

152191
for (let idx = 0; idx < authorCommits.length; idx++) {
153192
const item = authorCommits[idx];
154193
const sha = (item && item.sha ? item.sha.slice(0, 7) : `commit-${idx + 1}`);
155194

156195
// Git commit author metadata
157196
const gitAuthor = item.commit && item.commit.author ? item.commit.author : {};
197+
const gitEmail = (gitAuthor.email || '').trim().toLowerCase();
198+
if (gitEmail.endsWith('@users.noreply.github.com')) {
199+
hasNoreplyAuthor = true;
200+
}
158201

159202
// Extract DCO trailers
160203
const message = item.commit ? item.commit.message : (item.message || '');
@@ -204,12 +247,23 @@ function resolveIdentity(prAuthor, commits) {
204247

205248
const verifiedEmail = distinctEmails[0];
206249

207-
// If the verified trailer is a GitHub noreply address, DCO is valid but recipient cannot be mapped to a Layer5 user
208-
if (verifiedEmail.endsWith('@users.noreply.github.com')) {
250+
// If the commit author used a GitHub noreply address (@users.noreply.github.com),
251+
// never resolve a real recipient email solely from contributor-controlled names.
252+
// Exact noreply DCO attribution is preserved (dcoVerified: true), but recipient
253+
// remains unresolved (resolvedEmail: null).
254+
if (hasNoreplyAuthor || verifiedEmail.endsWith('@users.noreply.github.com')) {
255+
if (verifiedEmail.endsWith('@users.noreply.github.com')) {
256+
return {
257+
resolvedEmail: null,
258+
dcoVerified: true,
259+
reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient`
260+
};
261+
}
262+
209263
return {
210264
resolvedEmail: null,
211265
dcoVerified: true,
212-
reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but recipient uses a GitHub noreply address (@users.noreply.github.com) which cannot be mapped to a Layer5 award recipient`
266+
reason: `Verified ${authorCommits.length} commit(s) by @${prAuthor} with DCO Signed-off-by trailer, but commit author uses a GitHub noreply address (@users.noreply.github.com); recipient cannot be resolved from contributor-controlled names`
213267
};
214268
}
215269

‎utils/identity-resolver.test.js‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ test('extractDcoTrailers rejects unanchored and prefixed trailer lines', () => {
6464
assert.equal(trailers[0].email, 'lee@layer5.io');
6565
});
6666

67+
test('extractDcoTrailers rejects Signed-off-by followed by later body text (terminal trailer block invariant)', () => {
68+
// 1. Later body text in a subsequent paragraph
69+
const msgWithLaterParagraph = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote <lee@layer5.io>\n\nNote: This commit was later amended and should not be credited.`;
70+
assert.equal(extractDcoTrailers(msgWithLaterParagraph).length, 0);
71+
72+
// 2. Later body text in the same paragraph
73+
const msgWithSameParagraphBody = `feat(api): update endpoints\n\nSigned-off-by: Lee Calcote <lee@layer5.io>\nAdditional explanatory body text here.`;
74+
assert.equal(extractDcoTrailers(msgWithSameParagraphBody).length, 0);
75+
76+
// 3. Body text before Signed-off-by in the same paragraph without blank line
77+
const msgWithPrecedingParagraphBody = `feat(api): update endpoints\n\nSome body text without empty line separation\nSigned-off-by: Lee Calcote <lee@layer5.io>`;
78+
assert.equal(extractDcoTrailers(msgWithPrecedingParagraphBody).length, 0);
79+
});
80+
81+
test('resolveIdentity fails closed when Signed-off-by is not in terminal trailer block', () => {
82+
const commits = [
83+
{
84+
sha: 'terminal1234567',
85+
author: { login: 'leecalcote' },
86+
commit: {
87+
author: { name: 'Lee Calcote', email: 'lee@layer5.io' },
88+
message: 'fix: update configuration\n\nSigned-off-by: Lee Calcote <lee@layer5.io>\n\nLater text explaining the change'
89+
}
90+
}
91+
];
92+
93+
const result = resolveIdentity('leecalcote', commits);
94+
assert.equal(result.dcoVerified, false);
95+
assert.equal(result.resolvedEmail, null);
96+
assert.ok(result.reason.includes('missing a valid DCO Signed-off-by trailer'));
97+
});
98+
6799
test('isTrailerAttributableToAuthor handles direct matches and noreply requirements', () => {
68100
// Direct email match
69101
assert.equal(
@@ -144,7 +176,7 @@ test('resolveIdentity: normal author + matching sign-off', () => {
144176
assert.equal(result.resolvedEmail, 'lee@layer5.io');
145177
});
146178

147-
test('resolveIdentity: GitHub noreply commit author with matching trailer name', () => {
179+
test('resolveIdentity: GitHub noreply commit author with matching trailer name preserves DCO but keeps recipient unresolved', () => {
148180
const commits = [
149181
{
150182
sha: 'noreply12345678',
@@ -157,8 +189,30 @@ test('resolveIdentity: GitHub noreply commit author with matching trailer name',
157189
];
158190

159191
const result = resolveIdentity('octocat', commits);
160-
assert.equal(result.dcoVerified, true);
161-
assert.equal(result.resolvedEmail, 'mona@example.com');
192+
assert.equal(result.dcoVerified, true, 'DCO attribution is preserved for matching trailer name');
193+
assert.equal(result.resolvedEmail, null, 'Must never resolve recipient email solely from matching contributor-controlled name on noreply author');
194+
assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names'));
195+
});
196+
197+
test('resolveIdentity: spoofing regression - attacker using noreply author cannot claim victim email via matching trailer name', () => {
198+
const victimEmail = 'victim@layer5.io';
199+
const commits = [
200+
{
201+
sha: 'spoof12345678',
202+
author: { login: 'attacker' },
203+
commit: {
204+
// Attacker sets their git author name to victim's name, but author email is attacker's GitHub noreply
205+
author: { name: 'Victim User', email: '99999+attacker@users.noreply.github.com' },
206+
message: `feat: malicious change\n\nSigned-off-by: Victim User <${victimEmail}>`
207+
}
208+
}
209+
];
210+
211+
const result = resolveIdentity('attacker', commits);
212+
assert.equal(result.resolvedEmail, null, 'Must never resolve spoofed victim email from noreply commit author');
213+
assert.equal(result.dcoVerified, true, 'DCO trailer name match preserves DCO attribution');
214+
assert.ok(result.reason.includes('cannot be resolved from contributor-controlled names'));
215+
assert.equal(result.reason.includes(victimEmail), false, 'Reason must not leak victim email');
162216
});
163217

164218
test('resolveIdentity: GitHub noreply commit author who signs off with noreply address (CASE B)', () => {

0 commit comments

Comments
 (0)