Skip to content

Commit 294d984

Browse files
feat: new exception approvals workflow (#22)
1 parent bf06297 commit 294d984

22 files changed

+1756
-47
lines changed

.changeset/curly-tables-stand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": minor
3+
---
4+
5+
Adds a new feature that allows exceptions to be approved by specific teams or people

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# layne
22

3+
## 1.2.0
4+
5+
### Minor Changes
6+
7+
- **Exception Approvals**: Configure specific users or teams who can approve PRs that would otherwise fail the security scan. When an authorized approver approves a PR, Layne automatically re-runs the scan and passes it with a clear audit trail. Features include:
8+
- Automatic re-run on `pull_request_review` webhook when authorized approver approves
9+
- Team membership resolution via GitHub API
10+
- Approval validation against current commit SHA (new commits invalidate approvals)
11+
- Configurable exception labels (`onException`)
12+
- Always-on notifications for exception usage
13+
- Full audit trail in check run summary and chat notifications
14+
- See [Exception Approvals](./website/docs/exception-approvals.md) documentation
15+
316
## 1.1.1
417

518
### Patch Changes

config/layne.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"onFailure": ["needs-security-review"],
1111
"removeOnSuccess": ["needs-security-review"],
1212
"onSuccess": [],
13-
"removeOnFailure": []
13+
"removeOnFailure": [],
14+
"onException": ["security-exception-used"]
1415
}
1516
},
1617
"acme/frontend": {
@@ -46,6 +47,10 @@
4647
"webhookUrl": "$PAYMENTS_ROCKETCHAT_WEBHOOK_URL",
4748
"template": ":rotating_light: *Payment system alert — {{repo}} PR #{{prNumber}}*\n{{total}} finding(s): {{critical}} critical, {{high}} high, {{medium}} medium, {{low}} low"
4849
}
50+
},
51+
"exceptionApprovers": {
52+
"users": ["payments-security-lead"],
53+
"teams": ["acme/payments-security"]
4954
}
5055
},
5156
"RocketChat/security": {

src/__tests__/config.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,39 @@ describe('loadScanConfig()', () => {
299299
const config = await loadScanConfig({ owner: 'acme', repo: 'payments' });
300300
expect(config.comment.template).toBe('repo tpl');
301301
});
302+
303+
// --- exceptionApprovers ---
304+
305+
it('returns empty exceptionApprovers by default', async () => {
306+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({}));
307+
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
308+
expect(config.exceptionApprovers).toEqual({ users: [], teams: [] });
309+
});
310+
311+
it('inherits $global exceptionApprovers when the repo has no exceptionApprovers block', async () => {
312+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
313+
'$global': { exceptionApprovers: { users: ['admin'], teams: ['org/security'] } },
314+
'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } },
315+
}));
316+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
317+
expect(config.exceptionApprovers).toEqual({ users: ['admin'], teams: ['org/security'] });
318+
});
319+
320+
it('repo-level exceptionApprovers replaces $global', async () => {
321+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
322+
'$global': { exceptionApprovers: { users: ['admin'], teams: ['org/security'] } },
323+
'acme/payments': { exceptionApprovers: { users: ['payments-lead'], teams: [] } },
324+
}));
325+
const config = await loadScanConfig({ owner: 'acme', repo: 'payments' });
326+
expect(config.exceptionApprovers).toEqual({ users: ['payments-lead'], teams: [] });
327+
});
328+
329+
it('repo can disable exceptionApprovals by setting empty arrays', async () => {
330+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
331+
'$global': { exceptionApprovers: { users: ['admin'], teams: ['org/security'] } },
332+
'acme/internal': { exceptionApprovers: { users: [], teams: [] } },
333+
}));
334+
const config = await loadScanConfig({ owner: 'acme', repo: 'internal' });
335+
expect(config.exceptionApprovers).toEqual({ users: [], teams: [] });
336+
});
302337
});

0 commit comments

Comments
 (0)