Skip to content

Commit a75de41

Browse files
fix: tries to solve a bug where layne doesn't suppress preexisting findings with security comment (#26)
1 parent 34d2fae commit a75de41

File tree

5 files changed

+206
-30
lines changed

5 files changed

+206
-30
lines changed

src/__tests__/suppressor.test.js

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,22 @@ function rejectWith(err = new Error('not found')) {
1515
mockExecFile.mockImplementationOnce((_cmd, _args, cb) => cb(err, '', ''));
1616
}
1717

18-
const BASE = { workspacePath: '/workspace', baseSha: 'base-sha' };
18+
// Sets up both the git diff (identity map: headLine === baseLine) and git show
19+
// (file content) calls for a single file. Use when line shifts are not relevant.
20+
function mockFile(filePath, content) {
21+
const lines = content.split('\n');
22+
const count = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
23+
const diff = [
24+
`--- a/${filePath}`,
25+
`+++ b/${filePath}`,
26+
`@@ -1,${count} +1,${count} @@`,
27+
...Array(count).fill(' x'),
28+
].join('\n');
29+
resolveWith(diff); // for buildLineMapForFile (git diff)
30+
resolveWith(content); // for gitShow (git show)
31+
}
32+
33+
const BASE = { workspacePath: '/workspace', baseSha: 'base-sha', headSha: 'head-sha' };
1934

2035
const finding = (overrides = {}) => ({
2136
file: 'src/app.js',
@@ -37,61 +52,61 @@ describe('suppressFindings()', () => {
3752
});
3853

3954
it('keeps a finding when git show fails (new file)', async () => {
40-
rejectWith(new Error('fatal: path not in tree'));
55+
resolveWith(''); // diff: empty map → fall back to head line
56+
rejectWith(new Error('fatal: path not in tree')); // show: fails
4157
const f = finding();
4258
const result = await suppressFindings([f], BASE);
4359
expect(result).toEqual([f]);
4460
});
4561

4662
it('keeps a finding when there is no SECURITY: comment at base', async () => {
47-
resolveWith('line1\nline2\nline3\nline4\nline5\nline6\n');
63+
mockFile('src/app.js', 'line1\nline2\nline3\nline4\nline5\nline6\n');
4864
const f = finding({ line: 5 });
4965
const result = await suppressFindings([f], BASE);
5066
expect(result).toEqual([f]);
5167
});
5268

5369
it('suppresses a finding when // SECURITY: reason is on the same line', async () => {
54-
resolveWith('line1\nline2\nline3\nline4\nconst x = 1; // SECURITY: reviewed by alice\nline6\n');
70+
mockFile('src/app.js', 'line1\nline2\nline3\nline4\nconst x = 1; // SECURITY: reviewed by alice\nline6\n');
5571
const result = await suppressFindings([finding({ line: 5 })], BASE);
5672
expect(result).toEqual([]);
5773
});
5874

5975
it('suppresses a finding when // SECURITY: reason is on the line immediately above', async () => {
60-
resolveWith('line1\nline2\nline3\n// SECURITY: approved in issue #42\nconst x = 1;\nline6\n');
76+
mockFile('src/app.js', 'line1\nline2\nline3\n// SECURITY: approved in issue #42\nconst x = 1;\nline6\n');
6177
const result = await suppressFindings([finding({ line: 5 })], BASE);
6278
expect(result).toEqual([]);
6379
});
6480

6581
it('suppresses a finding with # SECURITY: (YAML/shell comment style)', async () => {
66-
resolveWith('line1\nline2\nline3\nline4\n# SECURITY: checked for injection\nline6\n');
82+
mockFile('src/app.js', 'line1\nline2\nline3\nline4\n# SECURITY: checked for injection\nline6\n');
6783
const result = await suppressFindings([finding({ line: 5 })], BASE);
6884
expect(result).toEqual([]);
6985
});
7086

7187
it('does NOT suppress when // SECURITY: has no text after the colon', async () => {
72-
resolveWith('line1\nline2\nline3\nline4\n// SECURITY:\nline6\n');
88+
mockFile('src/app.js', 'line1\nline2\nline3\nline4\n// SECURITY:\nline6\n');
7389
const f = finding({ line: 5 });
7490
const result = await suppressFindings([f], BASE);
7591
expect(result).toEqual([f]);
7692
});
7793

78-
it('calls execFile only once for two findings in the same file (cache hit)', async () => {
79-
resolveWith('line1\nline2\nline3\nline4\nline5\n');
94+
it('calls execFile exactly twice for two findings in the same file (cache hit)', async () => {
95+
mockFile('src/app.js', 'line1\nline2\nline3\nline4\nline5\n');
8096
const f1 = finding({ line: 2 });
8197
const f2 = finding({ line: 4 });
8298
await suppressFindings([f1, f2], BASE);
83-
expect(mockExecFile).toHaveBeenCalledTimes(1);
99+
expect(mockExecFile).toHaveBeenCalledTimes(2);
84100
});
85101

86102
it('does not crash and keeps the finding when finding.line === 1', async () => {
87-
resolveWith('const x = 1;\nline2\n');
103+
mockFile('src/app.js', 'const x = 1;\nline2\n');
88104
const f = finding({ line: 1 });
89105
const result = await suppressFindings([f], BASE);
90106
expect(result).toEqual([f]);
91107
});
92108

93109
it('does not suppress an unvalidated Claude finding', async () => {
94-
resolveWith('line1\n// SECURITY: unrelated prior approval\nline3\n');
95110
const f = finding({
96111
tool: 'claude',
97112
line: 2,
@@ -103,10 +118,11 @@ describe('suppressFindings()', () => {
103118
const result = await suppressFindings([f], BASE);
104119

105120
expect(result).toEqual([f]);
121+
expect(mockExecFile).not.toHaveBeenCalled();
106122
});
107123

108124
it('uses suppressionLine when checking for SECURITY comments', async () => {
109-
resolveWith('line1\nline2\n// SECURITY: approved in prior PR\nline4\n');
125+
mockFile('src/app.js', 'line1\nline2\n// SECURITY: approved in prior PR\nline4\n');
110126

111127
const result = await suppressFindings([finding({
112128
tool: 'claude',
@@ -122,14 +138,8 @@ describe('suppressFindings()', () => {
122138
});
123139

124140
it('returns the correct filtered array for a mixed batch', async () => {
125-
// file A: has a SECURITY comment above line 3
126-
const fileAContent = 'line1\n// SECURITY: reviewed by bob\nconst y = 2;\nline4\n';
127-
// file B: no SECURITY comment
128-
const fileBContent = 'line1\nline2\nline3\n';
129-
130-
mockExecFile
131-
.mockImplementationOnce((_cmd, _args, cb) => cb(null, fileAContent, ''))
132-
.mockImplementationOnce((_cmd, _args, cb) => cb(null, fileBContent, ''));
141+
mockFile('src/a.js', 'line1\n// SECURITY: reviewed by bob\nconst y = 2;\nline4\n');
142+
mockFile('src/b.js', 'line1\nline2\nline3\n');
133143

134144
const fA = finding({ file: 'src/a.js', line: 3 }); // should be suppressed
135145
const fB = finding({ file: 'src/b.js', line: 1 }); // should be kept
@@ -139,8 +149,9 @@ describe('suppressFindings()', () => {
139149
});
140150

141151
it('handles git show failure for file A independently from success for file B', async () => {
142-
rejectWith(new Error('not found'));
143-
resolveWith('line1\nline2\nline3\n');
152+
resolveWith(''); // fA: diff returns empty → fall back to head line
153+
rejectWith(new Error('not found')); // fA: show fails
154+
mockFile('src/b.js', 'line1\nline2\nline3\n'); // fB: diff + show succeed
144155

145156
const fA = finding({ file: 'src/a.js', line: 2 });
146157
const fB = finding({ file: 'src/b.js', line: 2 });
@@ -149,4 +160,72 @@ describe('suppressFindings()', () => {
149160
// fA kept (git show failed), fB kept (no SECURITY comment)
150161
expect(result).toEqual([fA, fB]);
151162
});
163+
164+
// --- Line-shift tests ---
165+
166+
it('suppresses a finding on a shifted pre-existing line', async () => {
167+
// PR inserts one line at the top; everything shifts down by 1.
168+
// Finding is at head line 5 (ignoreSsrfValidation) → maps to base line 4.
169+
// SECURITY comment is at base line 3 (lineAbove base line 4) → suppressed.
170+
const diff = [
171+
'--- a/src/app.js',
172+
'+++ b/src/app.js',
173+
'@@ -1,5 +1,6 @@',
174+
'+INSERTED',
175+
' line1',
176+
' line2',
177+
' // SECURITY: approved in prior PR',
178+
' ignoreSsrfValidation: true',
179+
' line5',
180+
].join('\n');
181+
resolveWith(diff);
182+
resolveWith('line1\nline2\n// SECURITY: approved in prior PR\nignoreSsrfValidation: true\nline5\n');
183+
184+
const result = await suppressFindings([finding({ line: 5 })], BASE);
185+
expect(result).toEqual([]);
186+
});
187+
188+
it('keeps a finding on a newly added line', async () => {
189+
// ignoreSsrfValidation: true is a + line in the diff — must not be suppressed
190+
// even though a SECURITY comment exists nearby.
191+
const diff = [
192+
'--- a/src/app.js',
193+
'+++ b/src/app.js',
194+
'@@ -1,4 +1,5 @@',
195+
' line1',
196+
' line2',
197+
'+ignoreSsrfValidation: true',
198+
' // SECURITY: pre-existing comment for something else',
199+
' line4',
200+
].join('\n');
201+
resolveWith(diff); // git show is never reached for a newly added line
202+
203+
const result = await suppressFindings([finding({ line: 3 })], BASE);
204+
expect(result).toEqual([finding({ line: 3 })]);
205+
expect(mockExecFile).toHaveBeenCalledTimes(1);
206+
});
207+
208+
it('keeps a finding when there is no SECURITY comment at the correct mapped base line', async () => {
209+
// PR inserts one line at the top; finding at head line 5 → base line 4.
210+
// Base line 5 has a SECURITY comment — the old (broken) suppressor would
211+
// have checked base line 5 (using the head line number directly) and
212+
// wrongly suppressed. The new suppressor checks base line 4 and correctly keeps.
213+
const diff = [
214+
'--- a/src/app.js',
215+
'+++ b/src/app.js',
216+
'@@ -1,5 +1,6 @@',
217+
'+INSERTED',
218+
' line1',
219+
' line2',
220+
' line3',
221+
' ignoreSsrfValidation: true',
222+
' // SECURITY: comment at base line 5, not line 3',
223+
].join('\n');
224+
resolveWith(diff);
225+
resolveWith('line1\nline2\nline3\nignoreSsrfValidation: true\n// SECURITY: comment at base line 5, not line 3\n');
226+
227+
// head line 5 → base line 4; lineAbove = base line 3 = 'line3' → no match
228+
const result = await suppressFindings([finding({ line: 5 })], BASE);
229+
expect(result).toEqual([finding({ line: 5 })]);
230+
});
152231
});

src/__tests__/worker.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ describe('processJob()', () => {
233233
expect(suppressFindings).toHaveBeenCalledWith(rawFindings, {
234234
workspacePath: '/tmp/layne-test-workspace',
235235
baseSha: 'merge-base-sha',
236+
headSha: 'abc123',
236237
});
237238
});
238239

src/fetcher.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,65 @@ function isWithinPath(targetPath, basePath) {
150150
return targetPath === basePath || targetPath.startsWith(`${basePath}${sep}`);
151151
}
152152

153+
/**
154+
* Builds a Map<headLine, baseLine | null> for a single file by parsing the
155+
* unified diff between baseSha and headSha.
156+
*
157+
* - Context lines ( ): headLine → baseLine (unchanged, possibly shifted)
158+
* - Added lines (+): headLine → null (new in this PR, no base equivalent)
159+
* - Removed lines (-): no head entry (gone from head, base line consumed)
160+
*
161+
* --unified=999999 ensures every unchanged line appears as a context line so
162+
* the map covers the entire head file, not just changed regions.
163+
*/
164+
export async function buildLineMapForFile({ workspacePath, baseSha, headSha, filePath }) {
165+
const patch = await git([
166+
'-C', workspacePath, 'diff',
167+
'--unified=999999', '--no-color', '--no-ext-diff',
168+
baseSha, headSha, '--', filePath,
169+
]);
170+
return parseLineMap(patch, filePath);
171+
}
172+
173+
function parseLineMap(patch, filePath) {
174+
const map = new Map();
175+
let headLine = null;
176+
let baseLine = null;
177+
let inFile = false;
178+
179+
for (const line of patch.split('\n')) {
180+
if (line.startsWith('+++ ')) {
181+
inFile = stripGitPathPrefix(line.slice(4).trim()) === filePath;
182+
continue;
183+
}
184+
185+
if (!inFile) continue;
186+
187+
if (line.startsWith('@@')) {
188+
const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
189+
if (!match) continue;
190+
baseLine = Number.parseInt(match[1], 10);
191+
headLine = Number.parseInt(match[2], 10);
192+
continue;
193+
}
194+
195+
if (headLine === null) continue;
196+
197+
if (line.startsWith(' ')) {
198+
map.set(headLine, baseLine);
199+
headLine++;
200+
baseLine++;
201+
} else if (line.startsWith('+')) {
202+
map.set(headLine, null);
203+
headLine++;
204+
} else if (line.startsWith('-')) {
205+
baseLine++;
206+
}
207+
}
208+
209+
return map;
210+
}
211+
153212
function parseChangedLineRanges(patch) {
154213
const rangesByFile = {};
155214
let currentFile = null;

src/suppressor.js

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFile } from 'child_process';
2+
import { buildLineMapForFile } from './fetcher.js';
23

34
const SECURITY_COMMENT_RE = /(?:\/\/|#)\s*SECURITY:\s+\S/;
45

@@ -15,11 +16,18 @@ function gitShow(workspacePath, baseSha, filePath) {
1516
* the base SHA — meaning the comment was reviewed and merged in a prior PR.
1617
* New `// SECURITY:` comments added in the current PR are invisible to this
1718
* function (they're not in base), so self-approval is impossible.
19+
*
20+
* Uses a diff-based line map to translate HEAD line numbers to base line
21+
* numbers before reading the base file. This handles cases where a PR adds
22+
* lines earlier in a file, shifting the finding's position relative to base.
23+
* Findings on newly added lines (mapped to null in the diff) are never
24+
* suppressed regardless of what comments exist nearby.
1825
*/
19-
export async function suppressFindings(findings, { workspacePath, baseSha }) {
26+
export async function suppressFindings(findings, { workspacePath, baseSha, headSha }) {
2027
if (findings.length === 0) return [];
2128

22-
const fileCache = new Map();
29+
const fileCache = new Map();
30+
const lineMapCache = new Map();
2331

2432
async function getLines(filePath) {
2533
if (fileCache.has(filePath)) return fileCache.get(filePath);
@@ -34,25 +42,54 @@ export async function suppressFindings(findings, { workspacePath, baseSha }) {
3442
return lines;
3543
}
3644

45+
async function getLineMap(filePath) {
46+
if (lineMapCache.has(filePath)) return lineMapCache.get(filePath);
47+
let map = null;
48+
try {
49+
map = await buildLineMapForFile({ workspacePath, baseSha, headSha, filePath });
50+
} catch {
51+
// Diff unavailable — fall back to using head line numbers directly
52+
}
53+
lineMapCache.set(filePath, map);
54+
return map;
55+
}
56+
3757
const kept = [];
3858
for (const finding of findings) {
3959
if (finding.tool === 'claude' && finding.locationValidated !== true) {
4060
kept.push(finding);
4161
continue;
4262
}
4363

64+
const headLineNumber = finding.suppressionLine ?? finding.startLine ?? finding.line;
65+
66+
const lineMap = await getLineMap(finding.file);
67+
let baseLookupLine;
68+
69+
if (lineMap === null || !lineMap.has(headLineNumber)) {
70+
// Diff unavailable or line not in map — fall back to head line number
71+
baseLookupLine = headLineNumber;
72+
} else {
73+
const mapped = lineMap.get(headLineNumber);
74+
if (mapped === null) {
75+
// Newly added line in this PR — cannot have a pre-existing approval
76+
kept.push(finding);
77+
continue;
78+
}
79+
baseLookupLine = mapped;
80+
}
81+
4482
const lines = await getLines(finding.file);
4583
if (lines === null) {
4684
kept.push(finding);
4785
continue;
4886
}
4987

50-
const line = finding.suppressionLine ?? finding.startLine ?? finding.line;
51-
const sameLine = lines[line - 1] ?? '';
52-
const lineAbove = lines[line - 2] ?? '';
88+
const sameLine = lines[baseLookupLine - 1] ?? '';
89+
const lineAbove = lines[baseLookupLine - 2] ?? '';
5390

5491
if (SECURITY_COMMENT_RE.test(sameLine) || SECURITY_COMMENT_RE.test(lineAbove)) {
55-
console.log(`[suppressor] suppressed finding ${finding.file}:${line} [${finding.ruleId}] — SECURITY: comment found at base`);
92+
console.log(`[suppressor] suppressed finding ${finding.file}:${headLineNumber} [${finding.ruleId}] — SECURITY: comment found at base`);
5693
} else {
5794
kept.push(finding);
5895
}

src/worker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ async function runScan(job) {
180180
const rawFindings = await dispatch({ workspacePath, baseSha, baseRef, changedFiles, changedLineRanges, labels, owner, repo });
181181
const validatedFindings = await validateFindingLocations(rawFindings, { workspacePath, changedFiles, changedLineRanges });
182182
logFindingPlacement(validatedFindings, { owner, repo, prNumber });
183-
const findings = await suppressFindings(validatedFindings, { workspacePath, baseSha: mergeBaseSha });
183+
const findings = await suppressFindings(validatedFindings, { workspacePath, baseSha: mergeBaseSha, headSha });
184184
const actionableFindings = findings.filter(isActionableFinding);
185185
const discardedCount = findings.length - actionableFindings.length;
186186
console.log(`[worker] ${actionableFindings.length} actionable finding(s) for ${owner}/${repo} PR #${prNumber} across all tools:`);

0 commit comments

Comments
 (0)