Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .clj-kondo/config.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
;; Root clj-kondo config for the github-actions repo.
;;
;; spec-fixtures/ holds deliberately-vulnerable Clojure used as the detection
;; corpus for the cc-* clj-holmes rules (bin/test-rules.sh). Linting it is
;; wrong by construction: the files exist precisely because they are bad code,
;; they are never loaded or compiled, and their namespaces intentionally do not
;; correspond to a source root. Excluding them here is the lint-side twin of
;; the `holmes-ignored-paths` input that keeps them out of security.yml's own
;; scan of this repo.
;;
;; The release library under clj/ carries its own .clj-kondo/config.edn; this
;; file does not apply to it.
{:output {:exclude-files ["spec-fixtures/"]}}
298 changes: 270 additions & 28 deletions .github/workflows/security.yml

Large diffs are not rendered by default.

67 changes: 60 additions & 7 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,66 @@ on:
pull_request: {}
push:
branches: [ master ]
# Least privilege by default. Reusable workflows called from here declare their
# own permissions; these apply to the jobs defined in this file. zizmor flags
# the absence as excessive-permissions.
permissions:
contents: read

jobs:
security:
uses: ./.github/workflows/security.yml
# no inputs: exercises defaults. This repo has no src/, bin/, or deps.edn,
# so clj-kondo, shellcheck, and clj-watson must SKIP gracefully
# (portability guard test); gitleaks / clj-holmes / semgrep run against the
# repo tree.
with:
# Without this the rules checkout would fetch v1 from GitHub and a PR
# would be validated against RELEASED rules instead of its own.
rules-ref: ${{ github.sha }}
# spec-fixtures/ holds deliberate vulnerabilities used as the detection
# corpus; scanning them would fail this repo's own build.
ignored-paths: "spec-fixtures"
# This repo has no src/ or deps.edn, so clj-kondo and clj-watson exercise
# their skip paths here for real. bin/ exists (the rule tooling), so
# shellcheck runs. The skip predicates themselves are covered by
# bin/test-detect.sh in rule-tests, not by a duplicate workflow invocation.
# No secrets passed: security.yml requires none (GITHUB_TOKEN is auto-
# provided), so `secrets: inherit` would violate least privilege.

rule-tests:
# Guards the cc-* detection rules themselves. Without this a rule could stop
# matching and still appear in the README coverage matrix — the exact false
# confidence this repo exists to prevent.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install semgrep
shell: bash
run: |
set -euo pipefail
python3 -m pip install --quiet semgrep
- name: Install yq
# check-rule-tags.sh and gen-coverage-matrix.sh both parse rule YAML.
# jq ships on the runner image; yq does not reliably, so pin it here
# rather than depending on whatever the image happens to carry.
shell: bash
run: |
set -euo pipefail
VER=4.44.3
sudo curl -fsSL "https://github.com/mikefarah/yq/releases/download/v${VER}/yq_linux_amd64" \
-o /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
yq --version
- name: Check rule tags
run: bash bin/check-rule-tags.sh
- name: Run detect tests
# Replaces the old security-skips job, which duplicated all nine scanner
# jobs to exercise two conditionals. 17 cases, milliseconds.
run: bash bin/test-detect.sh
- name: Run rule fixture tests
run: bash bin/test-rules.sh
- name: Check coverage table is current
run: bash bin/gen-coverage-matrix.sh --check

clj-lib:
# Guards the release library the c3kit repos consume as a git dep. Note the
# working-directory: the library lives under clj/ so this repo keeps no
Expand All @@ -22,12 +73,14 @@ jobs:
run:
working-directory: clj
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
with:
java-version: 21
distribution: 'temurin'
- uses: DeLaGuardo/setup-clojure@3fe9b3ae632c6758d0b7757b0838606ef4287b08 # 13.4
- uses: DeLaGuardo/setup-clojure@4c7a6f613e5089821bb3bb2a33a3ee115578580d # v13.6.1 (node24)
with:
cli: 'latest'
- name: Install clj-kondo
Expand Down
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ Shared reusable GitHub Actions workflows for cleancoders repos.

## `security.yml` — reusable security-scan workflow

Runs six scanners. **Hard-fail** (block the caller): `clj-kondo`, `clj-holmes`,
`shellcheck`, `gitleaks`. **Advisory by default** (report, never block):
`clj-watson`, `semgrep` — each can be made blocking per-consumer via the
`clj-watson-blocking` / `semgrep-blocking` inputs.
Runs eight scanners. **Hard-fail** (block the caller): `clj-kondo`, `clj-holmes`,
`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 primary Clojure detection engine.** It carries the 12
cleancoders `cc-*` rules in `security-rules/semgrep/` and reads `.clj`, `.cljs`,
and `.cljc`. `clj-holmes` runs upstream rules only — it covers weak crypto,
XXE, and `read-string`, but reads **only `.clj`**: it silently skips `.cljs` and
`.cljc`, and its parser rejects reader conditionals. Upstream has been
unmaintained since October 2022. That split is deliberate; see
`docs/superpowers/specs/2026-07-27-cwe-owasp-coverage-design.md` (Revision 2).

### Usage

Expand Down Expand Up @@ -35,6 +44,63 @@ jobs:
| `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. |
| `holmes-upstream-ref` | `"git://clj-holmes/clj-holmes-rules#main"` | Upstream clj-holmes rules source. Override to pin a SHA. |
| `ignored-paths` | `""` | Paths both clj-holmes and semgrep must skip, e.g. deliberately-vulnerable fixtures. |

### Coverage

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.

<!-- BEGIN COVERAGE -->

| 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-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-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-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 |

<!-- END COVERAGE -->

#### What this coverage does not claim

A coverage table that overstates is worse than none, so:

1. **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."
2. **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 and
`bin/test-rules.sh` fails if any stops matching, so the enumeration is
test-guarded rather than aspirational — but it is still enumeration.
3. **clj-holmes rules apply to `.clj` only** — not `.cljs`, not `.cljc`. That
covers the crypto, XXE, and `read-string` rows.
4. **OWASP A06 Insecure Design is uncovered.** It is a threat-modeling category.
5. **10 of 19 applicable CWE Top 25 entries depend on a manual
`/security-audit` run** — access control above all. CI cannot invoke it.
Expected cadence is once per release; nothing enforces that.
6. **`cc-path-traversal` and `cc-generic-catch` do not block** (`severity:
WARNING`). Without dataflow they cannot be precise enough to gate a build.
7. **Rules track `rules-ref`, default `v1`.** Consuming another ref without
setting it gets v1 rules.

### gitleaks

Expand Down
59 changes: 59 additions & 0 deletions bin/check-rule-tags.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Every cleancoders rule must carry metadata.cwe, metadata.owasp, and
# metadata.class. The coverage matrix in README.md is generated from these, so an
# untagged rule is a detection that exists but is invisible to the evidence trail.
#
# Also enforces the alias mitigation: semgrep cannot resolve namespace aliases, so
# a rule that matches a namespaced function must enumerate its aliases. Rules that
# opt out declare metadata.alias-exempt with a reason (special forms and interop
# like js/eval or .-innerHTML cannot be aliased).
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RULES="${ROOT}/security-rules/semgrep"

command -v yq >/dev/null || { echo "yq not installed"; exit 1; }

status=0
for f in "${RULES}"/*.yaml; do
id="$(yq -r '.rules[0].id' "${f}")"

case "${id}" in
cc-*) ;;
*) echo "${f}: rule id '${id}' must be prefixed cc-"; status=1 ;;
esac

[ "$(yq -r '.rules[0].languages | contains(["clojure"])' "${f}")" = "true" ] \
|| { echo "${f}: must declare languages: [clojure]"; status=1; }

# Capture first, then match. `yq ... | grep -q` breaks under `set -o pipefail`:
# grep -q exits on the first match and closes the pipe, yq takes SIGPIPE, and
# pipefail reports the whole pipeline as failed. Only shows up on rules with
# more than one CWE entry, which makes it a nasty intermittent.
cwes="$(yq -r '.rules[0].metadata.cwe[]' "${f}" 2>/dev/null || true)"
echo "${cwes}" | grep -qE '^CWE-[0-9]+' \
|| { echo "${f}: metadata.cwe must list at least one 'CWE-<n>: ...' entry"; status=1; }

owasp_count="$(yq -r '.rules[0].metadata.owasp[]' "${f}" 2>/dev/null \
| grep -cE '^A(0[1-9]|10):2025' || true)"
[ "${owasp_count}" -eq 1 ] \
|| { echo "${f}: needs exactly one 'A<NN>:2025 - ...' owasp entry (found ${owasp_count})"; status=1; }

cls="$(yq -r '.rules[0].metadata.class // ""' "${f}")"
echo "${cls}" | grep -qE '^[a-z0-9-]+$' \
|| { echo "${f}: metadata.class must name a clojure-security class"; status=1; }

exempt="$(yq -r '.rules[0].metadata.alias-exempt // ""' "${f}")"
if [ -z "${exempt}" ]; then
# A rule matching a namespaced fn must enumerate aliases, because semgrep
# cannot resolve them. Count distinct "(prefix/" tokens in the pattern lines;
# fewer than two means the rule only matches one spelling of the sink.
prefixes="$(grep -E '^\s+- pattern' -A0 "${f}" \
| grep -oE '\(([a-zA-Z0-9._-]+)/' | sort -u | wc -l | tr -d ' ')"
[ "${prefixes}" -ge 2 ] \
|| { echo "${f}: enumerate at least 2 namespace aliases, or set metadata.alias-exempt with a reason"; status=1; }
fi
done

[ "${status}" -eq 0 ] && echo "all rules tagged"
exit "${status}"
65 changes: 65 additions & 0 deletions bin/detect.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Self-skip predicates for security.yml, in one tested place.
#
# Every scanner job must skip cleanly on a repo that lacks the thing it scans —
# a library with no bin/, a repo with no deps.edn. That logic used to be an
# inline `if [ -d ... ]` duplicated across jobs, which meant it could not be
# tested and drifted between copies. The `detect` job runs this once and exposes
# the answers as job outputs.
#
# Usage:
# detect.sh dir-has-files <dir> exit 0 if dir exists and holds >=1 file
# detect.sh existing-dirs <paths...> print the subset of paths that are dirs
# detect.sh outputs emit key=value lines for GITHUB_OUTPUT
#
# `outputs` reads SHELLCHECK_DIR, EXTRA_RULES_DIR, and SRC_PATHS from the env so
# the workflow passes inputs in one place.
set -euo pipefail

dir_has_files() {
local d="${1:-}"
[ -n "$d" ] || return 1
[ -d "$d" ] || return 1
[ -n "$(find "$d" -type f 2>/dev/null | head -1)" ]
}

# Filters a space-separated list down to paths that exist as directories. Used
# for src-paths, where the default names three roots and most repos have one.
existing_dirs() {
local out=()
for p in "$@"; do
[ -d "$p" ] && out+=("$p")
done
[ ${#out[@]} -gt 0 ] && printf '%s\n' "${out[@]}"
return 0
}

emit_outputs() {
local sc="${SHELLCHECK_DIR:-}" extra="${EXTRA_RULES_DIR:-}" src="${SRC_PATHS:-}"

if dir_has_files "$sc"; then echo "has-shellcheck-target=true"
else echo "has-shellcheck-target=false"; fi

if dir_has_files ".github/workflows"; then echo "has-workflows=true"
else echo "has-workflows=false"; fi

if [ -n "$extra" ] && [ -d "$extra" ]; then echo "has-extra-rules=true"
else echo "has-extra-rules=false"; fi

if [ -f deps.edn ]; then echo "has-deps-edn=true"
else echo "has-deps-edn=false"; fi

# shellcheck disable=SC2086 # deliberate word-splitting: src is a path list
local dirs
dirs="$(existing_dirs $src | paste -sd' ' - || true)"
echo "src-dirs=${dirs}"
if [ -n "$dirs" ]; then echo "has-src-dirs=true"; else echo "has-src-dirs=false"; fi
}

case "${1:-}" in
dir-has-files) shift; dir_has_files "$@" ;;
existing-dirs) shift; existing_dirs "$@" ;;
outputs) emit_outputs ;;
*) echo "usage: detect.sh {dir-has-files <dir>|existing-dirs <paths...>|outputs}" >&2
exit 2 ;;
esac
71 changes: 71 additions & 0 deletions bin/gen-coverage-matrix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Emits the scanner rows of the coverage matrix from rule metadata. Hand-
# maintaining this table is how a coverage doc starts lying: a rule gets renamed
# or deleted and the table keeps claiming it, which is worse than no table once
# it reaches an auditor. --check fails CI when the committed table no longer
# matches the rules on disk.
#
# gen-coverage-matrix.sh rewrite the table in README.md
# gen-coverage-matrix.sh --emit print the table to stdout
# gen-coverage-matrix.sh --check exit 1 if README.md is stale
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RULES="${ROOT}/security-rules/semgrep"
README="${ROOT}/README.md"
BEGIN='<!-- BEGIN COVERAGE -->'
END='<!-- END COVERAGE -->'

command -v yq >/dev/null || { echo "yq not installed"; exit 1; }

generate() {
echo "${BEGIN}"
echo
echo "| rule | class | CWE | OWASP 2025 | blocking |"
echo "|------|-------|-----|------------|----------|"
for f in "${RULES}"/*.yaml; do
id="$(yq -r '.rules[0].id' "${f}")"
cls="$(yq -r '.rules[0].metadata.class' "${f}")"
sev="$(yq -r '.rules[0].severity' "${f}")"
# "CWE-79: Improper ..." -> "79"; join multiples with ", "
cwe="$(yq -r '.rules[0].metadata.cwe[]' "${f}" \
| sed -E 's/^CWE-([0-9]+).*/\1/' | paste -sd', ' - | sed 's/,/, /g')"
# "A05:2025 - Injection" -> "A05"
owasp="$(yq -r '.rules[0].metadata.owasp[0]' "${f}" | sed -E 's/^(A[0-9]+):.*/\1/')"
# WARNING rules are triage-only by design and must not gate a build.
if [ "${sev}" = "WARNING" ]; then blocking="no (triage)"; else blocking="yes"; fi
echo "| \`${id}\` | \`${cls}\` | ${cwe} | ${owasp} | ${blocking} |"
done
echo
echo "${END}"
}

case "${1:-}" in
--emit)
generate
;;
--check)
current="$(sed -n "/${BEGIN}/,/${END}/p" "${README}")"
if [ "${current}" != "$(generate)" ]; then
echo "README coverage table is stale. Run: bash bin/gen-coverage-matrix.sh"
diff <(echo "${current}") <(generate) || true
exit 1
fi
echo "coverage table current"
;;
*)
tmp="$(mktemp)"
generate > "${tmp}"
python3 - "${README}" "${tmp}" <<'PY'
import re, sys
readme, table = sys.argv[1], sys.argv[2]
body = open(table).read().strip()
src = open(readme).read()
new = re.sub(r"<!-- BEGIN COVERAGE -->.*?<!-- END COVERAGE -->", lambda _: body,
src, flags=re.S)
open(readme, "w").write(new)
PY
rm -f "${tmp}"
echo "README coverage table regenerated"
;;
esac
Loading
Loading