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.
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.
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.
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.
- Pin the action to a full commit SHA if your threat model includes compromise of this repository.
@v0moves by design, and avX.Y.Ztag cannot. The pin fixes the action and the version it resolves, and not the release asset it fetches at run time: the binary and theSHA256SUMSit 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-refto 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. Rungh attestation verifyyourself 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 throughactions/setup-pythonand builds throughhatchlingfrom PyPI, and no ref here names either. - Pin a
pre-commithook to a version tag rather than to a branch, for the same reason. A mirror'smainmoves 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 arefs/tags/v*.*.*ruleset of its own, matching the first one described above; the deletion rule overrefs/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 whatscripts/check_mirror_identity.pywatches 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/checkoutruns before it and wantscontents: read.write: 'true'rewrites the Markdown it was pointed at and pushes nothing;contents: writebelongs 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 toreport.jsonat the workspace root and removes it before the step ends, so a tracked file of that name is clobbered whichever waywriteis 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.ymlandunwrap-comment.ymlpair, 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_targetmakes, and it gives away write access to anyone who opens a pull request. unwrap-pr-body-check.ymlreads 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'sSHA256SUMS, so it is never code from the pull request. Changing any of the three is a security regression rather than a preference, andtests/test_workflow_contracts.pyasserts 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,jqandgh, and takes the report it posts fromtransform'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 onreport, 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 grantspull-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.ymlis 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 atransformjob declaring an empty permission set while theeditjob, which holds the token, runs no action and takes the body it writes fromtransform's output. Two things differ. It namespull-requests: writeon itseditjob 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 acceptspull_request_targetalone, failing the run on any other trigger. Underpull_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 forpull-requests: writethere 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.pypins 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.ymlpins 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 throughrefs/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 publishedmichen00/freeze-pr-linksaction at a pinned release. That action's SECURITY.md states the first two for its own steps, and its tests pin them. It fires onpull_request_targetclosed, gated ongithub.event.pull_request.merged == true, with aworkflow_dispatchnaming one pull request number as the only other way in, so a link is frozen only once its branch can no longer change. It holdspull-requests: writeitself 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 intests/test_workflow_contracts.pycover it; none of that file's per-workflowContractentries does.