One pinned, checksum-verified set of open-source scanners, run the same way on a developer's machine and on a clean CI checkout:
| category | owner tool | where it runs |
|---|---|---|
| secrets | Gitleaks | every commit (staged), every push (range), CI |
| static analysis (SAST) | Opengrep | changed files at commit, full repo at push, CI |
| dependency vulnerabilities | OSV-Scanner | push, CI (when a manifest or lockfile changed) |
| infrastructure misconfig | Trivy config |
push, CI (when an IaC file changed) |
| workflow syntax | actionlint | commit |
One owner per category, on purpose: a secret is reported by Gitleaks and nobody else; a Dockerfile misconfiguration by Trivy and nobody else. Container image CVEs and SBOMs stay with the syft/grype pipeline that already publishes them.
Three files, no copied scanner configuration:
# .pre-commit-config.yaml
default_install_hook_types: [pre-commit, pre-push]
repos:
# thyn-security-toolchain:begin
- repo: https://github.com/thyn-ai/security-toolchain
rev: v0.1.17
hooks:
- id: gitleaks-staged
- id: opengrep-changed
args: ["--overlay=python-uv"]
- id: actionlint
- id: verify-toolchain
- id: gitleaks-push
- id: opengrep-full
args: ["--overlay=python-uv"]
- id: osv-scan
- id: trivy-config
- id: security-fix-deps
# thyn-security-toolchain:end# .github/workflows/security.yml
on:
pull_request:
merge_group:
push:
branches: [main]
jobs:
full:
uses: thyn-ai/security-toolchain/.github/workflows/security-full.yml@<sha> # v0.1.17
permissions: { contents: read, security-events: write, pull-requests: read, actions: read }
with: { overlay: python-uv, mode: advisory }security/baseline/<tool>.txt # measured on CI, may only shrink (see below)
If the repository runs Dependabot on github-actions, exclude this toolchain so the CI
pin and the pre-commit rev are only ever bumped together by propagate.py. Dependabot
names a reusable-workflow dependency by its full path, so the pattern needs a wildcard:
ignore:
- dependency-name: "thyn-ai/security-toolchain*"The merge_group trigger is what lets full / security gate be a required check on a branch
protected by a merge queue: the queue runs the checks it requires on that event, and a caller
without it would wait for a status that never arrives. propagate.py adds the trigger to a
caller that lacks it, mirroring the branches filter of its pull_request entry.
pre-commit install installs both stages. The first run fetches the pinned binaries into
~/.cache/thyn-sec (about 250 MB, verified against security/toolchain.lock); after that
the commit-time hooks take a few seconds and the push-time hooks under two minutes on a
typical repository.
Full templates live in templates/; scripts/propagate.py applies them to
every repository in fleet.json and bumps pins on each release.
A fourth file, .github/workflows/dependabot-auto-merge.yml
(template), calls the reusable
dependabot-auto-merge.yml on three events: pull_request_target (Dependabot's pushes, and any
other push to a Dependabot pull request's head) and pull_request_review (a codna review being
submitted, in any state) for pull requests opened by dependabot[bot], and push to main.
Once the codna review has approved the pull request's current head and left no
unresolved thread, the job enables GitHub auto-merge on it; GitHub then completes the merge only
once every ruleset requirement on the base branch is met -- the codna review status check and
the repository's required checks -- so the workflow adds no bypass and needs none. Adopt it
only where the default branch requires codna review (every repository in fleet.json does:
the org ruleset "Codna Review required", or a repository ruleset); without that requirement
auto-merge would complete on green CI alone.
- Minor and patch updates are enabled. A semver-major update stays open for a human or codna
decision even when approved and green, unless the caller sets
auto_merge_majors: true(dependabot_auto_merge_majorsinfleet.jsonrenders that). For a version bump (Bump <pkg> from X to Y, a grouped update) the type isdependabot/fetch-metadata's, the highest change in the update. For a requirement update (Update <pkg> requirement from <old range> to <new range>, pip) the job derives the type itself, from the two ranges' lower bounds (>=X,~=Xor==X, exactly one per range): fetch-metadata reads a range written upper-bound-first -- Dependabot's own format,<2,>=1.29.0-- as version2,...and reports a floor bump from 1.29.0 to 1.30.0 as a major (algenta-integrations #153, 2026-09-20). A requirement update whose upper bound or exclusions moved too, or with no such lower bound on either side, is unclassified and stays open with a notice, as does any other change without an update type. - The review is judged on the pull request's reviews, never on the
codna reviewcheck-run conclusion: codna concludes that check-runneutralwhenever it posted an inline finding (even a LOW one under an "Approved" summary) andsuccessonly when finding-free, and a neutral conclusion satisfies a required status check -- so on a ruleset that requires review thread resolution, the unresolved thread is what would hold an enabled auto-merge forever. Auto-merge is enabled only when codna's latest review isAPPROVEDand was submitted against the current head, the review decision is not against it, and there are zero unresolved threads. Findings are never resolved by the workflow; a pull request codna did not approve, or left findings on, stays for a human or agent, and the run summary says so -- from there, resolve and merge it (or enable auto-merge) by hand; there is no trigger on a thread being resolved, because the pinned actionlint (1.7.12) does not knowpull_request_review_threadand the pre-commit hook lints every caller. - An arm is good for one head. GitHub keeps an enabled auto-merge across pushes to the head
branch and keeps an approval whose diff a rebase did not change, so a pull request armed on
head A stayed armed, with
reviewDecisionAPPROVED, after Dependabot rebased it to head B, and a finding codna then posted on B would not have stopped the merge (algenta-integrations #150, 2026-09-20). The job therefore disarms an auto-merge it enabled itself when the head changes (synchronize, whoever pushed) and when codna's latest verdict is not an approval of the current head with zero unresolved threads; codna's approval of the new head re-arms it. An auto-merge a human (or another App) enabled is never touched, in either direction. - Skipped, each with a
::notice: a pull request not opened bydependabot[bot], apull_request_targetevent not triggered by it, a review event that is not codna's (any account can review a public repository's pull request; the caller'sif:keeps every other reviewer from starting the job at all, and the callee refuses them too), a head branch in another repository, a draft, a repository with "Allow auto-merge" off, and a pull request whose auto-merge a human enabled. - A ruleset that requires branches to be up to date (
strict_required_status_checks_policy) turns every other open Dependabot pull requestBEHINDon each merge tomain, and GitHub's auto-merge never updates a branch: an armed pull request then waits forever (algenta-integrations #150/#152/#154, 2026-09-20 -- armed,BEHINDafter the first sibling merged, moved only by a human's@dependabot rebase). Dependabot honours that command from users with push access only; to a GitHub App it answers "Sorry, only users with push access can use that command" (dependabot/dependabot-core#9147, open). So the job brings the branch up to date itself, with the App's token, through GitHub's update-branch API (gh pr update-branch): mergingmaininto the head rather than rebasing, because fetch-metadata verifies the pull request's first commit -- Dependabot's, signed -- which a merge leaves in place and a server-side rebase rewrites into a commit GitHub does not sign. Two paths reach it: the pull-request job, when it arms aBEHINDpull request or finds one it armed still holding its conditions; and thepushsweep, for the pull requests that turnedBEHINDwithout an event of their own. A base branch with a merge queue is left to the queue (algenta-sdk). The update is a new head: the job disarms, codna reviews that head, and its approval re-arms. The fleet was audited at rollout: the policy is off on every private repository (cohenta 17646026 and algenta 17410382 were switched off on 2026-09-20, before-JSON kept); algenta-sdk keeps it behind its merge queue; the four other public repositories (algenta-integrations, mojo-kernels, codna-action, feedback) keep it -- OpenSSF Scorecard's Branch-Protection check scores it.fleet.jsonrecords this per repository. - The merge is enabled with an installation token of the org's
algenta-sdk-syncApp, scoped to the one repository, rather than withGITHUB_TOKEN: GitHub does not start workflow runs for events theGITHUB_TOKENtriggers (exceptworkflow_dispatchandrepository_dispatch), so aGITHUB_TOKENmerge would never run thepushworkflows onmain-- release-please, deploys, mirrors, the gate's post-merge run. The caller passes exactly one secret, the org-wideALGENTA_SDK_SYNC_APP_PRIVATE_KEY; the App id and slug are public values and inputs (app_id,app_slug; a caller that changes one must change the other, and the job refuses a token minted for a different App than the slug names). - Nothing from the pull request runs: no checkout, no local action, no pull-request string in a
shell. The title is the one free-text field read -- into the Decide step's environment, matched
against the requirement-update grammar above, never executed, and echoed only in tokens made of
range characters.
pull_request_targetis what makes the secret available for a Dependabot pull request (apull_requestrun by Dependabot gets a read-only token and no repository secrets).
The same gate runs everywhere: thyn-sec ci on CI, one tool at a time in the hooks.
- advisory mode (the default for a newly onboarded repository) surfaces every finding as a warning and never fails.
- ratchet mode fails only on a new HIGH/CRITICAL finding -- one whose key is not in
the committed
security/baseline/<tool>.txt. Keys are line-number independent, so editing above a known finding does not churn the file. - Baselines are measured on CI (
workflow_dispatch→measure_baseline: true, download the artifact, commit it as-is) and may only shrink: the gate tells you which entries are no longer observed so you can delete them. - Secrets are never baselined. Allowlist a reviewed false positive through
.gitleaksignore(fingerprints) or.gitleaks.toml(paths); rotate anything real. - Suppressions live in those files, never in inline
nosemgrep/# noseccomments. - Opengrep audit rules -- registry rules tagged
LOW CONFIDENCEor with.audit./-auditin their id (dangerous-subprocess-use-audit,non-literal-import,dynamic-urllib-use-detected, ...) -- flag intended subprocess, import and URL use. They score MEDIUM so they never block, they are counted in the console summary and kept in thethyn-sec-reportsartifact, but they are left out of the SARIF uploaded to code scanning so the alert list stays actionable. A repository that wants them as code-scanning warnings setsopengrep_upload_audit: trueonsecurity-full.yml. - Test code is out of Opengrep's scope:
**/tests/**,**/test/**,**/__tests__/**,test_*.py,*_test.py,conftest.py,*.test.ts|tsx|js,*.spec.ts|tsx|js,**/testdata/**,**/fixtures/**. What SAST finds there is the fixture, not a defect: a hard-coded JWT secret that mints test tokens,jwt.decodewithout verification on a token the test itself just signed, XML parsing of a checked-in sample,subprocessin a test harness. The scanner applies the exclusion itself (--exclude+--force-exclude), so it holds for the pre-commit hooks, the PR-scoped CI scan and the full scan alike, and the results never exist -- not in the gate, not in the code-scanning upload. Opengrep's default.semgrepignorealready skipstests/andtest/, but a repository's own.semgrepignorereplaces that default and files named on the command line bypass it without--force-exclude; this policy holds either way.benchmarks/,scripts/and every other non-test path stay in scope. A repository that wants its tests scanned setsopengrep_scan_tests: trueonsecurity-full.yml(thyn-sec ci --opengrep-scan-tests;--scan-testson theopengrep-changed/opengrep-fullhooks). That lifts this toolchain's exclusion only: Opengrep's built-in.semgrepignorestill skipstests/andtest/-- on a full scan and, because--force-excludeapplies it to files named on the command line too, on the PR-scoped CI scan and theopengrep-changedhook alike -- until the repository commits a.semgrepignoreof its own (an empty one is enough); the gate summary says so when it applies.
On pull requests the CI job scopes Opengrep to the changed files and skips OSV-Scanner
or Trivy when no manifest or infrastructure file changed; the list is derived fail-closed
(anything uncertain widens to a full scan). A merge group is scoped as the pull request it
was built for -- its files from the API when the queue ref names the number, otherwise
git diff merge_group.base_sha...head_sha, otherwise everything. Pushes to main and the
weekly schedule scan everything.
An overlay is the set of Opengrep rule packs for a repository class, and nothing more:
python-uv (Python), site (Next.js/TypeScript), python-javascript (Python +
JavaScript/TypeScript), engine, codna, smoke. Packs come from a pinned commit of
opengrep-rules, fetched into the cache at
scan time; a repository can add its own rules under security/opengrep/*.yml.
thyn-sec overlays -v lists what each one loads.
The overlay name never reaches the other scanners. OSV-Scanner walks the whole tree
(osv-scanner scan source --recursive) and reads every lockfile it supports --
package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, uv.lock, poetry.lock,
requirements.txt, pixi.lock, go.sum, Cargo.lock, ... -- so an npm workspace, a pnpm
workspace and a uv project are scanned identically under any overlay, and on a pull request
a change to any of those files puts the scan in scope. Trivy config and Gitleaks take the
whole tree as well. pnpm-monorepo is the pre-0.1.12 name of python-javascript, kept as
an alias so repositories onboarded with it keep working; it never selected lockfiles.
security/toolchain.lock is the single source of truth for every tool version and the
sha256 of each release asset per platform. Nothing is executed before its digest matches.
verify-toolchain (a hook and a CI step) checks that the rev: in pre-commit, the
@<sha> # vX.Y.Z pin of the caller workflow and the installed toolchain agree, and that
CI pins by commit SHA.
The reusable workflows install the toolchain from the very commit the caller pinned: the job
reads job.workflow_sha and job.workflow_repository -- the workflow file that defines it,
resolved -- checks that out and installs it through the composite action in that checkout.
There is no second version literal to keep in step, nothing a pin checker reads as unpinned,
and the CI verify-toolchain --expect-ref is an exact comparison of the caller's SHA against
the commit that is running (tests/test_self_reference.py holds the shape). The toolchain
repository has to be readable by the caller's token: public, or the caller's own repository.
The CI job runs on standard GitHub-hosted runners only and refuses billed runner labels.
thyn-sec ci --overlay <name> --mode advisory|ratchet [--changed FILE|ALL] [--measure-baseline] [--opengrep-upload-audit] [--opengrep-scan-tests]
thyn-sec changed-files [--output FILE]
thyn-sec install all --rules # prefetch everything (CI, air-gapped prep)
thyn-sec run trivy -- image ... # any pinned tool, verbatim
thyn-sec lock | overlays -v | verify-toolchain
Environment: THYN_SEC_CACHE_DIR, THYN_SEC_OFFLINE=1 (cache misses become errors),
THYN_SEC_OSV_OFFLINE=1 (query a locally cached OSV database), THYN_SEC_VERBOSE=1.
Toolchain code: Apache-2.0. Scanners are used as unmodified upstream binaries: Gitleaks
(MIT), Opengrep (LGPL-2.1), OSV-Scanner (Apache-2.0), Trivy (Apache-2.0), actionlint
(MIT). The opengrep-rules bundle (LGPL-2.1 with the Commons Clause) is fetched at scan
time and not redistributed here. No scanner in this toolchain sends code or credentials
to a third party; OSV-Scanner queries the public OSV API with package coordinates unless
THYN_SEC_OSV_OFFLINE=1 is set.
Python 3.12 or newer -- the org standard -- on a laptop and on CI alike (requires-python in
pyproject.toml; the unit matrix runs 3.12 and 3.13). v0.1.13 dropped 3.9 through 3.11 (3.9
reached end of life in October 2025), which is also what lets the dev toolchain run pytest
9.0.3+ (GHSA-6w46-j5rx-g56g). The pre-commit hooks install the package into whichever Python
pre-commit selects, which has to meet that floor.
python -m venv .venv && . .venv/bin/activate
pip install --require-hashes -r requirements-dev.txt # pytest, pyyaml, ruff -- the CI pins
pip install --no-deps -e . # the package has no dependencies
pytest -m "not integration" # fast; also replays the fuzz corpora through every harness
pytest -m integration # downloads the pinned scanners, proves one-owner-per-fixturepip install -e ".[dev]" ruff works too when the exact CI versions do not matter.
requirements-dev.txt carries the hashes CI installs with and says how to regenerate them;
Dependabot proposes bumps for it and refreshes the hashes.
The parsers are fuzzed with atheris on every push
(fuzz/README.md): the lock validator, the overlay resolver, the changed-file reader, the
four report parsers and the pin readers each have a harness under fuzz/ with the property
it enforces, and a bounded run of all of them is a job in self-test.yml. Posture is tracked
by OpenSSF Scorecard
(scorecard.yml, weekly and on every push to main).
Releasing: bump version in pyproject.toml and __init__.py and the two pins in this
README, merge, tag vX.Y.Z on the merged commit, publish the release. The reusable workflows
carry no version of their own to bump. propagate.yml (or scripts/propagate.py) opens the
pin-bump pull requests across the fleet -- see the next section for what the workflow needs.
propagate.yml runs on every published release, and on workflow_dispatch with tag,
repos and dry_run. It needs a token that can push a branch and open a pull request in
every fleet repository, including a change to .github/workflows/security.yml. The default
GITHUB_TOKEN cannot (enterprise policy forbids it from creating pull requests, and it has
no workflows permission), so the job reads one of two credentials. With neither present it
posts a ::notice and exits 0 -- which is what every run through v0.1.10 did, because the
secret never existed; each of those releases was fanned out from a laptop instead.
The secrets are read in exactly two places: the creds step tests their presence in the
expression layer (${{ secrets.NAME != '' }}, so booleans reach its shell and no secret value
does), then the App id and key go only to the token-mint action and TOOLCHAIN_PROPAGATE_TOKEN
enters the Propagate step's environment only when it is the credential in use. On the release
trigger the job also refuses to fan out unless the tagged commit is reachable from main (the
compare API reports identical or behind) and its __version__ equals the tag, because
release: published fires for a tag on any commit and an unreviewed one would otherwise reach
every fleet repository with the org token.
GitHub App (preferred). Two repository secrets, named exactly as in
algenta-integrations so the owner sets them the same way:
| secret | value |
|---|---|
ALGENTA_SDK_SYNC_APP_ID |
the App's numeric id (public) |
ALGENTA_SDK_SYNC_APP_PRIVATE_KEY |
the App's private key, the PEM as downloaded |
gh secret set ALGENTA_SDK_SYNC_APP_ID -R thyn-ai/security-toolchain --body <app-id>
gh secret set ALGENTA_SDK_SYNC_APP_PRIVATE_KEY -R thyn-ai/security-toolchain < <path-to-pem>The App must be installed on every repository in fleet.json with the repository permissions
Contents: read and write, Pull requests: read and write and Workflows: read and
write (the bump rewrites .github/workflows/security.yml; GitHub refuses the push without
it). Each run mints a short-lived installation token narrowed to exactly those plus
metadata: read, and the pull requests are authored by the App's bot user, with no personal
or assistant attribution. A repository the App is not installed on shows up as one FAILED
line for that repository while the rest fan out. If the token cannot be minted at all (a
permission missing from the App, the App not installed on the organization, a rotated key),
the job fails with an ::error that says so rather than silently doing nothing.
Fine-grained PAT (alternative). TOOLCHAIN_PROPAGATE_TOKEN: a fine-grained personal
access token limited to the fleet repositories with the same three permissions (Contents,
Pull requests, Workflows: read and write). Commits then carry propagate.py's default
author. The App secrets take precedence when both are present.
gh secret set TOOLCHAIN_PROPAGATE_TOKEN -R thyn-ai/security-toolchainEvery run writes a step summary naming the credential mode and, per repository, whether a pull request was opened, the repository was already at the tag, or the fan-out failed.
Open-source tooling around Algenta, from the Algenta team. The Algenta engine itself is proprietary; everything listed here is Apache-2.0. Issues and discussions are welcome in whichever repository owns the code.
- thyn-ai/algenta-sdk — Python and TypeScript SDKs for Algenta: governed data queries, simulations, decision memory with execution receipts, agent runs with approvals.
- thyn-ai/algenta-integrations — Framework integrations for Algenta: LangChain, LlamaIndex, pydantic-ai, MAF, Haystack, LiteLLM, Ray Serve, vLLM, Vercel AI SDK and n8n.
- thyn-ai/mojo-kernels — Clean-room Mojo kernels as drop-in accelerators for popular Python/TypeScript libraries, with bit-exact parity and pure-language fallbacks.
- thyn-ai/security-toolchain (this repository) — The pinned, checksum-verified security toolchain (Gitleaks, Opengrep, OSV-Scanner, Trivy config, actionlint) that every thyn-ai repository runs locally and in CI.
- thyn-ai/feedback — Public issue intake for the open-source tooling around Algenta and for the Codna GitHub App.
- thyn-ai/codna-action — GitHub Action for Codna: fix, review or secure a repository in CI through the same packaged local runtime the CLI uses.