pytestHTML Reporter
Home Docs Security & privacy
Reference

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.

CarefulA GitHub Pages site is public by default, even when the repository is private. Setting the Action's 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.

WhatWhere it showsCan 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.
CarefulOne row is opt-in and worth knowing about before you turn it on: --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.

output The Environment panel, in source order
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.

Screenshot: assets/img/shots/environment-panel-arguments.png The Environment panel of a report generated on a CI runner, 1440px wide, light theme, with a ring drawn around the 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

FileWhat 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.
NoteIf you send someone the HTML file on its own, the screenshots do not travel with it and the images break. If you send them the folder, they get the archive of previous builds as well. Decide which one you meant.

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.

python pytest_html_reporter/attachments.py
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

Which helper gets which treatment:

HelperRedacted?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.
shell The cURL part of an attached call, as it reaches the report
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
}'
Screenshot: assets/img/shots/attachment-curl-redacted.png The cURL part of an API Logs attachment, light theme, cropped to about 900px wide, showing an Authorization header and an ?api_key= query parameter both reading <redacted> while the Content-Type header and the JSON body are intact.

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.

CarefulNothing outside attachments.py redacts anything. Captured logs, failure text, the environment block and screenshots are written exactly as they were produced.

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.

Publishing a report from CI

Before you make one public

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.

FlagDefaultWhat 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.
ini A report you intend to publish
[pytest]
html_report = ./report
report_logs = failed
report_attachments = none
report_screenshots = none
report_open = none

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.

InputDefaultWhy 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:

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.

yaml examples/fork-pr-comment.yml — the privileged half
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:

output What it prints instead of commenting
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.
CarefulIf you copy the 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.

yaml On the job that runs the tests
permissions:
  contents: read        # always
  pull-requests: write  # only for `comment: 'true'`
PermissionJobNeeded for
contents: readtestAlways.
pull-requests: writetestcomment: 'true'. Since 2023 the default GITHUB_TOKEN is read-only, which is the usual reason a comment does not appear.
pages: write, id-token: writedeployDeploying to Pages — on the deploy job, not the one running the tests. An action cannot grant itself an environment.
actions: readcommentThe 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.

RouteWhere it is publishedUse 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.

Don'tAttach the real report. If a report is leaking something, sending it is sending the leak. Describe the shape — the field, the helper, the flag — or rebuild it against a fake credential.

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.