Skip to content

Security: michen00/markdown-prose-hooks

SECURITY.md

Security policy

Supported versions

Fixes go to the latest release, and what changed in each one is on the releases page, generated from the pull requests it carries. v0 is an alias that the release flow moves to the newest 0.x; every vX.Y.Z tag is frozen where it was published, by a ruleset over refs/tags/v*.*.* that blocks update and deletion for every actor with no bypass. A second ruleset over refs/tags/v* blocks deletion alone, so the alias can move but cannot disappear.

Release tags are annotated. Signing is a step in the release procedure rather than a rule anything enforces: commit.gpgsign is on in this repository and tag.gpgsign is not, so -s is written out by hand each time and the immutability rule above means a tag that missed it cannot be signed afterwards. Check the tag you are pinning with git verify-tag vX.Y.Z rather than assuming, and expect the v0 alias never to verify — the release flow moves it from a runner, which holds no key.

Reporting a vulnerability

Use GitHub's private vulnerability reporting: report a vulnerability. Please do not open a public issue for a security report.

Report here rather than on markdown-prose-hooks-py or markdown-prose-hooks-rs. Those two repositories are generated from this tree and replaced wholesale on every release, so a fix that does not land here does not survive the next one.

This is a personal project maintained on a best-effort basis. Expect an acknowledgment within a week, and a fix as a patch release.

What the tool can reach

Both implementations read Markdown files and write Markdown files, and do nothing else. Neither has a dependency beyond its own standard library, neither opens a network connection, and neither evaluates or executes anything it reads. A document written to attack the transform therefore produces wrong text rather than an executed instruction, and git diff shows you that text before you commit it.

Verifying a release

Each release publishes a binary per supported platform and a SHA256SUMS manifest beside them, so a binary you downloaded yourself can be checked against the manifest from the same release.

Every asset the release workflow uploads — each binary and the SHA256SUMS manifest, but not the source-code archives GitHub generates alongside them — is also covered by a build provenance attestation, which is what the manifest on its own cannot give you: a checksum proves a download matches the manifest beside it, and both come from the same release, so a release rewritten wholesale would still agree with itself. The attestation is signed through Sigstore by an identity minted for the workflow run that built the assets, and names the repository, the workflow, the ref and the run. Check one with:

gh attestation verify unwrap-markdown-prose-rs-<target> \
  --repo michen00/markdown-prose-hooks \
  --signer-workflow michen00/markdown-prose-hooks/.github/workflows/release.yml \
  --source-ref refs/tags/vX.Y.Z

<target> is the platform suffix exactly as the release lists it, and the Windows asset is the one that carries a .exe on the end where the others carry no extension. Both constraints below it are load-bearing and neither substitutes for the other. Without --signer-workflow the command accepts any attestation this repository could produce; with it, only release.yml will do. --source-ref is the one that makes the check specific to the release you downloaded from, and vX.Y.Z is a placeholder you must replace with that tag: every release is built by the same workflow, so the signer constraint alone is satisfied by a legitimate attestation from any tag. An asset swapped for a different release's genuinely attested binary would pass without it, and fail with it. Releases carry this from the first tag after release.yml gained the step; v0.5.0 and everything before it do not, and cannot gain one afterwards, because the attestation is minted from the run that built the assets and no path adds one later.

The action does the manifest check, and not the attestation one, on every run that downloads a binary. It downloads the manifest first, declines the binary when the manifest does not list an asset for the runner's platform, and treats a digest that disagrees as fatal. A mismatch is never a fallback: it stops the run. There is no path through the action that executes a binary it could not verify. Two paths download no binary at all and so verify none: implementation: 'python', and the fallback an uncovered platform takes. Both install the package from the action's own checked-out tree, and what vouches for that tree is the ref you pinned the action to.

Neither registry is published with a stored credential. PyPI and crates.io both authorize a release through trusted publishing, so the identity is the workflow's rather than a token held in this repository.

Consumer guidance

  • Pin the action to a full commit SHA if your threat model includes compromise of this repository. @v0 moves by design, and a vX.Y.Z tag cannot. The pin fixes the action and the version it resolves, and not the release asset it fetches at run time: the binary and the SHA256SUMS it is checked against come from the same release, and a release asset is not a ref, so neither tag ruleset reaches it. The digest check proves the download matches the manifest from that release rather than the bytes any particular release once held. What tells those two apart is the provenance attestation under Verifying a release, which binds a digest to the run that produced it, and which a rewritten release cannot forge — though it can borrow: another release's genuinely attested asset dropped in here carries genuine provenance, for the wrong tag. Pinning --source-ref to the tag you downloaded from is what separates those, and is why the command above carries it. The action does not check it: verifying provenance needs a network call and a tool a runner is not guaranteed to carry, and a fallible check in the path that runs the binary would be worse than none. Run gh attestation verify yourself if that distinction is one your threat model cares about. implementation: 'python' is the path that fetches no release asset at all, since it installs the package from the tree your pin already fixes. The pin stops at that tree, though: the install still provisions an interpreter through actions/setup-python and builds through hatchling from PyPI, and no ref here names either.
  • Pin a pre-commit hook to a version tag rather than to a branch, for the same reason. A mirror's main moves on every release, and while the tree it carries is replaced wholesale, the history is appended to rather than rewritten and there is no force path, so for as long as that mirror repository exists a commit SHA stays resolvable and is a durable pin as well. Each mirror's version tags are frozen by a refs/tags/v*.*.* ruleset of its own, matching the first one described above; the deletion rule over refs/tags/v* is this repository's alone, because a mirror carries no moving alias to protect. Both guarantees stop at the repository's own lifetime: deleting a mirror takes its rulesets with it, and a new repository under the same name is free to serve those tag names against a different tree, which is what scripts/check_mirror_identity.py watches the numeric repository id for. A commit SHA cannot be made to name a different tree that way, since the name is derived from the content; it can only stop resolving.
  • The action itself uses no token, so nothing it does is a reason to widen a job's permissions:. Minimal is not empty, though: actions/checkout runs before it and wants contents: read. write: 'true' rewrites the Markdown it was pointed at and pushes nothing; contents: write belongs to the step you add afterwards to commit and push those rewrites, not to the action. It is not the only thing the action puts in your workspace, though: a run that inspects any file writes its JSON report to report.json at the workspace root and removes it before the step ends, so a tracked file of that name is clobbered whichever way write is set. Its default reporting path annotates and writes a job summary, which needs no token permissions at all, and that is what lets it behave identically on a pull request from a fork.
  • If you adopt the unwrap-propose.yml and unwrap-comment.yml pair, two properties are what make it safe to hand a writable token to a workflow that reacts to a fork's pull request, and changing either is a security regression rather than a preference. The commenting half checks out nothing from the pull request and runs nothing from it. It also refuses an artifact whose claimed pull request number is not the one the producing run's own head repository, branch and commit belong to.
  • In particular, do not "fix" the commenting half by checking out the pull request head. That is the mistake pull_request_target makes, and it gives away write access to anyone who opens a pull request.
  • unwrap-pr-body-check.yml reads a pull request body and never its files, and three properties are what make that safe to run against a pull request nobody has reviewed. Nothing from the pull request is checked out and nothing from it is executed, so the body is data from end to end. The body reaches the transform through a file written from an environment variable, and never through a workflow expression interpolated into a shell command — an expression is substituted before the shell parses the line, so a body carrying shell syntax would otherwise run. The transform is the published action at a pinned release, which checks the binary it downloads against that release's SHA256SUMS, so it is never code from the pull request. Changing any of the three is a security regression rather than a preference, and tests/test_workflow_contracts.py asserts the first and the second. A fourth property keeps the token away from the transform. The steps of one job share a runner, so an action run beside a token can read it, or change what a later step runs; the action therefore runs in a job of its own, transform, which declares an empty permission set, and the job that comments, report, runs no action, only the shell, jq and gh, and takes the report it posts from transform's outputs through the environment. Changing that is a security regression too, and the same contract test asserts it. Separately from these, the workflow declares no permissions at workflow scope or on report, so the commenting job runs with exactly what the calling job granted and nothing beyond it — a caller wanting the check alone grants nothing at all, and one wanting the comment grants pull-requests: write. Keeping that grant to the mode in use is the caller's to do rather than this file's to assert, and the same contract test pins the absence at both levels, so a scope cannot appear here without the contract moving with it.
  • unwrap-pr-body.yml is the editing half, and the same four properties hold: it checks out nothing and executes nothing from the pull request, the body reaches the transform through a file written from an environment variable, the transform is the published action at a pinned release, and the action runs in a transform job declaring an empty permission set while the edit job, which holds the token, runs no action and takes the body it writes from transform's output. Two things differ. It names pull-requests: write on its edit job instead of passing the caller's grant through, which makes that grant a precondition: a caller granting less fails the run before any job starts. And it accepts pull_request_target alone, failing the run on any other trigger. Under pull_request, the workflow file is read from the pull request's own merge commit, and that file is what grants the token, so a caller asking for pull-requests: write there gives write access to everyone who can push a branch, without an approving review, and the fork approval settings do not reach a branch in the same repository. An edit reads the body from the payload and needs no checkout of the head, so the safer trigger requires nothing extra. tests/test_workflow_contracts.py pins the permissions, the absent checkout and the split between the two jobs; the trigger is checked by the job itself, where no test here can see it.
  • freeze-pr-links.yml pins a merged pull request's head-branch links to its head commit, so a link a reviewer could open while the branch existed does not 404 once the branch, or the fork holding it, is deleted; merging leaves that commit reachable here through refs/pull/<n>/head. The same three properties as the two halves above hold: nothing from the pull request is checked out or executed, the body and every comment it touches reach the transform through a file rather than a workflow expression interpolated into a shell command, and the transform is the published michen00/freeze-pr-links action at a pinned release. That action's SECURITY.md states the first two for its own steps, and its tests pin them. It fires on pull_request_target closed, gated on github.event.pull_request.merged == true, with a workflow_dispatch naming one pull request number as the only other way in, so a link is frozen only once its branch can no longer change. It holds pull-requests: write itself rather than passing a caller's grant through, because it is a repository automation with no caller, and the paths a link is checked against come from the git tree at that head commit, read from this repository rather than from the fork. Every write is a compare-and-swap: the text is re-read and byte-compared against the copy the rewrite started from, and a mismatch skips the write rather than forcing it. The repository-wide checks in tests/test_workflow_contracts.py cover it; none of that file's per-workflow Contract entries does.

There aren't any published security advisories