Threat model¶
How touchmark's delivery can be attacked, and for each attack the code that stops it, the tests that show it, and the risk that remains. Nine threats come first, T1 to T9, each named after what the attacker does; then the controls in depth: the isolation of the write key, tokens and git, untrusted input, and the rest of touchmark's security rules. Every row has a test or a recorded residual risk. Security model explains the design these tables check.
TestThreatModelCitesTests (internal/cli) keeps the page honest: every test it names in backticks must exist, and every threat must have its row.
The boundary¶
A merge into the hub is code that runs in the targets' CI: a sync pull request comes from a branch of the target repository itself, so the target's workflows run on it with the target's secrets before anyone reviews it. Two things hold the line: the review of the hub, and the isolation of the write account. Everything else (sensitive paths, warnings, guards) helps the reviewer or catches a misconfiguration; it is not a boundary.
The accounts (Security model): a reader for plan in hub pull requests, a writer for distribute and doctor on the default branch only, and the hub channel, the CI's own token, which reaches the hub repository alone.
How to read the tests column:
- plain names run in
go test ./...on every OS; - (git 2.45): the test needs a git that
plananddistributesupport and skips on an older one; the Docker run of the suite (golang:1.27, git 2.47, e2e.md) runs it; - (e2e Gitea), (e2e GitLab): the live harnesses
scripts/e2e/gitea.shandscripts/e2e/gitlab.sh(build tage2e); - (GitHub fake):
ghfake, an HTTP fake of GitHub; no sandbox organization runs live checks; the bedrock-python hub runs touchmark on github.com (e2e.md).
Threats¶
| Threat | Controls | Tests | Residual risk |
|---|---|---|---|
| T1. Someone who can push a branch to the hub runs its workflows | The write key reaches the default branch only: on GitHub the environment touchmark-distribute with a deployment policy for the default branch alone, which distribute reads through the hub channel and refuses otherwise, failing closed (distribute.CheckEnvironment); on GitLab a protected variable scoped to that environment, and distribute needs CI_COMMIT_REF_PROTECTED=true and CI_ENVIRONMENT_NAME=touchmark-distribute; Gitea and Forgejo cannot isolate it, so platform is refused there. The isolation probe (below). Guards (distribute.DistributeGuard): the default branch only, never a pull_request* or merge_request_event run, a dry run included; operation flags in CI exit 2; plan in CI exits 2 when it sees any write key or signing key (cli.writeGuard); a hub HEAD that is not the tip of the default branch writes nothing. An untrusted run (a hub pull request, another branch, a tag) takes providers and ca_file from the default branch, plans a new or moved provider without credentials, and unites the sensitive paths of both. plan holds no write credential. One-off operations come only from a reviewed .touchmark/operations.yml. |
TestDistributeGuard, TestDistributeRefusals, TestDistributeLocalRefusals, TestPlanRefusals, TestCheckEnvironment, TestDistributeEnvironment (git 2.45), TestProbe, TestProbeCommand, TestCheckProbe, TestCheckProbeProviders, TestUntrustedRun, TestPlanHubPullRequestProviders, TestPlanDefaultBranchProviders, TestPlanSensitivePathsUnion (git 2.45), TestPlanReachesNoWriter, TestMigrateReachesNoWriter, TestHeadGuard, TestPlanSuperseded, TestDistributeScheduleChecksHead (git 2.45), TestDistributeChannelFails (git 2.45), TestDistributeIsolationNone (git 2.45), TestCI (e2e GitLab: the probe sees an unprotected scoped variable) |
What the reader reads: on GitHub code and pull requests; on GitLab (Reporter) code, merge requests, issues, CI logs and artifacts (not checked one by one), but not Secure Files or Terraform state; on Gitea code, pull requests and issues. A manual distribute on the default branch is the same as the schedule. A read credential that turns out to be the writer's account stops plan in CI with exit 2 and is a warning locally. The probe on GitHub is a job of the hub's own workflow: check verifies that it tests every provider's write key, not that the template's job stays without an environment. |
| T2. A harmful pack gets through a weak review | The ⚠ section of every pull request body: the built-in sensitive paths, hub.yml sensitive_paths and any file written with mode 100755, matched ignoring case, deletions included (prbody.Sensitive). Agent skills and commands are among the built-in paths (.claude/skills/**, .claude/commands/**, .agents/skills/**, .github/skills/**): a skill's allowed-tools lets Claude Code use those tools without asking, even in a folder nobody trusted, and its frontmatter can register hooks. plan marks them SENSITIVE in "This hub pull request changes"; in a hub pull request the patterns are those of the default branch and of the pull request together, so a pull request cannot drop a pattern and change a file under it unmarked. A new .gitea/workflows or .forgejo/workflows on a Gitea or Forgejo target that has .github/workflows is called out in the body and on the target's line of plan: it turns those workflows off. No automerge. The hub template puts CODEOWNERS on packs/. |
TestSensitive, TestRenderSensitivePending, TestCheckCodeowners, TestPlanPaths (git 2.45), TestPlanSensitivePathsUnion (git 2.45), TestRowsAndGiteaWorkflows, TestPlanWarnsGiteaWorkflows (git 2.45), TestNoMergeRequests |
The sensitive list is a hint, not a boundary: a target's CI runs its Makefile, conftest.py and the rest too. The sync pull request runs in the target's CI with its secrets before review. Targets should put CODEOWNERS on the sensitive paths. The hub's own CODEOWNERS protect the packs only once they name a real team: check warns while one still names the template's @acme/hub-maintainers, which the platforms take for no owner. |
| T3. The write key leaks | GitHub: the App's installation token is minted per target, for minutes, with only the permissions the target's work needs (Workflows only when a push touches .github/workflows), and revoked when the target is done (DELETE /installation/token). doctor fails or warns on App permissions beyond contents, pull requests, workflows and metadata. The key never reaches argv, a URL or .git/config (below). |
TestTargetToken, TestTargetTokenRenewal, TestTargetApp, TestMintWider, TestMintRefused, TestCloseRevokes, TestNeedPerms, TestExecuteRevokesTargetWriters, TestAppPermissions, TestCheck, TestTokenLifetime (GitHub fake), TestCanaries |
Until the key is revoked: pull requests into every target, a push of any branch and a CI run in every target with its unprotected secrets. The App's private key and the GitLab and Gitea tokens live long; only GitHub's per-target tokens are short. doctor does not check the scopes of every kind of token yet. On GitHub the bedrock-python hub runs the live path; what only a live GitHub confirms is listed in e2e.md. A GitLab writer that reaches the hub (security.writer_on_hub: guard) cannot change the packs only while the guard holds (below). |
| T4. A touchmark release is compromised | The hub pins the Action by commit (Dependabot) and, on GitLab, Gitea and Forgejo, the image by digest (Renovate), and takes updates through reviewed pull requests. A release is published by publish.yml on master only, for a tag vX.Y.Z whose commit is on master and carries the version in .release-please-manifest.json and action.yml. The workflow checks that with master's copy of scripts/release/check-version.sh, never the tag's, and every later job checks out that commit by its id and stops on anything else. The archives and their SBOMs are listed in checksums.txt, which cosign signs, and attested; the image is built from the release's own Linux archives once that signature checks out (scripts/release/build-image.sh), pushed by digest, checked (scripts/release/check-image.sh), attested (the attestation in GHCR too) and signed before any image tag names it. Only the jobs that sign and attest can request an OIDC token; the vulnerability scanner runs in a job of its own with read access alone. binfmt, BuildKit and BuildKit's SBOM scanner are pinned by digest, and no Actions cache is used. The Action at vX.Y.Z carries the version, not a digest (scripts/action/run.sh): it resolves ghcr.io/bedrock-python/touchmark:X.Y.Z to a digest once, verifies that digest's build provenance (gh attestation verify --bundle-from-oci: signer publish.yml of this repository, source ref refs/heads/master, GitHub-hosted runner), checks the image's version label, and runs it by that digest; GH_TOKEN never reaches the container. The image holds touchmark and git only, without git-lfs, as a non-root user (Dockerfile, scripts/release/check-image.sh). A new version's first run is the plan of its update pull request, which holds no write credential. Dependabot's pull request gets no Actions secrets, so that plan runs offline: it reaches no provider, reports the hub's side and warns, and exits 0; the template passes --strict only to pull requests that get secrets. Renovate's merge request on GitLab runs plan with the reader's token: GHCR gives no release dates, so the template's renovate.json asks for approval on the Dependency Dashboard before it opens one. |
TestPlanReachesNoWriter, TestMigrateReachesNoWriter, TestPlanOffline, TestTemplateWorkflow (GitHub fake: Dependabot's pull request; the Action's step resolves, verifies and runs the release's digest); the release and Action checks are scripts, not Go tests: scripts/action/run_test.sh (a version tag that resolves to nothing, an attestation that fails, an older release's image under the tag, version 0.0.0, GH_TOKEN kept out of the container), scripts/release/tools_test.sh (a tag off master, a manifest or action.yml of another version, the tag's commit checked from a checkout of master, an archive that does not match checksums.txt), the CI job release-snapshot (the Action against a registry on the runner) and the verify job of publish.yml (the published release, the Action of the tag run anonymously) |
A hub that merges updates without reading them. The offline plan checks no target: the maintainer runs plan with the reader's key before merging an engine update. The Action trusts a version tag in GHCR only as far as the attestation goes: whoever can move tags of the package (this repository's workflows with packages: write, the organization's package admins) can point X.Y.Z at another image that publish.yml built on master; the label check refuses another version's image, not a rebuild of the same version by a re-run of Publish, which builds the same commit. A tag moved to an image the workflow did not build fails the step: the hub's job stops, nothing runs. Between Release Please's tag and the end of Publish (minutes), or until a failed Publish is run again, the Action at the new tag finds no image and fails, Dependabot's pull request in that window included. A vulnerability the scan finds at publish time does not hold the image back, since the tag exists by then: the scan that blocks is CI's, on the release pull request and nightly, and a red scan at publish is fixed by a patch release. At run time the Action needs gh, docker buildx, GHCR and Sigstore's trust root: an outage of either stops the hub's jobs, closed. On GitLab, Gitea and Forgejo, verifying the image's signature and attestation before pinning its digest is up to the hub. |
| T5. A harmful target | touchmark runs git alone, without a shell (below), never checks a target out, and runs no hook, filter, LFS or submodule of a target. Every git in a target's clone is isolated: empty home and global config, GIT_CONFIG_NOSYSTEM, GIT_ATTR_NOSYSTEM, hooks in an empty directory, core.fsmonitor=false, protocol.allow=never but https (and http to a loopback host), http.followRedirects=false, transfer.fsckObjects=true, submodule.recurse=false, no credential helper, GIT_NO_LAZY_FETCH=1. Limits on what a target can feed it (below). Paths a target's .gitattributes would filter or renormalize leave the change. Only the recreate and repropose checkboxes of a body act; the rest is data, and bodies touchmark keeps are made inert. |
TestOnlyGitRuns, TestTargetIsolation, TestInitTarget, TestInitTargetDirAndHome, TestAttrsPinnedSettings, TestReadBlobNeverFetches, TestFetchRefusesMalformedObjects, TestObjectLimits, TestDecodeLimits, TestParseGzipBomb, TestMaxBody, TestRunUnsafePaths (git 2.45), TestRunUnsafeFixpoint (git 2.45), TestTicked, TestInert, TestRunKeptBodyIsInert (git 2.45), TestCI (e2e GitLab: CI_JOB_TOKEN) |
A target may forge its own memory (close, edit or reopen touchmark's pull requests): it is the target's right. GitLab: the CI of a sync branch runs as the writer, and its CI_JOB_TOKEN acts with the writer's access to other targets. The job token allowlist, on for new projects and enforced instance-wide from 18.x, keeps target X's token away from target Y through the API and git; if Y lists X, the token reads Y's code (17.11 git only; 18.11 and 19.4 also files and merge requests through the API), but push and API writes are refused (checked live on GitLab 17.11, 18.11 and 19.4). Mitigation: never list sync targets in an allowlist, and a separate writer per group of targets with another level of trust. |
T6. Another hub with the same id |
The hub's fingerprint (host and immutable repository id) is in every commit's trailers and every marker; a sync branch whose touchmark commits carry another fingerprint is Foreign and blocked:branch-taken; a pull request with another hub's marker is not ours. doctor warns about markers with our id and another fingerprint. |
TestFindDataOfAnotherHub, TestFindWithoutFingerprints, TestOwn, TestOwnWithoutFingerprints, TestClassifyBranch, TestParseTrailers, TestCanonicalFingerprint, TestDoctor (e2e Gitea: markers warn) |
Two hubs with one id share the branch name touchmark/<id>: the second one sets branch in hub.yml. |
| T7. Someone reads the hub's CI logs | The redaction registry holds every token and JWT in its raw, Basic and URL-encoded forms; drivers register what they mint during a run; on GitHub Actions every form is announced with ::add-mask:: before its first use. Errors that echo a credential are masked. A public hub, or a hub of unknown visibility in CI, skips non-public targets and only counts them, in every output. |
TestAddForms, TestWriterProperty, FuzzWriter, TestManySecrets, TestGitHubMask, TestMaskNewSecrets, TestJWT, TestInstallationTokens, TestCanaries, TestPlanMasksSecrets, TestStatusErrorMasksEchoedToken, TestErrorsMaskTheToken, TestErrorMasksToken, TestErrorsMaskSecrets, TestHTTPEchoedCredential, TestDetectNeverReturnsSecrets, TestSecretsNeverPrinted, TestPlanPrivateInPublicHub, TestDistributePublicHubNamesNoPrivateTarget, TestExecuteSweepHidden, TestHiddenSweepTarget, TestChannelVisibility, TestWriteOperations, TestVisibility (e2e Gitea) |
Pack paths and the names of public targets are in the logs. security.private_targets_in_public_hub: deliver puts the names of non-public targets there, by choice. Masking target names comes after v1. |
| T8. Someone adds commits to a sync branch | The branch is checked by content: touchmark's commit Hc must still bring exactly what its trailer says, only clean merges of the base may follow it, and anything else pauses the pull request (blocked:edited, with the recreate checkbox). Every move of a ref leases on the head touchmark read (CAS), and the target is read again before its writes. |
TestClassifyBranch, TestClassifyBranchModel, TestFindHc, TestIsCleanMerge, TestRunBranchSafety (git 2.45), TestPushLease, TestExecuteStaleLease (git 2.45), TestRunAPICommitStaleLease (git 2.45), TestCommitStale, TestExecuteRecheckMoves, TestMidRunMoves, TestCrashMatrix, TestPropertyDistribute (no commit a person pushed to a sync branch is lost), TestDistribute (e2e Gitea: a merged feature branch is blocked:edited) |
A reviewer who does not read the pull request merges whatever the branch holds. |
| T9. An outsider with a fork of a target or a project on the same instance | A pull request is touchmark's own only when its head is in the target repository, its author is the writer or one of known_authors, and its marker carries the hub's fingerprint. The sweep lists only pull requests of touchmark's authors. Someone else's open pull request on the sync branch blocks it (blocked:branch-in-use): touchmark never moves or deletes that branch. |
TestOwn, TestOwnEmptyValuesNeverMatch, TestDecideTargetOpenBranchInUse, TestSweep, TestRunSweep (git 2.45), TestOpenPRsBy, TestForkPR (GitHub fake), TestCreatePRBesideFork, TestExecuteDeletionRefused, TestPropertyDistribute (no write to anything that is not touchmark's), TestConformance (fake, GitHub fake, e2e Gitea, e2e GitLab: a fork's pull request with a copy of the marker), TestAGitPR (e2e Gitea) |
None recorded. |
Isolation of the write key¶
| Control | Where | Tests | Residual risk |
|---|---|---|---|
security.write_isolation: platform (default), external, none; none needs security.reason |
config.ParseHub; distribute adds a report warning with the reason; doctor's write-isolation check |
TestFixtures (isolation-none-no-reason, isolation-none-blank-reason), TestDistributeIsolationNone (git 2.45), TestIsolationChecks |
none is a warning, not an error: the report lists the reason among its warnings in every run, and doctor grades it warn (exit 3 with --strict). The risk is accepted on purpose, and hubs on Gitea and Forgejo cannot do without it. |
plan in CI exits 2 on TOUCHMARK_WRITE_*, TOUCHMARK_<ID>_WRITE_* or a signing key, whichever provider id the name carries |
cli.writeGuard |
TestPlanRefusals |
— |
distribute and distribute --dry-run exit 2 off the default branch or for a pull or merge request event; on GitLab without CI_COMMIT_REF_PROTECTED=true and CI_ENVIRONMENT_NAME=touchmark-distribute; in an unknown CI |
distribute.DistributeGuard |
TestDistributeGuard, TestDistributeRefusals |
— |
| Operation flags in CI exit 2 | distribute.DistributeGuard |
TestDistributeGuard, TestDistributeRefusals, TestDistributeOperationFlags |
— |
HEAD of the hub not the tip of the default branch: superseded, nothing written; a local run needs origin/HEAD at HEAD unless --allow-stale |
distribute.HeadGuard, run.superseded, cli.localHead |
TestHeadGuard, TestPlanSuperseded, TestRunTargetSuperseded (git 2.45), TestDistributeScheduleChecksHead (git 2.45), TestDistributeLocalRefusals |
— |
GitHub: TOUCHMARK_KEY_EXPOSED=false required; true or unset exit 2; the environment must let the default branch alone use it, and an unreadable environment fails closed |
distribute.guardIsolation, distribute.CheckEnvironment |
TestDistributeGuard, TestCheckEnvironment, TestDistributeEnvironment (git 2.45), TestChannelEnvironment |
A private hub on GitHub Free cannot pass (no environment secrets or branch policies there): external, none or a public hub. Reading the environment of a public hub with GITHUB_TOKEN runs in the bedrock-python hub; a private hub's is unverified. |
check requires every secret a workflow hands touchmark as a write key, with: inputs of the Action included, to be tested by every probe expression and by the job whose output TOUCHMARK_KEY_EXPOSED reads (a test in another job does not count; a constant is refused), and the write key of every provider to be handed out where it can see it |
config.CheckProbe |
TestCheckProbe, TestCheckProbeProviders, TestWriteKeySecrets |
check reads .github/workflows only, and does not follow a TOUCHMARK_KEY_EXPOSED that a reusable workflow takes from its inputs. |
GitLab: touchmark probe in merge request pipelines exits 2 when it sees a write key |
distribute.Probe, cli probe command |
TestProbe, TestProbeCommand, TestCI (e2e GitLab) |
distribute cannot see a variable's flags; the probe in every merge request pipeline is what catches it. |
Bitbucket Pipelines and Azure Pipelines: distribute and doctor need the environment touchmark-distribute (the step's deployment, a deployment job) and the default branch read by the hub channel; under platform they need security.reason stating what keeps other branches out (Premium deployment permissions; the variable group's Branch control check), which the job cannot read |
distribute.guardContextOf, distribute.guardIsolation, distribute.IsolationChecks, cli.hubRepository |
TestDistributeGuardBitbucket, TestIsolationChecksBitbucket, TestDistributeGuardAzure, TestIsolationChecksAzure, TestHubRepositoryBitbucket, TestHubRepositoryAzure, TestTemplatePipelines, TestTemplateAzurePipelines |
The statement is the hub's word: doctor --hub-token reads the Azure check with a maintainer's token, and nothing reads Bitbucket's deployment permissions. |
GitLab, security.writer_on_hub: guard: a writer that reaches the hub as a Developer cannot get content onto its default branch. doctor's hub-guard checks its role (below Maintainer), the rules that match the default branch (no push, no merge for it), that merge request pipelines read the CI file of the default branch (ci_config_path <file>@<hub>:<branch>, so a pushed CI file cannot drop the plan), and that a merge waits for a pipeline that succeeded and not a skipped one; doctor --hub-token grades the settings with a maintainer's token; setup gitlab sets them up. plan in a hub merge request pipeline exits 2 when the writer or a known author opened it or pushed to its source branch since its creation (push events, which name the pusher), and when it cannot tell; the setting, the writer and the known authors come from the default branch's hub.yml |
platform/gitlab.GuardHub, platform/gitlab.MergeRequestPushes, distribute.hubGuardCheck, distribute.gitlabGuardSettings, cli.writerMergeGuard, setup.writerGuard, setup.guardSettings |
TestGuardHub, TestMergeRequestPushes, TestDoctorHubGuard, TestKeyLocationGuardSettings, TestWriterMergeGuard, TestGitLabWriterOnHub, TestPinnedCIConfig, TestWriterOnHub (e2e GitLab) |
A maintainer who merges a merge request without reading it; settings changed between two doctor runs; push rules (EE) that show only at push time; protected tags, which GitLab shows to Maintainers only, are graded by doctor --hub-token alone. Branch protection and merge checks are the hub's to keep: the guard verifies, it does not enforce. |
Gitea and Forgejo cannot isolate the key: platform refused, doctor fails |
distribute.guardIsolation, distribute.IsolationChecks |
TestDistributeGuard, TestIsolationChecks |
A hub on Gitea or Forgejo runs with none and a reason, or its CI runs on GitHub or GitLab. |
touchmark setup offers only an isolated write key: it refuses security.write_isolation: none, Gitea and Forgejo, and CI; it refuses a GitLab hub inside the group of targets and fails when the writer reaches the hub |
cli.runSetup, setup.GitLab, setup.GitHub |
TestSetupRefusals, TestGitLabPreconditions, TestGitLabWriterReachesHub, TestGitHubPreconditions, TestGitHubFreePrivate |
— |
GitLab: setup mints the tokens straight into the hub's variables, registered with the redaction before use and never printed; the write key protected, masked, hidden and scoped to touchmark-distribute. A write key found unprotected, unmasked or in another scope: setup mints a new token (rotating its own revokes the old one), revokes the token each exposed variable held with that token itself (DELETE /personal_access_tokens/self), whoever made it, and deletes the copies; a refused revocation fails the step writer-exposed-token |
setup.gitlabSetup (mint, writerVariable, revokeExposed) |
TestGitLabGroupTokens, TestGitLabServiceAccounts, TestGitLabRepairsWriteKey, TestGitLabStoreFails, TestSetup (e2e GitLab: a token made by hand in a variable of every environment is revoked) |
GitLab hides a variable only when it creates it: a protected, masked write key made by hand before setup stays readable by Maintainers in the UI (setup warns). The value of a hidden exposed copy cannot be read: setup cannot revoke its token and asks the person to (a manual step). |
GitHub: setup's page of the App manifest flow listens on a loopback address, answers GET to its own Host only, checks a random state per App in constant time, sends no referrer and lets its form post to GitHub alone; the code is exchanged by setup, and the Apps' private keys go to files of a private directory, never to an output; the maintainer's token goes to the hub's own API host only |
setup.manifestFlow, setup.githubSetup, cli.trustedTokenProvider |
TestManifestFlowPage, TestGitHubSetup, TestShellQuote, TestSetupTokenStaysHome, TestSetupGitLabRefusedToken |
The key files stay on the maintainer's disk until gh secret set stores them and the person deletes the directory (on Windows it has the ACL of the user's temporary directory). The browser step itself is unverified: only the fake has run it. |
doctor --hub-token (local only) reads where the hub keeps its secrets and variables |
hubch.ReadKeyStore, distribute.KeyLocationChecks |
TestReadKeyStoreGitHub, TestReadKeyStoreGitLab, TestReadKeyStoreGitea, TestReadKeyStoreAzure, TestReadKeyStoreRefuses, TestKeyLocationGitHub, TestKeyLocationGitLab, TestKeyLocationGitea, TestKeyLocationAzure, TestKeyLocationUnread, TestDoctorHubTokenGitHub, TestDoctorHubTokenCAFile, TestDoctorRefusals, TestDoctor (e2e Gitea, e2e GitLab) |
The live GitHub --hub-token checks are unverified. |
Tokens and git¶
| Control | Where | Tests | Residual risk |
|---|---|---|---|
git gets a token only as http.<base-url>/.extraHeader through GIT_CONFIG_COUNT/KEY_n/VALUE_n: never in a URL, argv or .git/config |
gitx (Auth, the target's environment) |
TestHTTPExtraHeader, TestHeaderSecrets, TestCanaries, TestGitLabCommandKeepsTheTokenOutOfArgv, TestGitLabProcessArgvCanary |
— |
The HTTP client sends Authorization only to its account's host, never over a redirect, never over plain http but to loopback; the hub channel only to the hub's repository; every request goes through httpx |
httpx, hubch |
TestAuthToAllowedHost, TestAuthRefusedForOtherHost, TestAuthCaseInsensitiveHosts, TestRefusedRequestsNeverReachTheTransport, TestNoRedirectWithAuth, TestPlainHTTPToLoopback, TestRESTChannelTokenGoesOnlyToTheAPIHost, TestGitLabChannelIsolation, TestOnlyHTTPXSpeaksHTTP |
— |
In a hub pull request, providers and ca_file come from the default branch, a new or moved provider is planned without credentials, and the sensitive paths are the union of both branches' |
cli.planProviders, cli.unionSensitive |
TestPlanHubPullRequestProviders, TestPlanDefaultBranchProviders, TestUntrustedRun, TestPlanSensitivePathsUnion (git 2.45) |
Where the default branch names no writer for a provider (a new hub's first pull request, which names it), plan takes the pull request's: the writer decides no destination of a credential, only which pull requests the plan's report counts as touchmark's (TestPlanHubPullRequestProviders/added_writer) |
The redaction registry: every token and JWT in its forms, secrets drivers mint registered, ::add-mask:: on GitHub Actions, merged automata so that masking stays linear in the targets |
redact, the drivers, cli.maskNewSecrets |
TestAddForms, TestOnAdd, TestManySecrets, TestGitHubMask, TestMaskNewSecrets, TestJWT, TestInstallationTokens, FuzzWriter |
— |
A secret in a body, title, label, comment, commit message or branch name stops the target before its first write: failed:secret-exposure |
distribute (targetExec.leak); config files refuse token-like strings, and a URL with credentials (user:password@), which targets.yml refuses, has them cut from every message |
TestLeakEveryText, TestExecuteSecretExposure, TestExecuteRefusesBeforeWriting (git 2.45), TestExecuteSecretInPath (git 2.45), TestParseRejectsSecrets, TestParseOperationsSecrets, TestDisplayURL |
— |
No touchmark token, GITHUB_TOKEN or CI_JOB_TOKEN in the environment of a target's git; the environment keeps PATH, system and temporary directories, proxies and SSL_CERT_*; GIT_CONFIG_NOSYSTEM, an empty global config, a temporary HOME, GIT_ATTR_NOSYSTEM, GIT_TERMINAL_PROMPT=0, empty credential.helper and askpass |
gitx.Isolation, gitx.Git |
TestTargetIsolation, TestInitTargetDirAndHome, TestRunIgnoresInheritedEnv, TestRunInherit, TestCanaries |
— |
No git of touchmark's, in the hub or a target, inherits a variable that adds config or names a program git runs: GIT_CONFIG_COUNT with every GIT_CONFIG_KEY_* and GIT_CONFIG_VALUE_*, GIT_CONFIG_PARAMETERS, GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM; GIT_EXEC_PATH, GIT_SSH, GIT_SSH_COMMAND, GIT_ASKPASS, SSH_ASKPASS, GIT_PROXY_COMMAND, GIT_EXTERNAL_DIFF, pagers and editors; GIT_TRACE* and GIT_CURL_VERBOSE; GIT_TEMPLATE_DIR; the variables that point git at another repository, index, object store or namespace, or change pathspecs and attributes; the loader's LD_* and DYLD_*. A CI variable or a custom pipeline run with variables cannot make git run core.fsmonitor or a hook. touchmark's own isolation sets its GIT_CONFIG_* explicitly; variables that only take config away (GIT_CONFIG_NOSYSTEM, GIT_ATTR_NOSYSTEM, GIT_CEILING_DIRECTORIES) stay |
gitx.Git (droppedEnv, droppedPrefixes) |
TestRunDropsInheritedConfig, TestDroppedEnv, TestHostileGitConfigEnv, TestRunIgnoresInheritedEnv |
Whoever sets touchmark's environment also sets PATH and HOME: they choose the git that runs and, for the local commands, the user's global config. Keep variables of the job that runs distribute to the maintainers: on Bitbucket, where anyone who can run a custom pipeline can pass variables, restrict the deployment touchmark-distribute to admins or named users, not only to the branch. The hub's and a target's own .git/config apply to the local commands: they are the user's checkouts. |
A timeout or cancel kills git's whole process tree (on Windows the launcher's children too); stale shallow.lock files go before the next fetch |
gitx (configureCancel, removeStaleLocks) |
TestCancelledFetch, TestNetworkTimeout, TestStaleLocksRemoved, TestRunCancelled |
— |
git's settings in a target: hooks off, core.fsmonitor=false, protocol.allow=never but https (and http to a loopback host), http.followRedirects=false, transfer.fsckObjects=true, submodule.recurse=false, GIT_NO_LAZY_FETCH=1 |
gitx.newIsolated |
TestTargetIsolation, TestFetchRefusesMalformedObjects, TestReadBlobNeverFetches, TestInitTargetRemotes, TestCheckRemoteScope |
core.hooksPath points at an empty directory of the isolation home, not at /dev/null: the same effect on every OS. |
Commands are argv without a shell, paths through -z or after --; only git runs |
gitx |
TestOnlyGitRuns, TestQuoteStdinPath, TestHashPathsEdgeCases, FuzzParseLsTree, FuzzParsePush |
— |
| No git-lfs in the image; targets do not share an object store | Dockerfile, scripts/release/check-image.sh; one repository per target (snapshot.GitSource), released when its work is done |
TestGitSourceRelease, TestGitSourceConcurrent |
The image check is a release script, run when the image is built. |
Untrusted input¶
| Control | Where | Tests | Residual risk |
|---|---|---|---|
| Opt-in file: at most 64 KiB, a bounded number of YAML anchors and aliases, a strict schema | config (decodeStrict, ParseOptIn), the drivers' ReadFile with a size limit |
TestDecodeLimits, TestParseHubAnchors, TestParseOptIn, TestOptInIgnoreLimit, FuzzParse, TestPlanAssumeOptInTooLarge |
— |
| Marker: at most 16 KiB, at most 64 KiB inflated, strict JSON | marker |
TestParseGzipBomb, TestParseRejectsHeader, TestParseRejectsData, TestParseRejectsPayload, TestParseMaxChanges, TestFindLargeBody, FuzzParse, FuzzFind |
— |
Azure DevOps: the marker lives in a pull request property (touchmark.marker), apart from the description; the author, the head, the head's repository (a fork) and the closer come from the API, never from the property; a new pull request whose property cannot be stored is abandoned, or marked through an edit when the abandon fails |
azuredevops (markerOf, toPR, storeNewMarker), decide.Identity, distribute (markCreated) |
TestPRs, TestCreatePRMarkerAbandons, TestRunAzureDevOpsMarkerLost (git 2.45) |
The property is not protected. Anyone who may contribute to pull requests in the project may be able to write it through the API: project Readers have "Contribute to pull requests" by default, and the REST reference names only the scope vso.code_write (to confirm live, providers.md). Such a person can block a target (blocked:marker-invalid, by breaking the marker of touchmark's open pull request, until an adopt entry takes it over) or, before the team abandons a pull request, write closed.by=touchmark into its marker so that the decline does not count and the content is proposed again. They cannot make a pull request of their own touchmark's: its author, head and fork come from the API. |
Bitbucket Data Center: declining a pull request needs read access only, so the reader's token, which every branch of the hub can use, can decline touchmark's pull requests. touchmark reads who declined from the pull request's activities, never from its description; plan warns until the reader is in automation_accounts, whose declines count as automatic closes, as Bitbucket's system user's (the decline of inactive pull requests) and service users' do |
bitbucketdc (closer, toAccount), distribute (connect), decide.ClassifyClose |
TestPRs, TestPRsCloser (bitbucketdc), TestPlanReaderCloses, TestRunBitbucketDataCenterDecline (git 2.45) |
With the reader in automation_accounts, whoever holds the read key can still hold content back for memory.auto_close_cooldown, and three such declines in a row count as a decline, which a forget_declines entry lifts. Without it, a decline made with the read key counts as the team's. Who the activities name for an automatic decline is not documented (to confirm live, providers.md): a decline without a user counts as the system's. |
Azure Pipelines runs any logging command (##vso[…]) a step prints; under TF_BUILD=True touchmark breaks every ##vso[ in its output, also across writes, so no text of a platform or a target becomes a command |
cli.commandGuard |
TestCommandGuard, TestMainBreaksLoggingCommands |
The template's own step prints ##vso[task.uploadsummary] for the report files, which touchmark wrote. |
| API answers: at most 32 MiB | httpx (DefaultMaxBody) |
TestMaxBody, TestOnlyHTTPXSpeaksHTTP |
— |
Objects of a target: a commit at most 1 MiB, a blob at most 8 MiB; more is failed:git |
gitx (MaxCommitObject, MaxReadBlob), distribute.failCommit |
TestObjectLimits |
— |
Only the recreate and repropose checkboxes act; every other text of a target is data |
prbody.Ticked, prbody.Inert |
TestTicked, TestUntick, FuzzTicked, TestInert, TestMemoryReproposeBypassesOnce, TestRunKeptBodyIsInert (git 2.45) |
— |
Other controls¶
touchmark's other security rules: what it never does on a platform, the accounts, paths, and the releases.
| Control | Where | Tests | Residual risk |
|---|---|---|---|
| No automerge and no merge options; no line of a body or comment becomes a GitLab quick action, and no text mentions anyone | the drivers; prbody.CheckText, distribute.unsafeText |
TestNoMergeRequests, TestCreatePR, TestStartsWithSlash, TestCheckText, TestMentionAt, TestGitQuickActions, TestExecuteFlavors (git 2.45), FuzzRender, TestFacts (e2e GitLab: quick actions) |
— |
Reader and writer are different accounts; the writer is no member of the hub (doctor); an administrator's token is refused |
distribute (resolve, doctor), the drivers' Self |
TestPlanWriterUnknown, TestRunWriterMismatch, TestDoctorWriterMismatch, TestDoctorHub, TestSelfRefusesAdministrators, TestSelfRefusesAdmin, TestAdminTokenRefused (e2e Gitea, e2e GitLab), TestDoctor (e2e Gitea: the hub visible to the writer) |
None recorded: a read credential that acts as the writer stops plan in CI. |
| touchmark touches only its own pull requests and never moves or deletes a branch with someone else's open pull request | decide.Identity, decide.DecideTarget |
see T9 | — |
touchmark executes nothing from the hub or the targets; the filters of hash-object in status and apply come from git config, never from a repository's files |
gitx, apply, provenance |
TestOnlyGitRuns, TestExecuteThroughFilters, TestReadCurrentIgnoresCheckoutFilters, TestAttrsPinnedSettings |
A target's own .git/config in a local apply is the user's own: touchmark runs its filters as git would. |
Path protection: no .., absolute paths, :, .git/ or writes through symlinks; unsafe when a parent is a file, symlink or submodule, a case twin exists, a .gitattributes gives it a filter or would renormalize it |
pathx, apply, decide, gitx.TargetRepo.Attrs |
TestValidateRejects, FuzzValidate, TestIsGitDirName, TestUnsafeSymlink, TestOptInSymlink, TestTargetInsideGitDir, TestCaseVariantOfTargetFile, TestExecuteSymlinkSwappedIn, TestExecuteSymlinkedParentAfterObserve, TestNestedRepositoryParent, TestRunUnsafePaths (git 2.45), TestRenormalized |
— |
check refuses in packs .git in any case and its NTFS and HFS forms, Windows reserved names, trailing dots and spaces, case collisions, paths over 500 characters, symlinks, submodules, .gitmodules, .lfsconfig and the opt-in file |
pathx.Validate, provenance.CheckPacks |
TestValidateRejects, TestIsGitmodulesName, TestCheckPacks, TestReadCurrentCommittedProblems, TestReadCurrentWorkTreeSymlinks, TestCheckReportsDefects, TestOptInFileProtected |
— |
| A public hub skips non-public targets by default | see T7 | see T7 | — |
Releases and the image are signed, attested and come with an SBOM; the image is minimal, without git-lfs, not root; the Action runs the image by the digest whose build provenance it verified; the release's binfmt, BuildKit and SBOM scanner are pinned by digest, without the Actions cache; a release is published from master only, for a tag that carries the manifest's version, and the image is built from the release's own signed archives and tagged only once it is checked, attested and signed; CI runs govulncheck and fuzzing |
.goreleaser.yaml, Dockerfile, action.yml, scripts/action/run.sh, .github/workflows/publish.yml, scripts/release/check-version.sh, scripts/release/build-image.sh, .github/workflows/ci.yml (job fuzz: every Fuzz* target for 30 seconds; job govulncheck) |
release scripts (scripts/action/run_test.sh, scripts/release/tools_test.sh: see T4); the CI job release-snapshot; the fuzz targets themselves (FuzzParse, FuzzWriter, FuzzValidate, …) |
Verified by the publish runs of v0.1.0 and v0.2.0 on GitHub. The base images of the Dockerfile move with Dependabot's docker ecosystem; the pinned build images (binfmt, BuildKit, the SBOM scanner) and the tools run by version (goreleaser, cosign, syft) need updates by hand. release-please.yml is the org's, byte for byte, and takes its action by tag, not by commit. |