Shared reusable GitHub Actions workflows for cleancoders repos.
Runs seven scanners. Hard-fail (block the caller): clj-kondo,
shellcheck, gitleaks, actionlint. Advisory by default (report, never
block): clj-watson, semgrep, zizmor — each can be made blocking
per-consumer via the clj-watson-blocking / semgrep-blocking /
zizmor-blocking inputs.
semgrep is the only Clojure detection engine. It carries the 16 cleancoders
cc-* rules in security-rules/semgrep/ and reads .clj, .cljs, and
.cljc.
clj-holmes was removed. It read only .clj — silently skipping .cljs and
.cljc, and rejecting reader conditionals outright — had been unmaintained
since October 2022, and produced three failures in its first week: exiting 3 on
zero findings under -t sarif, writing findings to a file so a red build gave
no reason, and crashing on a progress-bar integer overflow that blocked a
production deploy. Its unique detections are now cc-read-string,
cc-clojure-xml-xxe, cc-weak-crypto, and cc-insecure-tls, which catch 8 of
8 fixture cases against its 6 — it shipped rules for Blowfish and DESede and
matched neither. Rationale: docs/superpowers/specs/2026-07-27-cwe-owasp-coverage-design.md
(Revision 2).
Pin the moving @v1 tag:
name: Security
on:
pull_request: {}
workflow_call: {}
jobs:
security:
uses: cleancoders/github-actions/.github/workflows/security.yml@v1
with:
shellcheck-dir: "./bin" # optional; default "./bin"
# src-paths: "src" # optional; default "src/clj src/cljs src/cljc"
secrets: inherit| input | default | purpose |
|---|---|---|
src-paths |
"src/clj src/cljs src/cljc" |
Space-separated clj-kondo lint targets. Nonexistent paths are filtered out, so the default is safe for repos missing a source root. |
shellcheck-dir |
"./bin" |
shellcheck scandir. The job self-skips when the directory is absent or empty (e.g. library repos with no bin/). |
clj-watson-blocking |
false |
When true, clj-watson dependency-CVE findings fail the workflow. Default false = advisory (reported, never blocks). |
semgrep-blocking |
false |
When true, semgrep findings fail the workflow. Default false = advisory (reported, never blocks). |
zizmor-blocking |
false |
When true, zizmor Actions-security findings fail the workflow. Advisory by default because zizmor's defaults light up existing repos. |
extra-rules-dir |
".security-rules" |
Consumer-supplied semgrep rules, added as an extra --config. Self-skips when the directory is absent. |
rules-ref |
"v1" |
Ref of this repo to source the cc-* rules from. Must match the ref you consume the workflow at — a reusable workflow cannot determine its own ref, so consuming @v2 or a SHA without setting this gets you v1 rules. |
ignored-paths |
"" |
Paths semgrep must skip, e.g. deliberately-vulnerable fixtures. |
Both are optional; the jobs that use them self-skip or degrade when they are unset.
| secret | purpose |
|---|---|
private-git-ssh-key |
Deploy key for cloning private git dependencies while building the classpath. Only clj-kondo and clj-watson use it, and they skip SSH setup when it is unset. |
gh-api-token |
API token for zizmor's online audits, which resolve every repository a workflow references with uses:. Defaults to GITHUB_TOKEN, which reaches only the repository being scanned — so if a workflow calls a private reusable workflow, zizmor cannot resolve it and the job degrades to offline audits with a warning. Supply a token that can read every referenced repository to keep the online audits running. |
Scanner rows below are generated from rule metadata by
bin/gen-coverage-matrix.sh and checked in CI, so the table cannot claim a rule
that no longer exists. The manual-review rows — access control, insecure design,
and everything else no scanner reaches — live in the clojure-security plugin's
class index, because those two halves are maintained in different repos and each
is authoritative for its own.
| rule | class | CWE | OWASP 2025 | blocking |
|---|---|---|---|---|
cc-cljs-eval |
cljs-dom-xss |
94 | A05 | yes |
cc-cljs-innerhtml |
cljs-dom-xss |
79 | A05 | yes |
cc-clojure-xml-xxe |
xxe |
611 | A02 | no (triage) |
cc-dangerously-set-html |
cljs-dom-xss |
79 | A05 | yes |
cc-explain-data-response |
spec-malli-leak |
209 | A10 | yes |
cc-generic-catch |
fail-open |
636, 396 | A10 | no (triage) |
cc-hiccup-raw |
hiccup-injection |
79 | A05 | yes |
cc-insecure-tls |
insecure-tls-verification |
295 | A07 | yes |
cc-load-string |
dynamic-eval |
94 | A05 | yes |
cc-nippy-thaw |
java-deserialization |
502 | A08 | yes |
cc-path-traversal |
path-traversal |
22 | A01 | no (triage) |
cc-read-string |
read-string-rce |
94 | A05 | yes |
cc-shell-exec |
command-injection |
78, 77 | A05 | yes |
cc-snakeyaml-unsafe |
java-deserialization |
502 | A08 | yes |
cc-sql-string-concat |
sql-injection |
89 | A05 | yes |
cc-weak-crypto |
weak-crypto |
327, 328 | A04 | yes |
A coverage table that overstates is worse than none, so:
- No taint analysis anywhere. Every scanned row is pattern matching. Neither semgrep OSS nor clj-holmes tracks dataflow, so a sink reached by an unusual path is missed. The table says "we look for this shape," not "we would catch this bug."
- semgrep cannot resolve namespace aliases. Each rule enumerates the aliases
it expects (
hu/,html/,hiccup.util/, …). An unusual alias is a silent miss.spec-fixtures/exercises more than one alias per sink andbin/test-rules.shfails if any stops matching, so the enumeration is test-guarded rather than aspirational — but it is still enumeration. cc-weak-cryptoandcc-insecure-tlsmatch algorithm names textually (pattern-regex), because semgrep does not bind metavariables inside Clojure string literals. They can therefore fire inside a comment or an unrelated string. Weaker than the structural rules, but the thing being checked is a literal.- OWASP A06 Insecure Design is uncovered. It is a threat-modeling category.
- 10 of 19 applicable CWE Top 25 entries depend on a manual
/security-auditrun — access control above all. CI cannot invoke it. Expected cadence is once per release; nothing enforces that. cc-path-traversal,cc-generic-catch, andcc-clojure-xml-xxedo not block (severity: WARNING). Without dataflow they cannot be precise enough to gate a build —cc-clojure-xml-xxein particular fires on every XML parse because verifying that the factory was hardened requires tracking the object.- Rules track
rules-ref, defaultv1. Consuming another ref without setting it gets v1 rules.
Scans full history and honors a repo-local .gitleaksignore. Generate a baseline
per repo to suppress the pre-existing backlog (accepted risk); newly introduced
secrets fail CI.
Consumers pin @v1, a moving tag pointing at the latest good release. Retag v1
to publish an update to all consumers at once. Third-party actions inside the
workflow are SHA-pinned.
Release policy for libraries published to Clojars. It gates a publish on the commit's CI
result, keeps deploy to CI, verifies the published bytes, and tags only after all of that
succeeds. Nothing in it is specific to any one library — a consumer supplies its own group,
artifact name, and CI workflows as data.
Onboarding takes three things: the deps.edn alias below, a release.yml, and a clojars
environment. The alias alone gets you working local commands but no way to release — the
environment is what authorizes one.
Already onboarded? Bumping the pinned :git/sha is safe and changes nothing about how
you release. See upgrading.
A single-artifact library needs no build script — declare what it is as data:
;; deps.edn
:mvn/repos {"central" {:url "https://repo1.maven.org/maven2/"}
"clojars" {:url "https://repo.clojars.org/"}}
:build {:extra-deps {io.github.cleancoders/github-actions
{:git/sha "<full 40-char sha>" :deps/root "clj"}}
:ns-default cleancoders.build.api
:exec-args {:group "com.example"
:lib-name "mylib"
:repo "example/mylib"
:ci-workflow ["test.yml" "security.yml"]
:license-url "https://github.com/example/mylib/blob/master/LICENSE"}}That gives you clj -T:build clean, pom, jar, install, deploy, and
emergency-publish. tools.build and pomegranate arrive transitively.
Pin a full :git/sha, never the moving v1 tag. v1 moves so the reusable workflows
can be consumed that way; pointing release logic at a moving ref would let a change here
silently alter how every consuming library publishes.
Declare :mvn/repos explicitly. Left implicit, both Central and Clojars are live
resolution sources anyway; writing them down makes the set auditable and stops a transitive
dep from quietly adding a third. That is a mitigation, not a fix — deps.edn pins versions,
not digests, and tools.deps has no lockfile with hashes, so nothing cryptographically
constrains what those coordinates resolve to at build time. What the release can provide
is after-the-fact detection; see the SBOM.
:exec-args key |
Required | Default | Notes |
|---|---|---|---|
:group |
yes | — | |
:lib-name |
yes | — | |
:repo |
yes | — | |
:ci-workflow |
yes | — | one workflow filename, or a vector of them; all must be green |
:license-url |
yes | — | |
:version-file |
no | VERSION |
|
:emergency-var |
no | EMERGENCY_RELEASE |
|
:sign |
no | false |
GPG-sign the jar, pom, SBOM, and the release tag — signing |
:sbom |
no | false |
generate and publish a CycloneDX SBOM — the SBOM |
:repo-url |
no | Clojars | redirect uploads and verification elsewhere — staging rehearsal |
Missing or blank required keys abort before anything is built. So does an unrecognized key,
so a typo in an optional one is loud rather than silently ignored — :sbomb true aborts
rather than reading as "SBOM off".
:sign and :sbom are off by default, and deliberately so. Both arrived after the first
repositories onboarded, and both cost a consumer something to turn on: signing needs GPG
secrets on the release environment, and an SBOM hashes the whole resolved dependency closure
on every build. A consumer pins this library by sha, so defaulting them on would mean
bumping that sha for an unrelated fix could break a release.
What you get with no configuration at all: a reproducible jar, a CI gate on every named workflow, post-publish digest verification against Clojars, a digest record in the job summary and the release tag, and a refusal to release a version that is already tagged.
Attestations, not signatures, are the recommended provenance mechanism here. They need
no key, no secret, and nothing for a consumer to look up — see
verifying a release. :sign remains available and tested for
repos that need it, notably anything publishing to Maven Central.
| Doc | What it covers |
|---|---|
| Releasing | Cutting a release, the release.yml template, the clojars environment, emergency releases |
| Signing | What a signature proves, generating and exporting the key, installing the secrets, rotation |
| The SBOM | What an SBOM is and why publish one, what ours contains, why it is deterministic |
| Verifying a release | Consumer-side verification; and what to do when a release's own verification fails |
| Custom builds | The escape hatch: multi-artifact repos, the thunk contract, the signature-upload trap |
| Upgrading | Bumping the pinned sha, and turning on each opt-in feature in the right order |
| Staging rehearsal | Running the whole release path against a throwaway repository, then deleting every trace |