diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 0480e24..1d3445e 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -41,3 +41,7 @@ paths: ignore: # Same gap again: it checks out auto-tag/ at job.workflow_sha. - 'property "workflow_sha" is not defined in object type' + .github/workflows/semgrep-scan.yml: + ignore: + # Same gap again: it checks out semgrep/ at job.workflow_sha. + - 'property "workflow_sha" is not defined in object type' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d9c3a41..7575d60 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,3 +26,11 @@ updates: # of ~30 repos is the same reason `cooldown` is omitted above — bumps # here should not wait. See #127. open-pull-requests-limit: 10 + # Semgrep pin (semgrep/requirements.txt). No cooldown, as above. Callers + # get a bump only when the semgrep-scan-v1 tag moves. dev-env#166. + - package-ecosystem: "pip" + directory: "/semgrep" + schedule: + interval: "weekly" + commit-message: + prefix: "chore" diff --git a/.github/workflows/semgrep-scan.yml b/.github/workflows/semgrep-scan.yml new file mode 100644 index 0000000..a0071b6 --- /dev/null +++ b/.github/workflows/semgrep-scan.yml @@ -0,0 +1,102 @@ +name: Semgrep Scan + +# Semgrep CE, in place of CodeQL for private repos without GitHub Code +# Security (dev-env#166). Details: README.md. + +# pull_request fails only on new findings (--baseline-commit). push and +# dispatch scan all. With SEMGREP_APP_TOKEN: `semgrep ci`. + +# Caller stub: semgrep/caller-stub.yml. The caller grants contents: read +# and passes SEMGREP_APP_TOKEN by name, if it has one. + +# No SARIF upload: code scanning on a private repo needs the GitHub Code +# Security SKU, which is what this workflow exists to avoid. + +on: + workflow_call: + inputs: + rulesets: + description: >- + Semgrep configs, separated by spaces or newlines, each passed as + --config. Ignored when SEMGREP_APP_TOKEN is set. + type: string + default: p/default p/javascript p/typescript p/python p/ruby + timeout-minutes: + description: Job timeout. + type: number + default: 20 + secrets: + SEMGREP_APP_TOKEN: + description: >- + Optional Semgrep platform token. When set, the scan runs as + `semgrep ci` and the platform's policies choose the rules. + required: false + +permissions: {} + +jobs: + semgrep: + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: ${{ inputs.timeout-minutes }} + env: + SEMGREP_SEND_METRICS: "off" + steps: + # Full history: --baseline-commit needs the PR base commit locally. + - name: Checkout caller repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + path: repo + + # An empty ref makes checkout take the default branch, so fail instead. + # Same step as standards-check.yml. + - name: Resolve the workflow SHA + id: workflow_sha + env: + JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + set -euo pipefail + if [ -z "${JOB_WORKFLOW_SHA}" ]; then + echo "::error::job.workflow_sha is empty; refusing to check out an ambiguous ref" + exit 2 + fi + echo "sha=${JOB_WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}" + echo "workflow SHA: ${JOB_WORKFLOW_SHA}" + + - name: Checkout semgrep scripts at the called-workflow SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: smartwatermelon/github-workflows + ref: ${{ steps.workflow_sha.outputs.sha }} + persist-credentials: false + fetch-depth: 1 + path: semgrep-src + sparse-checkout: | + semgrep + + # A venv: the runner's Python refuses a global pip install (PEP 668). + # Dependabot bumps the pin in semgrep/requirements.txt. + - name: Install Semgrep + env: + VENV: ${{ runner.temp }}/semgrep-venv + run: | + set -euo pipefail + python3 -m venv "${VENV}" + "${VENV}/bin/pip" install --disable-pip-version-check --quiet \ + -r semgrep-src/semgrep/requirements.txt + "${VENV}/bin/semgrep" --version + + - name: Run Semgrep + working-directory: repo + env: + SEMGREP_BIN: ${{ runner.temp }}/semgrep-venv/bin/semgrep + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + RULESETS: ${{ inputs.rulesets }} + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} + run: | + set -euo pipefail + bash ../semgrep-src/semgrep/run-semgrep.sh diff --git a/README.md b/README.md index 9160eb5..f8e94e5 100644 --- a/README.md +++ b/README.md @@ -645,6 +645,110 @@ Prefixed for the same reason as `standards-check-v*`. --- +## `semgrep-scan.yml` + +Reusable static-analysis check with Semgrep Community Edition +([dev-env#166](https://github.com/smartwatermelon/dev-env/issues/166)). It +stands in for CodeQL on the private `nightowlstudiollc` repos, which do not +have GitHub Code Security. The pilot caller is nightowlstudiollc/reliquarist. + +It is **not** a required check. On a pull request it fails only on findings +that are new in that PR, so existing findings in a repo do not turn every PR +red. + +The triggering event and the secret pick the mode: + +| Event | `SEMGREP_APP_TOKEN` | Runs | +| ----- | ------------------- | ---- | +| `pull_request` | absent or empty | `semgrep scan --error --config --baseline-commit ` | +| `push`, `workflow_dispatch` | absent or empty | `semgrep scan --error --config ` (whole tree) | +| any of the three | set | `semgrep ci` | + +Any other event fails with exit 2, `pull_request_target` included. + +`--baseline-commit` makes Semgrep report only findings that are not also +present at the PR base (`github.event.pull_request.base.sha`). The caller is +checked out with `fetch-depth: 0` so that commit is in the history. A run +whose base commit is missing fails with exit 2 instead of scanning the wrong +range. A full scan on `push` or `workflow_dispatch` fails on every finding, +old or new. Fix a repo's existing findings before you add the caller, so +its first `push` run on `main` is green. + +**Token mode.** With `SEMGREP_APP_TOKEN` set, the workflow runs `semgrep ci`, +which reports to the Semgrep platform. The platform's policies then choose +the rules and the `rulesets` input is ignored. No repo uses a token today; +the secret is there so a repo can move to the platform later without a new +workflow. A PR from Dependabot or a fork gets no secrets, so it runs in scan +mode. + +**No code-scanning upload.** The job has only `contents: read`: no +`security-events: write` and no SARIF upload. Code scanning on a private repo +needs the Code Security SKU this workflow exists to avoid. Findings appear in +the job log. + +The mode logic is `semgrep/run-semgrep.sh`, checked out from this repo at +`job.workflow_sha`, as in `netlify-site-checks.yml`. Semgrep is installed +with pip into a venv from `semgrep/requirements.txt`, which pins one version; +Dependabot (`pip`, `/semgrep`) bumps it. `SEMGREP_SEND_METRICS=off` and +`--metrics=off` keep Semgrep from sending usage metrics. Registry rulesets +(`p/...`) need no token. + +### Setup + +`.github/workflows/semgrep-scan.yml` in the caller repo. Keep that file name +and the job name `semgrep-scan`: the daily digest will find callers by them. +The canonical copy is `semgrep/caller-stub.yml`: + +```yaml +name: Semgrep Scan +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + semgrep-scan: + uses: smartwatermelon/github-workflows/.github/workflows/semgrep-scan.yml@semgrep-scan-v1 + secrets: + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} +``` + +- The status check is `semgrep-scan / semgrep` (caller job / inner job). +- Pass the secret by name, not with `secrets: inherit`. With no such secret + in the repo it is empty, and the run uses scan mode. +- To skip paths, add a `.semgrepignore` to the caller repo. + +### Inputs + +| Input | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `rulesets` | string | `p/default p/javascript p/typescript p/python p/ruby` | Semgrep configs, separated by spaces or newlines, each passed as `--config`. Registry names or paths in the caller repo. Ignored in token mode. | +| `timeout-minutes` | number | `20` | Job timeout. | + +| Secret | Required | Description | +| ------ | -------- | ----------- | +| `SEMGREP_APP_TOKEN` | no | Semgrep platform token. When set, the run is `semgrep ci` and the platform's policies replace `rulesets`. | + +### Tests + +`tests/test-semgrep-scan.sh` runs `semgrep/run-semgrep.sh` against a stub +`semgrep` in `tests/stub-semgrep/` and a throwaway git repo. It covers each +mode's arguments, that the token reaches Semgrep only through the +environment, the exit-2 refusals, and that Semgrep's exit code passes +through. It also checks that the `rulesets` default is the same in the +workflow, the caller stub, and this README, and that the stub keeps the +stable job name and `uses:` ref. + +### Versioning + +| Tag | Meaning | +| ----- | --------- | +| `semgrep-scan-v1` | Floating major for callers, moved manually and human-authorized, like `standards-check-v1`. | + +Prefixed for the same reason as `standards-check-v*`. + +--- + ## `claude-assistant` (deprecated) `claude-assistant.yml` is deprecated. No fleet repo uses it except `kebab-tax` diff --git a/semgrep/caller-stub.yml b/semgrep/caller-stub.yml new file mode 100644 index 0000000..8329d23 --- /dev/null +++ b/semgrep/caller-stub.yml @@ -0,0 +1,18 @@ +name: Semgrep Scan +on: + pull_request: + workflow_dispatch: + # A push run scans the whole tree and fails on any finding, old or new. + # Fix existing findings before adding this caller. + push: + branches: [main] +permissions: + contents: read +jobs: + semgrep-scan: + uses: smartwatermelon/github-workflows/.github/workflows/semgrep-scan.yml@semgrep-scan-v1 + # with: + # rulesets: p/default p/javascript p/typescript p/python p/ruby # the default + secrets: + # Optional. Absent or empty: scan mode with the rulesets above. + SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} diff --git a/semgrep/requirements.txt b/semgrep/requirements.txt new file mode 100644 index 0000000..3c776d5 --- /dev/null +++ b/semgrep/requirements.txt @@ -0,0 +1 @@ +semgrep==1.182.0 diff --git a/semgrep/run-semgrep.sh b/semgrep/run-semgrep.sh new file mode 100755 index 0000000..b05ed77 --- /dev/null +++ b/semgrep/run-semgrep.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Run Semgrep for semgrep-scan.yml, from the root of the caller checkout. + +# Env: EVENT_NAME, BASE_SHA (PR only), RULESETS, SEMGREP_APP_TOKEN, +# SEMGREP_BIN. Exit: Semgrep's code (1 = findings), or 2 if misconfigured. + +# With a token: `semgrep ci` (platform rules, not RULESETS). Else +# `semgrep scan --error`, plus --baseline-commit on pull_request. +set -euo pipefail + +semgrep_bin="${SEMGREP_BIN:-semgrep}" +: "${EVENT_NAME:?EVENT_NAME is required}" + +if ! command -v "${semgrep_bin}" >/dev/null 2>&1; then + echo "::error::semgrep not found at '${semgrep_bin}'" >&2 + exit 2 +fi + +case "${EVENT_NAME}" in + pull_request | push | workflow_dispatch) ;; + *) + echo "::error::semgrep-scan runs on pull_request, push, or workflow_dispatch, not '${EVENT_NAME}'" >&2 + exit 2 + ;; +esac + +if [[ -n "${SEMGREP_APP_TOKEN:-}" ]]; then + echo "::notice::SEMGREP_APP_TOKEN is set: running semgrep ci; platform policies replace the rulesets input." >&2 + exec "${semgrep_bin}" ci +fi + +read -r -d '' -a rulesets <<<"${RULESETS:-}" || true +if [[ "${#rulesets[@]}" -eq 0 ]]; then + echo "::error::RULESETS is empty; give at least one Semgrep config" >&2 + exit 2 +fi +args=(scan --error --metrics=off) +for r in "${rulesets[@]}"; do + # A registry name (p/default, r/...) or a path in the caller repo. + if [[ ! "${r}" =~ ^[A-Za-z0-9_.][A-Za-z0-9_./:@-]*$ ]]; then + echo "::error::ruleset '${r}' is not a valid Semgrep config name" >&2 + exit 2 + fi + args+=(--config "${r}") +done + +if [[ "${EVENT_NAME}" == "pull_request" ]]; then + if [[ ! "${BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::pull_request run without a valid BASE_SHA ('${BASE_SHA:-}')" >&2 + exit 2 + fi + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "::error::base commit ${BASE_SHA} is not in this checkout; check out with fetch-depth: 0" >&2 + exit 2 + fi + echo "Diff-aware scan: only findings new since ${BASE_SHA} fail." >&2 + args+=(--baseline-commit "${BASE_SHA}") +else + echo "Full scan (${EVENT_NAME})." >&2 +fi + +exec "${semgrep_bin}" "${args[@]}" diff --git a/tests/stub-semgrep/semgrep b/tests/stub-semgrep/semgrep new file mode 100755 index 0000000..ce9336a --- /dev/null +++ b/tests/stub-semgrep/semgrep @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Stub semgrep for test-semgrep-scan.sh: writes argv to argv.log and +# SEMGREP_APP_TOKEN to token.log, then exits STUB_RC. +set -euo pipefail +: "${STUB_DIR:?}" +printf '%s\n' "$@" >"${STUB_DIR}/argv.log" +printf '%s\n' "${SEMGREP_APP_TOKEN-}" >"${STUB_DIR}/token.log" +exit "${STUB_RC:-0}" diff --git a/tests/test-semgrep-scan.sh b/tests/test-semgrep-scan.sh new file mode 100755 index 0000000..fa3bedf --- /dev/null +++ b/tests/test-semgrep-scan.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Hermetic tests for semgrep/run-semgrep.sh, plus checks that the +# workflow, caller stub, and README.md agree. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script="${here}/../semgrep/run-semgrep.sh" +workflow="${here}/../.github/workflows/semgrep-scan.yml" +stub_file="${here}/../semgrep/caller-stub.yml" +readme="${here}/../README.md" +stub="${here}/stub-semgrep/semgrep" +unset BASH_ENV +# Isolate from the developer's global git config (hooks, signing, identity). +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid +pass=0; fail=0 +tmp_dirs=() +trap 'rm -rf "${tmp_dirs[@]}"' EXIT +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } + +# A throwaway caller repo with two commits; base is the first. +repo="$(mktemp -d)"; tmp_dirs+=("${repo}") +git -C "${repo}" init -q +git -C "${repo}" commit -q --allow-empty -m one +base="$(git -C "${repo}" rev-parse HEAD)" +git -C "${repo}" commit -q --allow-empty -m two +absent="0123456789abcdef0123456789abcdef01234567" + +# run [VAR=value ...]: sets rc, err, argv (the stub's argv, one per line). +run() { + STUB_DIR="$(mktemp -d)"; tmp_dirs+=("${STUB_DIR}") + rc=0 + (cd "${repo}" && env -u SEMGREP_APP_TOKEN STUB_DIR="${STUB_DIR}" SEMGREP_BIN="${stub}" \ + RULESETS="p/default p/python" "$@" bash "${script}") 2>"${STUB_DIR}/err" || rc=$? + err="$(cat "${STUB_DIR}/err")" + argv="$(cat "${STUB_DIR}/argv.log" 2>/dev/null || true)" +} +called() { [[ -f "${STUB_DIR}/argv.log" ]]; } +nl=$'\n' + +# 1. pull_request, no token: diff-aware scan against the base +run EVENT_NAME=pull_request BASE_SHA="${base}" +want="scan${nl}--error${nl}--metrics=off${nl}--config${nl}p/default${nl}--config${nl}p/python${nl}--baseline-commit${nl}${base}" +if [[ "${rc}" -eq 0 && "${argv}" == "${want}" ]]; then _ok "pull_request runs scan --error with --baseline-commit"; else _bad "pull_request: rc=${rc} argv=${argv} err=${err}"; fi + +# 2. push and workflow_dispatch: full scan, no baseline +for ev in push workflow_dispatch; do + run EVENT_NAME="${ev}" BASE_SHA="${base}" + want="scan${nl}--error${nl}--metrics=off${nl}--config${nl}p/default${nl}--config${nl}p/python" + if [[ "${rc}" -eq 0 && "${argv}" == "${want}" ]]; then _ok "${ev} runs a full scan"; else _bad "${ev}: rc=${rc} argv=${argv} err=${err}"; fi +done + +# 3. newline-separated rulesets split the same as spaces +run EVENT_NAME=push RULESETS=$'p/default\n p/ruby\n' +want="scan${nl}--error${nl}--metrics=off${nl}--config${nl}p/default${nl}--config${nl}p/ruby" +if [[ "${rc}" -eq 0 && "${argv}" == "${want}" ]]; then _ok "newline-separated rulesets"; else _bad "newline rulesets: rc=${rc} argv=${argv}"; fi + +# 4. token set: semgrep ci, no --config, token only in the environment +run EVENT_NAME=pull_request BASE_SHA="${base}" SEMGREP_APP_TOKEN=tok-123 +token="$(cat "${STUB_DIR}/token.log")" +if [[ "${rc}" -eq 0 && "${argv}" == "ci" && "${token}" == "tok-123" ]] && + grep -q 'platform policies replace' <<<"${err}"; then + _ok "token runs semgrep ci with the token in env, not argv" +else + _bad "token: rc=${rc} argv=${argv} err=${err}" +fi + +# 5. empty token: falls back to scan mode +run EVENT_NAME=push SEMGREP_APP_TOKEN= +if [[ "${rc}" -eq 0 && "${argv}" == scan* ]]; then _ok "empty token falls back to scan"; else _bad "empty token: rc=${rc} argv=${argv}"; fi + +# 6. findings: Semgrep's exit code passes through +run EVENT_NAME=pull_request BASE_SHA="${base}" STUB_RC=1 +if [[ "${rc}" -eq 1 ]]; then _ok "semgrep exit 1 (findings) propagates"; else _bad "findings: rc=${rc}"; fi + +# 7. refusals: exit 2 and semgrep never runs +refuse() { # refuse LABEL PATTERN VAR=value ... + local label="$1" pattern="$2"; shift 2 + run "$@" + if [[ "${rc}" -eq 2 ]] && ! called && grep -q -- "${pattern}" <<<"${err}"; then + _ok "${label}" + else + _bad "${label}: rc=${rc} argv=${argv} err=${err}" + fi +} +refuse "pull_request without BASE_SHA exits 2" "valid BASE_SHA" EVENT_NAME=pull_request +refuse "malformed BASE_SHA exits 2" "valid BASE_SHA" EVENT_NAME=pull_request BASE_SHA=main +refuse "base not in history exits 2 and names fetch-depth" "fetch-depth: 0" EVENT_NAME=pull_request BASE_SHA="${absent}" +refuse "pull_request_target exits 2" "not 'pull_request_target'" EVENT_NAME=pull_request_target +refuse "empty RULESETS exits 2" "RULESETS is empty" EVENT_NAME=push RULESETS=" " +refuse "ruleset starting with - exits 2" "not a valid" EVENT_NAME=push RULESETS="p/default --dangerous" +refuse "missing semgrep exits 2" "semgrep not found" EVENT_NAME=push SEMGREP_BIN=/nonexistent/semgrep + +# 8. the rulesets default agrees across the workflow, the stub, and README +wf_default="$(grep -A6 '^ rulesets:' "${workflow}" | sed -n 's/^ *default: *//p')" +stub_default="$(sed -n 's/^ *# rulesets: \(.*\) # the default$/\1/p' "${stub_file}")" +if [[ "${wf_default}" == "p/default p/javascript p/typescript p/python p/ruby" && + "${stub_default}" == "${wf_default}" ]] && grep -qF "| \`rulesets\` | string | \`${wf_default}\` |" "${readme}"; then + _ok "rulesets default matches in workflow, caller stub, and README" +else + _bad "rulesets drift: workflow='${wf_default}' stub='${stub_default}'" +fi + +# 9. the names the daily digest keys on stay stable +if grep -q '^ semgrep-scan:$' "${stub_file}" && + grep -qF 'uses: smartwatermelon/github-workflows/.github/workflows/semgrep-scan.yml@semgrep-scan-v1' "${stub_file}" && + [[ -f "${workflow}" ]]; then + _ok "caller job name and uses: ref are the stable names" +else + _bad "caller stub names changed" +fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]]