Security & privacy
A report is a static file people put on GitHub Pages, attach to tickets and paste into chat. Before you do that on a private codebase, here is what is actually inside it, which credentials are blanked before they are written, and the handful of ways a secret still gets in.
What you are publishing
The generated report is one self-contained HTML file. It makes no outbound request at generation time and none at read time — every library and icon is inlined into the page, which is why it opens on an air-gapped machine — and there is no telemetry of any kind. Nothing about your run is sent anywhere. A generated report is inert HTML.
That is the reassuring half. The other half is that the file is a verbatim record of a pytest run on your machine: the command you typed, the path you ran it in, the host that ran it, and everything your tests printed while they ran. None of that is redacted, because none of it can be — a report that dropped the failure text would not be a report.
And it is not one file on disk. A run leaves output.json beside the report, an
archive/ folder of every retained previous build, and a
pytest_screenshots/ folder of PNGs. Publishing the folder publishes all of
them.
pages-artifact input hands actions/upload-pages-artifact the whole report
directory — so output.json, every archived build and every screenshot go up with the
page.What a report contains by default
No flag has to be set for any of this. It is what a bare pytest run produces.
| What | Where it shows | Can it carry a secret? |
|---|---|---|
| Host, platform, Python and pytest versions | Environment panel | Not directly. The host name of a laptop is often somebody's name. |
| Every installed plugin and its version | Environment panel | No — but it is a complete inventory of your test toolchain. |
| The CI system, the pipeline URL, the branch and the commit | Environment panel | Not directly — but from 0.4.2 a published report names the pipeline it came from and the revision under test, without anybody having asked. A private repository's branch names and commit shas are in a report published to Pages. |
The interpreter path, and the worker count under -n |
Environment panel | The path names a virtualenv, and on a laptop usually a home directory. |
| The full pytest command line | Environment panel, Arguments |
Yes. Whatever you passed is printed as you passed it. |
| The rootdir | Environment panel, Root |
Yes. An absolute path names your org and repo, or your username. |
--build-info values |
Environment panel | Yes. Printed verbatim, exactly as given. |
| The profile the run used | Environment panel, Profile |
Its name. The settings behind it are not listed anywhere in the page — they reach it only where that setting was always visible, such as a build_info row or the environment badge, whether it came from a flag, a profile or a PYTEST_HTML_REPORTER_* variable. |
| Captured stdout, stderr and logging | Logs column |
Yes. Nothing filters what a test prints. |
| Failure text and tracebacks | Error Message column, output.json |
Yes. This is pytest's own representation, kept whole. |
| Screenshots of a failing browser test | Screenshots gallery, Screens column |
Yes. Whatever the page was showing. |
| Attachments and API logs | API Logs tab |
Partly redacted — see below. |
| Test names, docstrings, markers, parameters | Test Metrics |
These are your source, reproduced. |
--report-packages
adds a Packages row holding every installed distribution and its version. That is a complete
dependency inventory of the machine, published into a file that gets attached to tickets and passed
around. It is off by default for exactly that reason.The environment block
environment_entries() in util.py writes a fixed set of rows in a fixed
order: what ran, then what it ran on, then what it was told to do. Several of them are worth reading
twice.
Environment staging # only when --environment is set
Profile ci # only when a named profile was selected
team payments # one row per --build-info entry
CI GitHub Actions #482 # detected, unless build_info said ci=
Pipeline https://github.com/acme/payments-api/actions/runs/1234/attempts/2
Branch release/9.2 # from the CI system, else from git
Commit 9f2c1ab
Captured output all tests: stdout, stderr and logging, logging from WARNING
Host fv-az1024-473 # platform.uname().node
Platform Ubuntu 22.04.4 LTS · Linux 6.8.0-1017-azure (x86_64)
Python 3.12.7 (CPython, 64-bit)
Interpreter /home/runner/work/payments-api/.venv/bin/python
pytest 8.3.4
Plugins cov-6.0.0, html-reporter-0.4.3, xdist-3.6.1
Workers 8 # only under -n
Arguments tests/ --html-report=report/index.html -q
Root /home/runner/work/payments-api/payments-api
Packages (214) attrs==24.2.0, boto3==1.35.0, ... # only with --report-packages
Generated Sep 05 2026, 11:04:18
Arguments is config.invocation_params.args — the command line
pytest was started with, minus the executable, joined with spaces. A credential passed as a
pytest argument is in the report. Root is config.rootpath, an
absolute path: on a CI runner it usually spells out the org and the repository, and on a laptop
it spells out the user's home directory.
Pipeline, Branch and Commit are detected
rather than configured, so a report that used to say nothing about where it came from now names the
build and the revision. Nothing is fetched to do it — the values come from the CI system's own
environment variables, and from git when it is not a CI run. If that is more than you want
published, name the rows yourself through --build-info: a branch,
commit, ci or pipeline you have given wins, and the detected value
is dropped rather than shown beside it. Interpreter is sys.executable,
which on a laptop spells out a home directory the same way Root does.
Plugins comes from pluginmanager.list_plugin_distinfo(), with the
pytest- prefix stripped and the version appended. It is not a secret, but it is a
precise statement of which versions of which packages you are running, which is more than some
organisations publish deliberately.
Arguments row and another around the Root row. The point of the picture is that the exact command line and an absolute workspace path are in every published report.
Captured output and failures
Everything pytest captures while a test runs — stdout, stderr and
logging, from setup, call and teardown alike — is kept against that test and opened
from the Logs column. This is on by default; no flag turns it on. If a test logs a
connection string at WARNING, the connection string is in the report. If your run
lowers --log-level to DEBUG, everything a library logs at DEBUG is in
there too.
The failure text is report.longreprtext — pytest's own representation of the
failure, kept whole rather than summarised. That is the traceback with its source lines; if the
run was started with --showlocals, it is the traceback with local variable values as
well. It is written into the Error Message column and into
output.json, from where it is carried into archive/ and survives in
every later report until retention removes it.
Screenshots
A test that fails while holding a Selenium driver or a Playwright page is photographed automatically, with no hook and no fixture. That is the feature, and it is also the least controllable disclosure in the product: the picture is of whatever the browser was showing at the moment of failure — a logged-in session, a customer record, a staging dataset, an access token sitting in a form field. Nothing inspects an image, so nothing can redact one.
The files beside the report
| File | What it holds |
|---|---|
output.json |
The build record: per test, its status, test_name, duration, rerun count and message — where message is the full failure text. Plus suite names, the start time, and a coverage block when the run measured coverage. |
archive/*.json |
One of the above per retained previous build. Every failure message from every build you have kept. |
pytest_screenshots/*.png |
The images. They are files on disk, not data URIs — the report references them by relative path. |
Automatic redaction
A report is a build artifact, so the attachment helpers blank credentials before anything is
written. All of the redaction in the package lives in one file,
pytest_html_reporter/attachments.py, and it replaces a matched value with the
literal string <redacted>.
The rule: SECRET_HINTS
A header or field name is treated as a credential when any of these appears anywhere in its lower-cased form. This is the list verbatim.
REDACTED = "<redacted>"
SECRET_HINTS = (
"authorization", "cookie", "token", "secret", "password", "passwd",
"api-key", "apikey", "api_key", "x-auth", "credential", "private-key",
)
def is_secret(name):
"""True when a header or field of this name carries a credential."""
return any(hint in str(name).lower() for hint in SECRET_HINTS)
Matching as a substring is what makes the short list sufficient:
X-Api-Key, Proxy-Authorization, Set-Cookie and
refresh_token are all covered without anybody listing every spelling that has ever
been used. The comment in the source says exactly that.
Where it is applied
-
Headers, request and response alike
redact_headers()replaces the value of any header whose name matches. The name itself is kept, so you can still see that anAuthorizationheader was sent. -
The URL's query string
redact_url()splits on?and blanks the value of anykey=valuepair whose key matches. Without this, an?api_key=would reach the report three times over — in the entry's title, in the URL on the meta strip, and in the curl line. A bare flag with no=is left alone. -
Decoded JSON bodies, at any depth
redact_data()walks nested dicts and lists. A bearer token is at least as likely to be in the body of a login response as in a header, and that response is exactly the one someone attaches while working out why the login failed. -
HAR-shaped header entries
{"name": "Authorization", "value": "Bearer …"}is how a HAR, and most API specs, write a header. Keying off the dict's own keys would look atnameandvalueand find nothing, so the pair is recognised and thevalueblanked. -
The generated curl line
curl_command()is built from the already-redacted headers, the already-redacted URL and the already-redacted body, so the line you copy out of the report cannot carry a credential the entry above it hid.
Which helper gets which treatment:
| Helper | Redacted? | Detail |
|---|---|---|
attach_api() |
yes | Headers, URL query, both bodies and the curl line. redact=False turns it off. |
attach_json() |
yes | Always. There is no way to turn it off — the payload goes through redact_data() whatever you pass. |
attach_file() |
only JSON | A file that resolves to the json format — a .json, a .har, or anything with format="json" — is redacted and pretty-printed. Everything else is kept verbatim. |
attach_text() |
no | Free text has nothing to key a redaction off. What you pass is what is stored. |
attach() |
no | It is an image. |
curl -X POST 'https://api.example.com/v2/orders?api_key=<redacted>' \
-H 'Authorization: <redacted>' \
-H 'Content-Type: application/json' \
--data '{
"sku": "A-1",
"qty": 2
}'
The bundled demo, tests/functional/test_attachments.py, carries a fake bearer
token, an ?api_key= and a password in a payload precisely so you can
run it and see that none of them arrive. It needs no browser and no network.
What redaction does not cover
This is the important part of the page. Redaction is a name-matching pass over the arguments of three helper functions. It is not a scan of the report, and it has no idea what your secrets look like. Every one of the following reaches the file untouched.
attachments.py redacts anything. Captured logs, failure text, the environment block
and screenshots are written exactly as they were produced.-
A secret printed to stdout or handed to a loggerThe
Logscolumn is verbatim capture.print(token)orlog.debug("auth=%s", header)lands in the report as written. -
A token in a tracebackThe failure text is pytest's
longreprtext, kept whole — including the source line of the failing assertion, and, under--showlocals, the values of the locals around it. It goes into the report and intooutput.jsonand its archives. -
A token baked into a URL path
redact_url()only looks after the?. A call to/v1/sessions/eyJhbGciOi…keeps its path — and because an untitled entry is namedMETHOD /path, the token becomes the entry's own title in the rail. -
A body that is not JSON
prepare_body()redacts a dict or list, and text that really parses as JSON. A form-encodedpassword=hunter2, an XML envelope or a SOAP body is stored as it stands, because there is nothing structured to key a redaction off. -
A file that is not JSON
attach_file(".env"), a.yamlconfig or a.sqldump is attached verbatim. Mangling a config file would be worse than not trying, so the code does not try. - A credential visible in a screenshotAn image is bytes. Nothing reads it, so nothing can blank anything in it.
-
A header or field whose name gives nothing awayMatching is on the name only; the value is never inspected.
X-Session,account_pinand a company-specific header name are not on the list and are not detected. -
Anything on the command line, or in
--build-infoBoth are printed as given.--build-info sha=$GITHUB_SHAis fine;--build-info token=$DEPLOY_KEYis not.
The character limits are not a safety net either. --report-log-limit and
--report-attachment-limit cut a payload down — the log keeps its tail, the
attachment keeps its head — but what survives is as unredacted as what was dropped.
What the report never does
Four pieces of defensive work you inherit without asking for them.
- It never reaches the networkThe ten JavaScript and CSS libraries and the Font Awesome icon set are vendored into the wheel and inlined at generation time. The page loads nothing from a CDN, so it works offline — and so it cannot phone anywhere. See Compatibility & files for the full licence inventory.
-
Test-written text cannot become markupEverything from the run goes through
escape_report_text()on its way into the page. It HTML-escapes, and it also breaks up%(— the page is assembled by substituting%(name)%placeholders, so a log line that happened to look like one would otherwise be filled in instead of shown. -
A custom nav link cannot run anything
--report-linkputs a URL from the command line into the page, sosafe_link()drops any scheme it does not know —javascript:anddata:among them. Relative paths are left alone, because linking./htmlcov/index.htmlbeside the report is the whole point of the option. -
The VS Code sidebar is locked downThe markup the extension renders itself carries
default-src 'none', allowing inline style and script — both generated in the same string — and nothing else at all, so no report content can pull in a remote resource. The full-report panel deliberately injects no policy, because the report is a self-contained document carrying its own inline scripts and a policy tight enough to be worth adding would break the tabs the panel exists to show; itslocalResourceRootsis limited to the report's own directory.
Publishing a report from CI
Before you make one public
- Open the Environment panel and read the Arguments rowIt is the exact command line. If a secret is in it, it is in the report, and it is in every archived build too.
- Open one failing test's Logs columnThat is a fair sample of what your suite prints. If you would not paste it into a public issue, do not publish it.
- Open the Screenshots galleryLook at every picture, not the first one. This is the item people skip.
-
Check what else is in the folder
output.jsonandarchive/*.jsonhold the failure text of every build you have kept, and they are published with the page. -
Grep the file onceIt is one HTML document.
grep -io 'bearer [a-z0-9._-]*' report/index.htmltakes a second and answers the question directly.
The flags that reduce exposure
Each of these has an ini key of the same name with underscores,
so you can set them once in pytest.ini rather than on every command line.
| Flag | Default | What setting it costs you |
|---|---|---|
--report-logs=none |
all |
No captured stdout, stderr or logging at all. failed is the middle setting: only the failures keep their output, which is usually the output you need. |
--report-attachments=none |
all |
No API logs, JSON, text or file attachments. failed keeps only the failures'. |
--report-screenshots=none |
failed |
Stops the automatic photograph of a live driver or page. Images a test hands to attach() are always kept, so an explicit call still gets through. |
--report-steps=none |
all |
No step trees, and none of the step names your tests chose. |
--archive-count 0 |
no limit | Deletes the archive. Every build published thereafter carries only its own failures — and Trends, Archives and Analytics have nothing to show. |
[pytest]
html_report = ./report
report_logs = failed
report_attachments = none
report_screenshots = none
report_open = none
- uses: prashanth-sams/pytest-html-reporter-action@v1
with:
tests: tests/
report-logs: failed
report-attachments: none
pytest-args: --report-screenshots=none
build-info: |
commit=${{ github.sha }}
The argument for keeping it behind authentication
Turning things off works, and it costs you the report. report_logs = none removes
the column you open when a test fails at three in the morning; report_screenshots =
none removes the picture that explains the failure without a re-run. Every flag in the
table above trades away part of the reason the report exists.
Restricting who can read the file costs you nothing. The Action is built that way round: the
default is upload-artifact, which puts the report where people who can read the
repository can download it, and pages-artifact defaults to 'false'
— you have to ask for the public copy.
| Input | Default | Why it matters here |
|---|---|---|
upload-artifact |
'true' |
The private route. Readable by whoever can read the repository, not by the world. |
pages-artifact |
'false' |
The public route. Uploads the whole report directory for a following job to deploy. |
artifact-include-history |
'true' |
Set it to 'false' to leave archive/ out and upload only the current build. |
artifact-retention-days |
repository default | How long the copy exists. A report nobody will read after a fortnight need not survive one. |
If you do publish to Pages, publish deliberately: a report built with
report-logs: failed, no screenshots and a build-info carrying only
things that are already public. That is what the project's own
demo report is.
The Action specifically
Nothing is interpolated into a shell
Every input reaches bash as an environment variable, never as ${{ ... }} spliced
into a run: body — which would make pytest-args shell source.
tests/test_action_yml.py fails the build if anyone writes one, so the rule is
enforced rather than remembered.
Output gets the same treatment, because on a fork's pull request the test names and failure messages are somebody else's code:
- pytest's entire output is wrapped in a
::stop-commands::block whose guard is sixteen random bytes of hex, so nothing the tests print is read as a workflow command — not an::add-mask::, and not a::stop-commands::of its own. - A name reaching the job summary or a comment is HTML-escaped (it sits inside a
<details>block, and a test called</details>would close it and take the rest of the summary with it), has its[and]escaped so[Security notice](http://evil)cannot become a link posted under the repository's own identity, and — inside a table — its|escaped, because GFM ends a cell at an unescaped one however deeply it is nested. - A line opening with
::is broken with a zero-width space rather than trusted not to appear. - The
$GITHUB_OUTPUTheredoc delimiter isphr_<name>_<8 random bytes>, so no payload can close the block early.
Why a fork pull request gets different handling
A pull_request run from a fork is handed a read-only token on purpose: the code
being tested is not yours. So the Action cannot post a comment from one. It warns and carries on
rather than failing the run — a report is not worth turning a build red over.
The safe pattern is a split, and the repository ships both halves.
examples/fork-pr.yml runs the tests with contents: read and
comment: 'false', then writes the PR number and the summary into a small
artifact. examples/fork-pr-comment.yml is triggered by
workflow_run, runs from your default branch with a writable token, and never checks
out the fork's code.
on:
workflow_run:
workflows: [tests]
types: [completed]
permissions:
contents: read
actions: read
pull-requests: write
The check that makes the split safe is the one people leave out. The artifact came out of a job that ran the fork's tests, so the pull request number inside it is a claim, not a fact. The privileged workflow fetches that pull request and refuses unless its head commit is the commit the run actually tested:
pull request #412 is at 3ab91f0, but this run tested 9f2c1ab.
Refusing to comment on a pull request the artifact only claims to belong to.
workflow_run pattern from somewhere else, copy that check with it. Without it, a
fork can name any pull request in your repository and have your workflow comment on it under the
repository's own identity.The permissions the workflows request
The Action needs nothing beyond the default for the basic case. Add what the features you turn on need, and nothing more.
permissions:
contents: read # always
pull-requests: write # only for `comment: 'true'`
| Permission | Job | Needed for |
|---|---|---|
contents: read | test | Always. |
pull-requests: write | test | comment: 'true'. Since 2023 the default GITHUB_TOKEN is read-only, which is the usual reason a comment does not appear. |
pages: write, id-token: write | deploy | Deploying to Pages — on the deploy job, not the one running the tests. An action cannot grant itself an environment. |
actions: read | comment | The workflow_run half, to download the artifact from the run that produced it. |
The github-token input exists so you can hand it a different token. Its own
description states the requirement plainly: it needs pull-requests: write, and a
pull_request run from a fork gets a read-only token and cannot comment.
Reporting a security problem
None of the three repositories ships a SECURITY.md, and no private disclosure
route is documented in any of them. What the repositories do publish is a single issue tracker
and a maintainer address.
| Route | Where it is published | Use it for |
|---|---|---|
prashanth-sams/pytest-html-reporter/issues |
setup.py project_urls; the extension's package.json points its bugs URL there too |
Anything safe to describe in public — a redaction gap, a missing escape, an over-broad permission in an example. |
sams.prashanth@gmail.com |
setup.py's author_email and maintainer_email, published with the package on PyPI |
Anything you should not describe in public before it is fixed. |
Whichever you use, include the version of the plugin, Action or extension, the pytest command
or workflow YAML that produced the problem, and the smallest reproduction you can manage. The
plugin's version is in pytest_html_reporter/__init__.py and is what
pytest-html-reporter --version prints.
The dividing line between the projects is the same one the Action's troubleshooting doc draws: a problem in getting the report built — inputs, permissions, caching, the comment, the gate — belongs on the Action; a problem in the report's contents belongs on the plugin. Everything from the VS Code extension goes to the plugin's tracker as well.