Threat model
What flowpact protects, where untrusted data and code cross into it, and its threats ranked by risk, each with the code and tests that defend against it and the risk that remains.
This is the security assessment of flowpact 0.12.3, built on @ciatlas/core 0.1.0, as of 2026-10-12. It names what
flowpact protects and where untrusted data and code cross into it, then ranks the threats by likelihood times impact.
For each threat it gives the code that defends against it, the tests that check that code, and the risk that remains.
It is revisited for every new feature and every breaking change, and the version and date above change with each
review. The security page describes what flowpact reads, writes and sends, and how to verify a
release; to report a vulnerability, see the security policy.
Paths are in this repository, except those marked (core), which are in ciatlas/core at v0.1.0.
Assets
- The analyzed repository. Its workflows, actions, config and contracts, and in impact mode the same files at the base commit. They are untrusted input: in CI, whoever opens a pull request controls their contents, their names and their symlinks, and flowpact's output repeats names and text from them.
- Plugins. JavaScript modules named by the config, the action's
plugininput or--plugin. Once loaded, a plugin is trusted code with every right of the process; flowpact does not sandbox it. - The CI runner. The job's secrets and environment, the workspace, the action's
tokeninput (github.tokenby default, used only for thegit fetchof impact mode) and the runner's own token for the drift artifact. The runner reads flowpact's log for workflow commands, and reviewers read its annotations and job summary. - The user's machine. For the CLI, the language server and the VS Code extension: files outside the repository, the terminal that shows the output, and the editor.
- The release pipeline.
release.yml, running frommain. The release bot's private key (RELEASE_BOT_PRIVATE_KEY, an organization secret that only ciatlas/flowpact and ciatlas/core can read). The publishing identities, each exchanged for a short-lived token at each publish: npm and Open VSX acceptrelease.ymlin thenpmandopen-vsxenvironments, and the Marketplace's Azure identity accepts any job in thevscode-marketplaceenvironment (release.ymlandvscode-publisher.yml); the three environments deploy only frommain. The accounts behind them: the maintainer's GitHub account (an organization owner; the organization requires two-factor authentication), the npm account, the Marketplace publisher, the Open VSX namespace and the Azure subscription that holds the Marketplace identity. - Dependencies. The npm packages in
pnpm-lock.yaml, bundled into the CLI, the action and the extension, and the build tools that run in CI; and@ciatlas/core, a git dependency on a release tag of ciatlas/core, which has the same maintainer. - The released artifacts. The npm package, the action at its tags (including the floating tag of each minor line,
such as
v0.12), the.vsixon the two registries, the signed files of each GitHub release, and this site, whichdocs.ymldeploys to GitHub Pages frommain.
Trust boundaries
- Repository to flowpact. Everything read from the analyzed repository is parsed as untrusted data, and read only from inside the repository.
- flowpact to its readers. Output goes to the runner's log, GitHub's annotations and job summary, a terminal and an editor, and each of them reads some text as commands or markup.
- Config to code. Loading a plugin turns repository content into code that runs. The only control is whether it loads.
- Workflow to command line. A workflow may put untrusted text, such as a pull request title, into a flag value.
- Dependencies to the bundle. Third-party code enters flowpact's artifacts when CI builds them.
- Release workflow to registries. The publishing identities and the release bot's key turn a commit on
maininto what users install. - Registries to users. Users get flowpact from npm, the action's tags, the Marketplace or Open VSX, and can check it against the signed GitHub release.
Threats, ranked
Likelihood and impact are rated low, medium or high. The rank is likelihood times impact before mitigation, and ties are ordered by the residual risk.
| Rank | Threat | Likelihood | Impact | Residual risk |
|---|---|---|---|---|
| 1 | Plugins run on untrusted code | medium | high | medium |
| 2 | A compromised dependency | medium | high | medium |
| 3 | Injection through output | high | medium | low |
| 4 | Writes through symlinks | medium | high | low |
| 5 | A stolen release-bot key or publishing credential | low | high | medium |
| 6 | A compromised @ciatlas/core release | low | high | low |
| 7 | A tampered release | low | high | low |
| 8 | Argument injection through flag values | low | high | low |
| 9 | Resource exhaustion | medium | low | low |
Plugins run on untrusted code
A pull request adds a plugin to the config, or changes one the config lists. A CI job that runs flowpact on that checkout with plugins on runs the pull request's code with the job's token and secrets; on a developer's machine, it runs with the user's rights.
- Mitigation. In the action,
plugins: auto(the default) loads no plugin from the checkout onpull_request_targetandworkflow_run(pluginsAllowedandUNTRUSTED_EVENTSinpackages/action/src/main.ts). A file of theplugininput that lies in the checkout is skipped there too, and the step warns that it skipped them. The extension declares limited support for untrusted workspaces, withflowpact.pluginsrestricted (packages/vscode/package.json), and turns plugins on in the language server only in a trusted workspace (serverSettingsinpackages/vscode/src/settings.ts). The language server loads none unless the client asks (packages/language-server/src/settings.ts), and then only for repositories inside the workspace folders (packages/language-server/src/workspace.ts). The CLI skips the config's plugins when--no-pluginsappears anywhere in its arguments (createContextinpackages/cli/src/shared.ts). A base config cannot list plugins (REPOSITORY_ONLYinpackages/lint/src/config.ts). Plugins are local files thatloadPluginsimports (packages/lint/src/plugins.ts); flowpact never downloads or installs one. - Tests.
packages/action/test/main.test.ts(plugins on untrusted events, files of theplugininput in the checkout),packages/vscode/test/settings.test.ts,packages/language-server/test/server.test.ts(plugins only when the client trusts the workspace),packages/cli/test/cli.e2e.test.ts(--no-pluginsand--plugin),packages/lint/test/config.test.ts(plugins in a base config). - Residual risk: medium. A loaded plugin can read the
tokeninput, the environment and the file system.plugins: autogoes by the event name only: a workflow that checks out a pull request's code on another event, such asissue_comment, while it holds secrets, runs that pull request's plugins. Setplugins: falsethere. The CLI loads the config's plugins by default, so a script that runs it on code it does not trust must pass--no-plugins.
A compromised dependency
A malicious version of a package that flowpact bundles, or of a tool that builds or publishes it, is published to npm.
- Mitigation. Users install nothing but flowpact's own files: the npm package declares no dependencies
(
scripts/check-pack.mjsfails CI and the publish otherwise), the action runs its committed bundle, and the extension bundles the language server. A dependency reaches users only inside a flowpact release. Versions and integrity hashes are fixed inpnpm-lock.yaml, and every workflow installs with--frozen-lockfile. pnpm runs install scripts only for esbuild (allowBuildsinpnpm-workspace.yaml), and the jobs that publish the extension install with--ignore-scripts. Renovate proposes a version only two days after it was published (minimumReleaseAgeinrenovate.json, except for@ciatlas/coreand updates that only pin the version in use) and raises OSV vulnerability alerts. Its pull requests must pass every required check; non-major updates then merge without review, but nothing ships until the maintainer merges the release pull request. The extension is built in a job with read access only (vscode-packageinrelease.yml), and so is this site (thebuildjob ofdocs.yml; only itsdeployjob, which runs no build tool, can write to GitHub Pages). Each artifact lists what it bundles inTHIRD_PARTY_LICENSES.txtand a CycloneDX SBOM (scripts/bundle-licenses.ts). - Tests.
scripts/test/bundle-licenses.test.ts; CI's Action bundle is up to date job, which rebuilds the action and fails when the result differs from the committed bundle;scripts/check-pack.mjs, in CI and before the npm publish. - Residual risk: medium. Third-party actions, pinned to a commit SHA, also update themselves after two days
(non-major) and run with the release bot's key or a publishing identity as soon as they are on
main. A malicious patch version that goes unnoticed for two days and passes the tests merges itself and ships with the next release; reviewing the release pull request is unlikely to catch it in the rebuilt bundle. Third-party packages also run in jobs that hold credentials: the release pull request job rebuilds the action bundle with the release bot's token (contents: write), the npm job builds the package with the npm publishing identity, and the Marketplace and Open VSX jobs ofrelease.yml, andvscode-publisher.yml, run the lockfile-pinned vsce and ovsx with a publishing identity (--ignore-scriptsstops only their install scripts). A compromised package there could push to branches other thanmain, move a floating tag, publish to npm with valid provenance, or publish the extension.
Injection through output
Names, descriptions and source lines from the analyzed files appear in flowpact's output. A pull request can choose
them to forge annotations or hide later log lines with workflow commands (::… at the start of a line, ##[…]
anywhere), to rewrite a terminal with escape sequences, or to add HTML, links, images or @mentions to the job summary.
- Mitigation.
neutralizeWorkflowCommands(coresrc/text.ts) puts a zero-width space into every line the runner would read as a command:::at its start (after whitespace, U+0085 or color codes) and##[anywhere. The CLI applies it to every string written to stdout and stderr (packages/cli/src/index.ts) and to every text report it writes (writeOutputinpackages/cli/src/shared.ts); the API's text output and the action's log lines use it too. JSON and SARIF are written withjsonSafe(core), which encodes##[and the Unicode line separators. The only workflow commands flowpact prints on purpose, annotations, have every value escaped (workflowCommandsinpackages/reporters/src/kit/github.ts; the action uses@actions/core). For terminals,escapeControl(core) andsafe(packages/reporters/src/kit/theme.ts) print C0 and C1 control characters, ESC included, as visible escapes, and colors and OSC 8 links appear only when asked for. For Markdown,markdownText(packages/reporters/src/kit/markdown.ts) escapes HTML, link brackets and backslashes, keeps code spans balanced and breaks @mentions; the job summary is cut to fit GitHub's 1 MiB limit (summaryWithinLimit). Hovers in the editor show repository text escaped or as inline code (packages/language-server/src/hover.ts). - Tests.
test/text.test.ts(core);packages/cli/test/cli.e2e.test.tsandpackages/cli/test/api.test.ts(no line GitHub would run as a workflow command, control characters from the YAML);packages/reporters/test/github.test.ts(nothing can end an annotation early);packages/reporters/test/markdown.test.ts(Markdown escaping of untrusted names);packages/reporters/test/reporters.test.ts(colors and OSC 8 links only when asked);packages/lint/test/review-round3.test.ts;packages/language-server/test/hover.test.ts. - Residual risk: low. Unicode bidirectional controls and look-alike characters are printed as they are, so a name can read differently from what it is. GitHub still turns bare URLs in the job summary into links. Patches and contracts are written byte for byte, as git must apply them; a later step that prints them prints text from the pull request. No test runs the action's own log lines against hostile names; they go through the same function as the CLI's.
Writes through symlinks
A pull request commits a symlink where flowpact writes: a contract file, a report file, or a directory on the way to
one. Followed, the link would let a write in CI land outside the repository, such as in the runner's home directory or
another checkout, or in its .git directory.
- Mitigation. For reports, patches,
--dump-graphandgenerate --out,assertSafeWritePath(packages/lint/src/contracts.ts) refuses a path that leads into the repository (or, in GitHub Actions, the workspace;protectedTreesinpackages/cli/src/lib/targets.ts) and then is a symlink itself, goes through a symlink that lies inside that tree, even a linked directory that stays in it, such as one into.git(followsLinkIn), or resolves outside it. It compares canonical real paths, so another spelling of a directory does not skip the check, and links outside the tree, such as/tmpfor/private/tmp, are still followed. The CLI's reports, patches and graphs (writeOutput),generate --out(writeContracts) and the action's reports (writeReport) are all written through it.writeContractschecks every contract before it writes or deletes any, and refuses one that is a symlink, goes through one in the repository (followsLinkIn), or resolves outside the repository or into.git(insideRepositoryin coresrc/project.ts). Reads are kept inside the repository and out of.gittoo, the default config included (loadConfiginpackages/lint/src/config.ts). The action writes the drift artifact to the runner's temporary directory, or, outside GitHub Actions, to a new private directory (driftArtifact). - Tests.
packages/lint/test/review-round3.test.tsandpackages/lint/test/review-round2.test.ts(writes and reads through symlinks, linked directories in the repository,.git, the config);packages/cli/test/cli.e2e.test.ts(reports,--outand contracts written in place, through a symlink or a linked directory in the repository, also one into.git, refused before anything is written);packages/action/test/main.test.ts(a report through a symlink or a linked directory in the checkout, also one into.git; the drift artifact's folder);test/project.test.ts(core; reads through symlinks out of the repository, into.gitor up out of it). - Residual risk: low. The check runs before the write, and files are not opened with
O_NOFOLLOW: a process that changes the directory in between, such as a step running in the background, can still redirect a write. Paths outside the repository and the workspace, such as$GITHUB_STEP_SUMMARY, are written as given. Versions before 0.12.3 followed some of these links; the security policy lists which.
A stolen release-bot key or publishing credential
Someone gets the release bot's private key, or the credentials of an account that can publish flowpact.
- Mitigation. The key is an organization secret that only the workflows of ciatlas/flowpact and ciatlas/core can
read. In this repository, three workflows use it:
release.yml(the release pull request and the floating tag),repo-settings.yml(applying the rulesets and settings files frommain, the only job that asks for administration) and the commit job ofdependency-bundle.yml, which runs nothing from the branch it commits to. Each job asks only for the permissions it needs (thepermission-*inputs ofactions/create-github-app-token).CONTRIBUTING.mddescribes how to rotate the key. No publishing token is stored in GitHub: npm and Open VSX accept the release workflow (release.yml) in their environments, and the Marketplace's Azure identity accepts any job in thevscode-marketplaceenvironment (release.ymlandvscode-publisher.yml); each issues a short-lived token, and those environments deploy only frommain(.github/environments.json). Everyflowpactversion from 0.2.1 on was published through npm trusted publishing, with provenance. Nobody can push tomain, no one is exempt from its checks (themainruleset has no bypass), the organization requires two-factor authentication, and secret scanning with push protection runs on the repository. - Tests. None in the test suite.
repo-settings.ymlshows how the live rulesets and settings differ from the files on every pull request that changes them, and applies them on every push tomainthat changes them, and every week. - Residual risk: medium. The app has administration and contents write access. With its key, someone can push to
any branch but
main, create or movev*tags other than those of immutable releases (0.10.0 and later), so the floating tags and the tags of releases up to 0.9.0 included, and change the rulesets that keep it offmain; frommain, a changed release workflow would publish everywhere with valid provenance. Workflows that pin the action to a commit SHA do not follow a moved tag. Accounts outside GitHub can publish without the pipeline: the npm account (the only owner offlowpactand of the deprecatedworkflow-contracts, which published their0.0.0-stageplaceholders by hand;flowpact0.2.0 andworkflow-contracts0.1.0 came from the release workflow with its token), the Marketplace publisher's members, the owner of the Open VSX namespace and the Azure subscription that holds the Marketplace identity. A version published that way has no build provenance and no signed GitHub release, which the checks under Verify a release show. With one maintainer, the maintainer's GitHub account, an organization owner, is a single point of compromise for all of the above.
A compromised @ciatlas/core release
Someone releases a malicious version of the workflow model, or moves one of its tags.
- Mitigation. Every package depends on the same release tag, and
pnpm-lock.yamlpins it to a commit, with a sha512 hash of its tarball; with--frozen-lockfile, a moved tag changes nothing until the lockfile does.scripts/test/core-dependency.test.tsfails unless every package names the same release tag, nothing links a local checkout, and the lockfile resolves it to a commit whose version is the tag's. ciatlas/core has the same branch and tag rulesets as this repository, and immutable releases. A new core arrives as a Renovate pull request that runs the whole suite, and ships only in a release that the maintainer merges. The SBOM names itpkg:github/ciatlas/core@vX.Y.Z. - Tests.
scripts/test/core-dependency.test.ts;scripts/test/bundle-licenses.test.ts(a git dependency on a version tag is named by its repository and tag). - Residual risk: low. Core has the same maintainer, organization and release bot key, so the stolen key threat applies to it too. Core updates skip Renovate's two-day wait, and a patch update merges itself once the checks pass.
A tampered release
Someone changes what users download: a file of a GitHub release, the action at a tag, the npm tarball or the .vsix.
- Mitigation. Releases are built only by
release.yml, from a tag that the workflow checks exists. A release stays a draft until every signed file is attached, and immutable releases, enabled in this repository (from 0.10.0) and in ciatlas/core, lock its tag and files once it is published. Each artifact has a Sigstore bundle, in-toto build provenance and an attested SBOM (actions/attest), and the npm package has npm provenance. The action's bundle is committed and rebuilt in CI, and CI builds its release archive twice and compares the bytes (scripts/action-archive.sh). The jobs that publish the extension check the.vsixagainst the release's file (SHA-256) before they upload it. The published npm package is smoke-tested on Linux, macOS and Windows. Verify a release gives the commands to check each artifact. - Tests. CI's Action bundle is up to date job (the bundle and the archive are reproducible); the release workflow's smoke tests of the published package.
- Residual risk: low. Releases up to 0.9.0 are not immutable: an admin or the release bot can still move their
tags. Verification is up to the user. The floating tag of a minor line, such as
v0.12, belongs to no release and moves with each one, so a workflow that uses it trusts whoever can move it; pin a commit SHA instead. The Marketplace and Open VSX serve their own copy of the.vsix, which can be compared with the signed release file only by hand. Releases up to 0.11.1 verify under the old owner. The two0.0.0-stageplaceholders, the first versions offlowpactandworkflow-contractson npm, contain no code, have no provenance and are deprecated, like the rest ofworkflow-contracts.
Argument injection through flag values
A workflow passes untrusted text as a flag value, such as --title "$PR_TITLE". Read as arguments, a title such as
--plugin=x.js, -- or --help could load a plugin, redirect a report, turn off --no-plugins or end the run.
- Mitigation. Before citty reads the arguments,
normalizeArgv(packages/cli/src/shared.ts) attaches every value of a string flag to its flag (--title Xbecomes--title=X), andrepeatedFlagreads repeated flags withnode:util'sparseArgs, so a value is never taken for another flag. Options before the command are a usage error. Git runs without a shell; refs that start with-are refused, and other refs are passed after--end-of-options(resolveCommitin coresrc/git.ts). The action reads its inputs fromwith:, not from a command line. - Tests.
packages/cli/test/args.test.ts;packages/cli/test/cli.e2e.test.ts(a pull request title as--pluginor-o, as--, as--help);test/git.test.ts(core; refs such as--output=/tmp/xare refused before git runs). - Residual risk: low. Only flag values are protected. A positional argument, such as a path, that starts with
-is still read as an option; put untrusted paths after--(flowpact lint -- "$PATHS"). Quoting is the workflow's job: pass untrusted text throughenv:, not by expression interpolation inrun:.
Resource exhaustion
A file built so that analyzing it takes disproportionate memory or time: YAML aliases that multiply, a matrix with a huge product, symlink loops or linked directories that fan out, or text that makes a regular expression backtrack.
- Mitigation. Before resolving aliases, core counts the nodes they would produce; past 50,000 the file is not
analyzed and gets
FP504(aliasExpansionTooLargein coresrc/parse.ts). Matrix products above 20,000 combinations are not enumerated (MAX_COMBINATIONSin coresrc/matrix.ts), and more than 256 jobs is reported (FP406). Directory walks follow at most 40 links in a path and expand at most 1,000 linked directories, keeping the shallowest (MAX_LINKSandMAX_LINKED_DIRSin coresrc/project.tsandsrc/git.ts). Output read from git is capped at 256 MiB. CodeQL's extended queries check the regular expressions, and code they flagged for backtracking (js/polynomial-redos) was rewritten to run in linear time, such astrimCharin coresrc/text.ts. The job summary stays under 1 MiB. - Tests.
packages/lint/test/review-round2.test.ts(an alias bomb stops withFP504within 2 seconds; matrices up to 256 jobs pass, and a product of 22,500 jobs is reported as at least that many);packages/lint/test/review-round3.test.ts(an alias bomb in the config);packages/lint/test/code-scanning.test.ts(linear time on adversarial input);packages/lint/test/robustness.test.ts(a property test: arbitrary workflow-shaped input never throws);test/matrix.test.ts,test/project.test.tsandtest/git.test.ts(core; large products, a directory linked into itself, fan-out, link depth);packages/action/test/main.test.ts(the job summary limit). - Residual risk: low. The size and number of files are not capped: time and memory grow with them, and in CI the job's timeout is the limit.