diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 0000000..f60e3b5 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,49 @@ +name: DCO +on: + pull_request: + branches: [main] +permissions: + contents: read + pull-requests: read +jobs: + dco: + runs-on: ubuntu-24.04 + steps: + - name: Verify Signed-off-by on every commit + env: + GH_TOKEN: ${{ github.token }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_COMMITS: ${{ github.event.pull_request.commits }} + run: | + set -eu + case "$PR_COMMITS" in + ''|*[!0-9]*) echo "::error::the PR commit count is missing or not a number; cannot verify every commit"; exit 1;; + esac + missing=0 + checked=0 + # Page through the PR commits and check each message for the trailer. + page=1 + while :; do + batch="$(gh api "${COMMITS_URL}?per_page=100&page=${page}" \ + --jq '.[] | @base64')" + [ -z "$batch" ] && break + for row in $batch; do + msg="$(printf '%s' "$row" | base64 -d | \ + python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["commit"]["message"]);print(d["sha"])')" + sha="$(printf '%s\n' "$msg" | tail -n1)" + checked=$((checked+1)) + body="$(printf '%s\n' "$msg" | sed '$d')" + if ! printf '%s\n' "$body" | grep -qiE '^Signed-off-by: .+ <.+@.+>'; then + echo "::error::commit ${sha} missing Signed-off-by trailer (run: git commit -s)" + missing=1 + fi + done + page=$((page+1)) + done + # The commits API lists at most 250 commits. Fail closed when it returned fewer + # than the PR holds, so an unchecked commit cannot pass. + if [ "$checked" -ne "$PR_COMMITS" ]; then + echo "::error::checked ${checked} of ${PR_COMMITS} PR commits; the commits API cannot list them all. Split the PR." + missing=1 + fi + [ "$missing" -eq 0 ] || exit 1 diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml new file mode 100644 index 0000000..a49e5d3 --- /dev/null +++ b/.github/workflows/guard.yml @@ -0,0 +1,173 @@ +name: guard +on: + pull_request: + branches: [main] + # edited: a description edit re-runs the issue-link check (#838). + types: [opened, synchronize, reopened, edited] + push: + branches: [main] +permissions: + contents: read +concurrency: + group: guard-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + gitleaks: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 — full history for the PR commit range + with: + fetch-depth: 0 + persist-credentials: false + - name: Resolve config from BASE (never trust the PR-controlled .gitleaks.toml) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + if [ -n "${BASE_SHA:-}" ]; then + # pull_request: scan with the base branch's config, not the PR's + git show "${BASE_SHA}:.gitleaks.toml" > /tmp/gitleaks.toml + else + # push to main: the checked-out file IS the trusted config + cp .gitleaks.toml /tmp/gitleaks.toml + fi + - name: gitleaks (pinned binary + sha256) + env: + GL_VERSION: "8.21.2" + GL_SHA256: "5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba" + run: | + set -eu + curl -fsSLo /tmp/gl.tgz "https://github.com/gitleaks/gitleaks/releases/download/v${GL_VERSION}/gitleaks_${GL_VERSION}_linux_x64.tar.gz" + echo "${GL_SHA256} /tmp/gl.tgz" | sha256sum -c - + tar -xzf /tmp/gl.tgz -C /tmp gitleaks + /tmp/gitleaks detect --source . --no-git --config /tmp/gitleaks.toml --redact --no-banner --exit-code 1 + - name: Sanity-lint the PR's proposed .gitleaks.toml (non-blocking on content) + if: github.event_name == 'pull_request' + run: /tmp/gitleaks detect --no-git --source /dev/null --config .gitleaks.toml >/dev/null 2>&1 || echo "::warning::PR .gitleaks.toml changed — review the allowlist diff (CODEOWNERS-gated)" + + commitlint: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + if: github.event_name == 'pull_request' + steps: + - name: Conventional Commits check (inline, PR commits API) + env: + GH_TOKEN: ${{ github.token }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + PR_COMMITS: ${{ github.event.pull_request.commits }} + run: | + set -eu + case "$PR_COMMITS" in + ''|*[!0-9]*) echo "::error::the PR commit count is missing or not a number; cannot verify every commit"; exit 1;; + esac + bad=0 + checked=0 + re='^(feat|fix|docs|chore|refactor|test|build|ci|perf|revert|style)(\([a-z0-9._-]+\))?!?: .+' + page=1 + while :; do + batch="$(gh api "${COMMITS_URL}?per_page=100&page=${page}" --jq '.[] | @base64')" + [ -z "$batch" ] && break + for row in $batch; do + msg="$(printf '%s' "$row" | base64 -d | python3 -c 'import sys,json;print(json.load(sys.stdin)["commit"]["message"])')" + sha="$(printf '%s' "$row" | base64 -d | python3 -c 'import sys,json;print(json.load(sys.stdin)["sha"][:7])')" + subj="$(printf '%s\n' "$msg" | head -n 1)" + checked=$((checked+1)) + # Any commit, merge commits included, may carry a session link (#838). + # Name the commit by SHA only: the log is public, so it must not echo + # a message that holds the link. The match is case-insensitive because + # hostnames are; it targets accidental links, not deliberate obfuscation. + if printf '%s\n' "$msg" | grep -qiE 'claude\.ai/code/session'; then + echo "::error::commit ${sha} links to an agent session; remove the link from its message" + bad=1 + continue + fi + case "$subj" in "Merge "*) continue;; esac + if ! printf '%s' "$subj" | grep -qE "$re"; then + echo "::error::commit ${sha} has a non-conventional subject; use type(scope): summary" + bad=1 + fi + done + page=$((page+1)) + done + # The commits API lists at most 250 commits. Fail closed when it returned fewer + # than the PR holds, so an unchecked commit cannot pass. + if [ "$checked" -ne "$PR_COMMITS" ]; then + echo "::error::checked ${checked} of ${PR_COMMITS} PR commits; the commits API cannot list them all. Split the PR." + bad=1 + fi + [ "$bad" -eq 0 ] || exit 1 + + trivy: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: trivy fs — vuln only (pinned binary + sha256) + env: + TRIVY_VERSION: "0.71.0" + TRIVY_SHA256: "30a3d22b23f88c233f1658f562fb477cae3b3e8b4761109d515b7698daf85814" + run: | + set -eu + curl -fsSLo /tmp/trivy.tgz "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" + echo "${TRIVY_SHA256} /tmp/trivy.tgz" | sha256sum -c - + tar -xzf /tmp/trivy.tgz -C /tmp trivy + /tmp/trivy fs --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --no-progress . + + actionlint: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: actionlint (pinned binary + sha256) + env: + AL_VERSION: "1.7.7" + AL_SHA256: "023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" + run: | + set -eu + curl -fsSLo /tmp/al.tgz "https://github.com/rhysd/actionlint/releases/download/v${AL_VERSION}/actionlint_${AL_VERSION}_linux_amd64.tar.gz" + echo "${AL_SHA256} /tmp/al.tgz" | sha256sum -c - + tar -xzf /tmp/al.tgz -C /tmp actionlint + /tmp/actionlint -color + + issue-link: + # Working-model gate (umbrella issue dmfdeploy/dmfdeploy#32): every PR must + # reference its tracking issue in the canonical backlog FULLY QUALIFIED — + # bare #N resolves to this repo's own issues and silently misses. Trivial + # changes: add the no-issue label. + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: PR references a canonical-backlog issue (qualified) + env: + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} + run: | + set -eu + # A link to a private agent session does not belong in a public PR (#838). + # This runs before the no-issue exit, so the label cannot skip it. The match + # is case-insensitive because hostnames are; it targets accidental links, + # not deliberate obfuscation. + if printf '%s' "${PR_BODY}" | grep -qiE 'claude\.ai/code/session'; then + echo "FAIL: PR description links to an agent session; remove the link." >&2 + exit 1 + fi + case " ${PR_LABELS} " in *" no-issue "*) + echo "OK: no-issue label set"; exit 0 ;; + esac + if printf '%s' "${PR_BODY}" | grep -qiE '(close[sd]?|fix(e[sd])?|resolve[sd]?|refs?)[[:space:]:]+(dmfdeploy/dmfdeploy#[0-9]+|https://github\.com/dmfdeploy/dmfdeploy/issues/[0-9]+)'; then + echo "OK: qualified issue reference found" + exit 0 + fi + echo "FAIL: PR body must reference its tracking issue FULLY QUALIFIED, e.g. 'Refs dmfdeploy/dmfdeploy#32'." >&2 + echo "Bare #N points at THIS repo, not the canonical backlog — see umbrella docs/WORKING-MODEL.md." >&2 + echo "Trivial change? Add the 'no-issue' label." >&2 + exit 1 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b31af8b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,67 @@ +# dmfdeploy platform — sub-repo gitleaks config +# +# Mirrors the public-safe subset of the umbrella's .gitleaks.toml so each +# public repo's local pre-commit catches the same leak categories: +# - generic-api-key (default), allowlisted under docs/ +# - dmf-dev-changeme: known placeholder credential +# - dmf-private-network-literal: concrete private or mesh IPs +# - dmf-macos-metadata: macOS metadata leaks +# +# Public repos intentionally do not encode operator-specific identity +# strings in this file. Those remain in the private umbrella scrub gate. +# +# Add new exceptions only with a comment explaining why and a path +# anchored as narrowly as possible. + +[extend] +useDefault = true + +# Global allowlist for gitignored local working-tree noise. +[allowlist] +description = "local working-tree noise (gitignored)" +paths = [ + '''^\.venv/''', + '''^node_modules/''', + '''^\.terraform/''', + '''^\.claude/settings\.local\.json$''', +] + +# Per-rule allowlist for the default generic-api-key rule. +[[rules]] +id = "generic-api-key" +[[rules.allowlists]] +description = "docs/ prose references to secret/token concepts" +paths = [ + '''^docs/''' +] + +# Catch a known placeholder credential if it appears outside documentation. +[[rules]] +id = "dmf-dev-changeme" +description = "Known placeholder credential" +regex = '''dev[:]changeme''' +tags = ["placeholder-cred", "dmf"] + +# Catch concrete private-network details in public-facing repos. Example +# addresses belong in placeholder form (``) or under the +# private environment repo, not here. +[[rules]] +id = "dmf-private-network-literal" +description = "Concrete private or mesh IP address" +regex = '''\b(10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|100\.(6[4-9]|[78][0-9]|9[0-9]|10[0-9]|11[0-9]|12[0-7])\.[0-9]{1,3}\.[0-9]{1,3})\b''' +tags = ["topology", "dmf"] + +# Catch macOS metadata leaking into the tracked tree. +[[rules]] +id = "dmf-macos-metadata" +description = "macOS metadata file in tracked tree" +regex = '''\.DS[_]Store''' +tags = ["operator-identity", "dmf"] +[[rules.allowlists]] +description = "files that intentionally name the artifact" +paths = [ + '''^\.gitleaks\.toml$''', + '''(^|/)\.gitignore$''', + '''(^|/)\.helmignore$''', + '''(^|/)docs/DEVELOPMENT-AND-BUILD-RULES\.md$''', +]