diff --git a/.github/workflows/ci-scan-community-node.yml b/.github/workflows/ci-scan-community-node.yml new file mode 100644 index 0000000..d46b2b1 --- /dev/null +++ b/.github/workflows/ci-scan-community-node.yml @@ -0,0 +1,32 @@ +name: 'CI: Scan Community Node' + +# Exercises the scan-community-node reusable workflow on pull requests that +# touch it: once against a published package and once against this repository. + +on: + pull_request: + paths: + - '.github/workflows/scan-community-node*.yml' + - '.github/workflows/ci-scan-community-node.yml' + - 'scan-community-node/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # A consumer that only scans packages needs nothing beyond contents: read. + package: + uses: $/.github/workflows/scan-community-node.yml + with: + package: n8n-nodes-evolution-api + version: 1.0.4 + + # Scans this repository. The upload stays off: Semgrep and Scorecard do not + # know `$/` and would report every self-reference here as an unpinned action. + workspace: + uses: $/.github/workflows/scan-community-node.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bca073..1ea2f4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,14 +26,30 @@ jobs: - run: pnpm typecheck - - name: Release dropdown covers every action + - name: Release dropdown covers every action and reusable workflow run: | set -euo pipefail missing=0 + check() { + grep -qE "^ +- $1$" .github/workflows/release.yml || { + echo "::error::'$1' is missing from the package options in .github/workflows/release.yml" + missing=1 + } + } for path in */action.yml; do - name="${path%/action.yml}" - grep -qE "^ +- ${name}$" .github/workflows/release.yml || { - echo "::error::'${name}' is missing from the package options in .github/workflows/release.yml" + check "${path%/action.yml}" + done + # A reusable workflow is released under its file name. Sub-workflows + # named -.yml belong to and are not listed. + for path in $(grep -lE '^ +workflow_call:' .github/workflows/*.yml); do + name="$(basename "$path" .yml)" + pkg="$name" + while [ -n "$pkg" ] && ! grep -qE "^ +- ${pkg}$" .github/workflows/release.yml; do + [ "${pkg%-*}" = "$pkg" ] && pkg="" || pkg="${pkg%-*}" + done + [ -n "$pkg" ] || check "$name" + [ -z "$pkg" ] || [ -f "$pkg/README.md" ] || { + echo "::error::reusable workflow package '$pkg' has no $pkg/README.md" missing=1 } done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f9a975..ca429f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,9 @@ run-name: "Release: ${{ inputs.package }} (${{ inputs.bump }})${{ inputs.dry-run # Tags one action at a time. Tags are `/vMAJOR.MINOR.PATCH`, so actions # version independently and a bump to one doesn't churn the others. # -# Adding a new action means adding it to the `package` options below — CI fails -# if a directory with an action.yml is missing from that list. +# Adding a new action or reusable workflow means adding it to the `package` +# options below — CI fails if a directory with an action.yml, or a workflow +# with a `workflow_call` trigger, is missing from that list. on: workflow_dispatch: @@ -16,6 +17,7 @@ on: type: choice options: - cla-check + - scan-community-node bump: description: 'Version bump (first release of an action is always v1.0.0)' required: true @@ -78,8 +80,17 @@ jobs: run: | set -euo pipefail - if [ ! -f "$PACKAGE/action.yml" ]; then - echo "::error::$PACKAGE/action.yml not found" + # An action lives in /action.yml. A reusable workflow lives + # in .github/workflows/.yml, with any sub-workflows named + # -*.yml and docs plus helpers under /. + if [ -f "$PACKAGE/action.yml" ]; then + uses_path="$PACKAGE" + paths="$PACKAGE" + elif [ -f ".github/workflows/$PACKAGE.yml" ]; then + uses_path=".github/workflows/$PACKAGE.yml" + paths=".github/workflows/$PACKAGE.yml .github/workflows/$PACKAGE-*.yml $PACKAGE" + else + echo "::error::neither $PACKAGE/action.yml nor .github/workflows/$PACKAGE.yml found" exit 1 fi @@ -112,6 +123,8 @@ jobs: echo "tag=$tag" echo "version=$next" echo "prev_tag=${prev_version:+$PACKAGE/v$prev_version}" + echo "uses_path=$uses_path" + echo "paths=$paths" } >> "$GITHUB_OUTPUT" - name: Render release notes @@ -120,26 +133,33 @@ jobs: PACKAGE: ${{ inputs.package }} VERSION: ${{ steps.version.outputs.version }} PREV_TAG: ${{ steps.version.outputs.prev_tag }} + USES_PATH: ${{ steps.version.outputs.uses_path }} + PATHS: ${{ steps.version.outputs.paths }} SHA: ${{ github.sha }} run: | set -euo pipefail + # PATHS is a space-separated list of pathspecs. Globbing is turned off + # so the shell only splits the words and git matches any glob against + # every tree in the range, including files renamed or removed since. + set -f + # shellcheck disable=SC2086 { echo '```yaml' - echo "- uses: ${{ github.repository }}/$PACKAGE@$SHA # v$VERSION" + echo "- uses: ${{ github.repository }}/$USES_PATH@$SHA # v$VERSION" echo '```' echo echo "### Changes" echo if [ -n "$PREV_TAG" ]; then - git log "$PREV_TAG..HEAD" --no-merges --pretty='- %s (%h)' -- "$PACKAGE" > /tmp/commits + git log "$PREV_TAG..HEAD" --no-merges --pretty='- %s (%h)' -- $PATHS > /tmp/commits else - git log --no-merges --pretty='- %s (%h)' -- "$PACKAGE" > /tmp/commits + git log --no-merges --pretty='- %s (%h)' -- $PATHS > /tmp/commits fi if [ -s /tmp/commits ]; then cat /tmp/commits else - echo "- No commits touched \`$PACKAGE/\` since ${PREV_TAG:-the start of the repo}." + echo "- No commits touched \`$PACKAGE\` since ${PREV_TAG:-the start of the repo}." fi if [ -n "$PREV_TAG" ]; then echo diff --git a/.github/workflows/scan-community-node-cve-lite.yml b/.github/workflows/scan-community-node-cve-lite.yml new file mode 100644 index 0000000..87723f7 --- /dev/null +++ b/.github/workflows/scan-community-node-cve-lite.yml @@ -0,0 +1,77 @@ +name: 'Scan Community Node (CVE Lite CLI)' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + version: + description: 'Optional package version to scan (e.g. 4.18.2).' + required: false + default: 'latest' + type: string + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +env: + PACKAGE_SPEC: ${{ inputs.package }}@${{ inputs.version }} + +jobs: + cve-lite: + name: CVE Lite CLI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: $/scan-community-node/actions/setup + with: + node: 'true' + + - name: Prepare scan target + id: prep + env: + PACKAGE: ${{ inputs.package }} + run: | + # With a package name, scan the published npm tarball; otherwise scan this checkout. + if [ -n "$PACKAGE" ]; then + # Unpack outside the workspace so the caller's own files are never scanned. + DIR="$RUNNER_TEMP/cve-lite-target" + rm -rf "$DIR" && mkdir -p "$DIR" + # npm pack downloads -.tgz and prints that file name. + ARCHIVE="$(npm pack --silent "$PACKAGE_SPEC")" + # The tarball wraps everything in a package/ folder; strip that level. + tar -xzf "$ARCHIVE" -C "$DIR" --strip-components=1 + # Tarballs ship no lockfile; without one only pinned direct deps are scanned. + # --package-lock-only resolves the tree without downloading or running anything. + (cd "$DIR" && npm install --package-lock-only --ignore-scripts) + # Hand the directory to the scan step as steps.prep.outputs.path. + echo "path=$DIR" >> "$GITHUB_OUTPUT" + else + echo "path=." >> "$GITHUB_OUTPUT" + fi + + - name: Run CVE Lite CLI + uses: OWASP/cve-lite-cli@e444d847f67d5f2b07f38d66a8e61b9eeba8b18d # v1.37.0 + with: + path: ${{ steps.prep.outputs.path }} + version: 1.37.0 + sarif: true + output: security-report + + - name: Write CVE Lite CLI summary + if: always() + uses: $/scan-community-node/actions/security-summary + + - name: Upload CVE Lite CLI SARIF file to GitHub + if: always() + uses: $/scan-community-node/actions/upload-security-sarif + with: + category: cve-lite + package: ${{ inputs.package }} + upload: ${{ inputs.upload-sarif }} diff --git a/.github/workflows/scan-community-node-gitleaks.yml b/.github/workflows/scan-community-node-gitleaks.yml new file mode 100644 index 0000000..a37abba --- /dev/null +++ b/.github/workflows/scan-community-node-gitleaks.yml @@ -0,0 +1,72 @@ +name: 'Scan Community Node (Gitleaks)' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + version: + description: 'Optional package version to scan (e.g. 4.18.2).' + required: false + default: 'latest' + type: string + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +env: + PACKAGE_SPEC: ${{ inputs.package }}@${{ inputs.version }} + GITLEAKS_VERSION: 8.30.1 + # SHA-256 of gitleaks__linux_x64.tar.gz; update when bumping the version. + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: $/scan-community-node/actions/setup + with: + node: 'true' + + - name: Install Gitleaks + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o gitleaks.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + sudo install -m 0755 gitleaks /usr/local/bin/gitleaks + rm -f gitleaks gitleaks.tar.gz + + - name: Run Gitleaks + continue-on-error: true + env: + PACKAGE: ${{ inputs.package }} + run: | + if [ -n "$PACKAGE" ]; then + DIR="$RUNNER_TEMP/gitleaks-target" + rm -rf "$DIR" && mkdir -p "$DIR" + npm pack "$PACKAGE_SPEC" + tar -xzf ./*.tgz -C "$DIR" --strip-components=1 + TARGET="$DIR" + else + TARGET="." + fi + gitleaks dir "$TARGET" --report-format sarif --report-path security-report/gitleaks.sarif + + - name: Write Gitleaks summary + if: always() + uses: $/scan-community-node/actions/security-summary + + - name: Upload Gitleaks SARIF file to GitHub + uses: $/scan-community-node/actions/upload-security-sarif + with: + category: gitleaks + package: ${{ inputs.package }} + upload: ${{ inputs.upload-sarif }} diff --git a/.github/workflows/scan-community-node-guarddog.yml b/.github/workflows/scan-community-node-guarddog.yml new file mode 100644 index 0000000..3557fd2 --- /dev/null +++ b/.github/workflows/scan-community-node-guarddog.yml @@ -0,0 +1,68 @@ +name: 'Scan Community Node (GuardDog)' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + version: + description: 'Optional package version to scan (e.g. 4.18.2).' + required: false + default: 'latest' + type: string + sandbox: + description: 'Whether GuardDog should run inside its kernel-level sandbox.' + required: false + default: true + type: boolean + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +jobs: + guarddog: + name: GuardDog + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: $/scan-community-node/actions/setup + + - name: Run GuardDog + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + SANDBOX: ${{ inputs.sandbox }} + run: | + # pipefail so a GuardDog failure isn't masked by tee's exit code. + set -o pipefail + SANDBOX_FLAG="" + if [ "$SANDBOX" = "false" ]; then + SANDBOX_FLAG="--no-sandbox" + fi + if [ -n "$PACKAGE" ]; then + # GuardDog scan can't emit SARIF, so keep its native report. + if [ -n "$VERSION" ] && [ "$VERSION" != "latest" ]; then + uvx guarddog npm scan $SANDBOX_FLAG "$PACKAGE" --version "$VERSION" | tee security-report/guarddog.txt + else + uvx guarddog npm scan $SANDBOX_FLAG "$PACKAGE" | tee security-report/guarddog.txt + fi + else + uvx guarddog npm verify $SANDBOX_FLAG --output-format sarif package.json > security-report/guarddog.sarif + fi + + - name: Write GuardDog summary + if: always() + uses: $/scan-community-node/actions/security-summary + + - name: Upload SARIF file to GitHub + uses: $/scan-community-node/actions/upload-security-sarif + with: + category: guarddog-builtin + package: ${{ inputs.package }} + upload: ${{ inputs.upload-sarif }} diff --git a/.github/workflows/scan-community-node-scorecard.yml b/.github/workflows/scan-community-node-scorecard.yml new file mode 100644 index 0000000..3b06e0b --- /dev/null +++ b/.github/workflows/scan-community-node-scorecard.yml @@ -0,0 +1,60 @@ +name: 'Scan Community Node (OpenSSF Scorecard)' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +jobs: + scorecard: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: $/scan-community-node/actions/setup + + - name: Run OpenSSF Scorecard (repository) + if: ${{ inputs.package == '' }} + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: security-report/scorecard.sarif + results_format: sarif + publish_results: false + + - name: Run OpenSSF Scorecard (package) + if: ${{ inputs.package != '' }} + env: + PACKAGE: ${{ inputs.package }} + GITHUB_AUTH_TOKEN: ${{ github.token }} + run: | + # pipefail so a scorecard failure isn't masked by tee's exit code. + set -o pipefail + # The scorecard binary can't emit SARIF, so keep its native report. + # Scorecard scores the package's source repository (default branch), + # not a specific release, so there is no version to select. + docker run --rm -e GITHUB_AUTH_TOKEN \ + ghcr.io/ossf/scorecard:v5.5.0@sha256:3f24714e9366917adb7a05635382c97dfecb14b21eaef3dfa2ea48c8e23e0795 \ + --npm="$PACKAGE" --format=default | tee security-report/scorecard.txt + + - name: Write Scorecard summary + if: always() + uses: $/scan-community-node/actions/security-summary + + - name: Upload Scorecard SARIF file to GitHub + uses: $/scan-community-node/actions/upload-security-sarif + with: + category: scorecard + package: ${{ inputs.package }} + upload: ${{ inputs.upload-sarif }} diff --git a/.github/workflows/scan-community-node-semgrep.yml b/.github/workflows/scan-community-node-semgrep.yml new file mode 100644 index 0000000..6dfe4a8 --- /dev/null +++ b/.github/workflows/scan-community-node-semgrep.yml @@ -0,0 +1,63 @@ +name: 'Scan Community Node (Semgrep)' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + version: + description: 'Optional package version to scan (e.g. 4.18.2).' + required: false + default: 'latest' + type: string + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +env: + PACKAGE_SPEC: ${{ inputs.package }}@${{ inputs.version }} + +jobs: + semgrep: + name: Semgrep + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: $/scan-community-node/actions/setup + with: + node: 'true' + + - name: Run Semgrep + env: + PACKAGE: ${{ inputs.package }} + run: | + if [ -n "$PACKAGE" ]; then + # Extract outside the git tree so Semgrep doesn't skip untracked files, + # and disable its default ignores so shipped dist/ code is scanned. + DIR="$RUNNER_TEMP/semgrep-target" + rm -rf "$DIR" && mkdir -p "$DIR" + npm pack "$PACKAGE_SPEC" + tar -xzf ./*.tgz -C "$DIR" --strip-components=1 + : > "$DIR/.semgrepignore" + SARIF="$PWD/security-report/semgrep.sarif" + (cd "$DIR" && uvx semgrep scan --config auto --sarif-output "$SARIF" .) + else + uvx semgrep scan --config auto --sarif-output security-report/semgrep.sarif . + fi + + - name: Write Semgrep summary + if: always() + uses: $/scan-community-node/actions/security-summary + + - name: Upload Semgrep SARIF file to GitHub + uses: $/scan-community-node/actions/upload-security-sarif + with: + category: semgrep + package: ${{ inputs.package }} + upload: ${{ inputs.upload-sarif }} diff --git a/.github/workflows/scan-community-node.yml b/.github/workflows/scan-community-node.yml new file mode 100644 index 0000000..723b7eb --- /dev/null +++ b/.github/workflows/scan-community-node.yml @@ -0,0 +1,87 @@ +name: 'Scan Community Node' + +on: + workflow_call: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to verify the local package.json.' + required: false + type: string + version: + description: 'Optional package version to scan (e.g. 4.18.2).' + required: false + default: 'latest' + type: string + sandbox: + description: 'Whether GuardDog should run inside its kernel-level sandbox.' + required: false + default: true + type: boolean + upload-sarif: + description: 'Whether to upload SARIF reports to GitHub code scanning.' + required: false + default: false + type: boolean + +jobs: + summary: + name: Test summary + runs-on: ubuntu-latest + steps: + - name: Write test target summary + env: + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + run: | + if [ -n "$PACKAGE" ]; then + TARGET="$PACKAGE" + SCANNED_VERSION="${VERSION:-latest}" + else + TARGET="local package.json" + SCANNED_VERSION="n/a" + fi + # Strip characters that could break out of the markdown table cell. + TARGET=$(printf '%s' "$TARGET" | tr -d '\n\r`' | sed 's/|/\\|/g') + SCANNED_VERSION=$(printf '%s' "$SCANNED_VERSION" | tr -d '\n\r`' | sed 's/|/\\|/g') + { + echo "## Security scan target" + echo "" + echo "| Package | Version |" + echo "| --- | --- |" + echo "| $TARGET | $SCANNED_VERSION |" + } >> "$GITHUB_STEP_SUMMARY" + + scan-for-malware: + uses: $/.github/workflows/scan-community-node-guarddog.yml + with: + package: ${{ inputs.package }} + version: ${{ inputs.version }} + sandbox: ${{ inputs.sandbox }} + upload-sarif: ${{ inputs.upload-sarif }} + + scan-for-insecure-code: + uses: $/.github/workflows/scan-community-node-semgrep.yml + with: + package: ${{ inputs.package }} + version: ${{ inputs.version }} + upload-sarif: ${{ inputs.upload-sarif }} + + scan-for-misconfiguration: + uses: $/.github/workflows/scan-community-node-scorecard.yml + with: + package: ${{ inputs.package }} + upload-sarif: ${{ inputs.upload-sarif }} + + scan-for-vulnerabilities: + uses: $/.github/workflows/scan-community-node-cve-lite.yml + with: + package: ${{ inputs.package }} + version: ${{ inputs.version }} + upload-sarif: ${{ inputs.upload-sarif }} + + scan-for-secrets: + uses: $/.github/workflows/scan-community-node-gitleaks.yml + with: + package: ${{ inputs.package }} + version: ${{ inputs.version }} + upload-sarif: ${{ inputs.upload-sarif }} diff --git a/.gitignore b/.gitignore index c2658d7..185698c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +security-report/ diff --git a/README.md b/README.md index fafcfa9..dcec542 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Shared GitHub Actions used across `n8n-io` repositories. -| Action | Description | +| Component | Description | |--------|-------------| | [`cla-check`](./cla-check) | Verify every contributor on a PR has signed the n8n CLA | +| [`scan-community-node`](./scan-community-node) | Reusable workflow: scan an npm package or the calling repo for malware, insecure code, misconfiguration, vulnerable dependencies and secrets | -## Consuming an action +## Consuming Reference the action by path, pinned to a commit SHA with the version in a trailing comment: @@ -15,6 +16,14 @@ trailing comment: - uses: n8n-io/github-actions/cla-check@ # v1.0.0 ``` +A reusable workflow is referenced by its file path and called from a job: + +```yaml +jobs: + scan: + uses: n8n-io/github-actions/.github/workflows/scan-community-node.yml@ # v1.0.0 +``` + Pin to a SHA rather than a tag. These actions run in workflows that hold write-scoped tokens, so a moving tag would let a change here take effect in every consuming repo without review. diff --git a/scan-community-node/README.md b/scan-community-node/README.md new file mode 100644 index 0000000..f1134fc --- /dev/null +++ b/scan-community-node/README.md @@ -0,0 +1,76 @@ +# scan-community-node + +Reusable workflow that scans an npm package, or the calling repository, for security problems before it is trusted. Each scanner runs as its own job, in parallel, and writes a summary to the run's step summary. + +## Layout + +Reusable workflows must live in `.github/workflows/`, so the package is split between that directory and this one: + +| Path | Role | +|------|------| +| [`.github/workflows/scan-community-node.yml`](../.github/workflows/scan-community-node.yml) | Entry point. Writes the target table and fans out to one job per scanner | +| `.github/workflows/scan-community-node-.yml` | One reusable workflow per scanner | +| [`actions/setup`](./actions/setup) | Installs uv and optionally Node.js, and creates `security-report/` | +| [`actions/security-summary`](./actions/security-summary) | Renders the reports in `security-report/` into the step summary | +| [`actions/upload-security-sarif`](./actions/upload-security-sarif) | Uploads the SARIF reports to code scanning when the caller opts in | +| [`examples/ci-security-scan.yml`](./examples/ci-security-scan.yml) | Caller workflow to copy into a consuming repo | + +The workflows reference each other and the helper actions with the `$/` self-repository prefix, which resolves against this repository at the running commit even when called from another repository. Nothing here needs to be checked out by the caller. Scorecard and Semgrep do not recognise `$/` yet and report those references as unpinned third-party actions when this repository scans itself ([ossf/scorecard#5191](https://github.com/ossf/scorecard/pull/5191), [semgrep/semgrep-rules#4042](https://github.com/semgrep/semgrep-rules/pull/4042)). + +## Scanners + +| Job | Tool | Area of concern | +|-----|------|-----------------| +| `scan-for-malware` | [GuardDog](https://github.com/DataDog/guarddog) | Malicious and supply-chain behavior (install scripts, obfuscation, exfiltration, typosquatting) | +| `scan-for-insecure-code` | [Semgrep](https://github.com/semgrep/semgrep) | Insecure code patterns (static analysis) | +| `scan-for-misconfiguration` | [OpenSSF Scorecard](https://github.com/ossf/scorecard) | Security posture of the source repository (branch protection, pinned dependencies, CI hardening) | +| `scan-for-vulnerabilities` | [CVE Lite CLI](https://github.com/OWASP/cve-lite-cli) | Known vulnerabilities (CVEs) in the dependency tree, matched against OSV and the npm advisory API, with the upgrade commands that fix them | +| `scan-for-secrets` | [Gitleaks](https://github.com/gitleaks/gitleaks) | Hardcoded secrets and leaked credentials | + +Every scanner reports; none of them fails its job on findings. A job fails only when a scanner cannot run. + +## Usage + +Copy [`examples/ci-security-scan.yml`](./examples/ci-security-scan.yml) to `.github/workflows/ci-security-scan.yml` in the consuming repo and replace the pinned SHA. The minimal form is one job: + +```yaml +permissions: + contents: read + security-events: write + +jobs: + scan: + uses: n8n-io/github-actions/.github/workflows/scan-community-node.yml@ # v1.0.0 + with: + package: ${{ inputs.package }} + upload-sarif: true +``` + +The called workflows declare no permissions of their own and inherit what the caller grants. `contents: read` is always needed. `security-events: write` is needed only when `upload-sarif` is enabled, and a private repository additionally needs `actions: read` for that upload. A caller that does not upload can leave both out. Triggers and `concurrency` are the caller's as well. + +Each scanner is also a reusable workflow of its own and can be called the same way, for example `.github/workflows/scan-community-node-guarddog.yml`. + +## Two modes + +**Published package.** Set `package` (and optionally `version`) to download and scan an npm package. Its tarball is unpacked and a lockfile is resolved so the whole dependency tree is scanned. Findings show up in the step summary only: they belong to another project and are never uploaded to the caller's code scanning. + +**Workspace.** Leave `package` empty to scan the calling repository. With `upload-sarif` enabled the SARIF reports are uploaded to GitHub code scanning, so findings appear under **Security → Code scanning** next to CodeQL and other analyses. Uploading needs GitHub Code Security on the repository. + +## Inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `package` | empty | npm package name, e.g. `express` or `@scope/pkg`. Leave empty to scan the calling repository. | +| `version` | `latest` | Package version to scan. Not accepted by the Scorecard workflow, which scores the package's source repository rather than a release. | +| `sandbox` | `true` | Whether GuardDog should run inside its kernel-level sandbox. Accepted only by the entry workflow and the GuardDog workflow. | +| `upload-sarif` | `false` | Whether to upload SARIF reports to GitHub code scanning. Only applies when scanning the calling repository. | + +## Results + +Every scan writes a human-readable summary to the workflow run's step summary. Each scanner writes its report files into a `security-report/` directory in the workspace. + +Semgrep, Gitleaks and CVE Lite CLI always emit SARIF, CVE Lite CLI unless the target has no lockfile. GuardDog and Scorecard emit SARIF when scanning the calling repository and their native text report for a third-party package, since they cannot emit SARIF in that mode. + +## Testing + +[`ci-scan-community-node.yml`](../.github/workflows/ci-scan-community-node.yml) runs on pull requests that touch this package: once against a published package and once against this repository. diff --git a/scan-community-node/actions/security-summary/action.yml b/scan-community-node/actions/security-summary/action.yml new file mode 100644 index 0000000..be0bd20 --- /dev/null +++ b/scan-community-node/actions/security-summary/action.yml @@ -0,0 +1,46 @@ +name: Security summary +description: Render the scan report in security-report/ into the job step summary. + +runs: + using: composite + steps: + - shell: bash + run: | + # Every scanner writes its report into this directory, created by the setup action. + DIR=security-report + # The rendered text is collected here first, then appended to the step summary in one go. + REPORT="$(mktemp)" + # compgen -G expands a glob and fails when nothing matches, so it doubles as an existence test. + if compgen -G "$DIR/*.sarif" > /dev/null; then + # sarif-tools reads every SARIF file in the directory. + uvx --from sarif-tools sarif summary "$DIR" > "$REPORT" + # summary abbreviates each rule, so append the full per-finding CSV. + CSV="$(mktemp -u).csv" + uvx --from sarif-tools sarif csv -o "$CSV" "$DIR" >/dev/null + echo >> "$REPORT" + # CSV always has a header row; more lines mean actual findings. + if [ "$(wc -l < "$CSV")" -gt 1 ]; then + cat "$CSV" >> "$REPORT" + else + echo 'No findings.' >> "$REPORT" + fi + rm -f "$CSV" + elif compgen -G "$DIR/*.txt" > /dev/null; then + # GuardDog and Scorecard cannot emit SARIF for a third-party package, so they leave plain text. + cat "$DIR"/*.txt > "$REPORT" + else + # Nothing was written, for example a package with no lockfile. Leave the summary untouched. + exit 0 + fi + # Escape leading backticks so untrusted scan output can't close the + # code fence and inject markdown into the step summary. + sed -i 's/^`/\\`/' "$REPORT" + # $GITHUB_STEP_SUMMARY is the markdown shown on the run's summary page. + { + echo '```' + cat "$REPORT" + # Newline so output without a trailing one doesn't glue to the fence. + echo + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + rm -f "$REPORT" diff --git a/scan-community-node/actions/setup/action.yml b/scan-community-node/actions/setup/action.yml new file mode 100644 index 0000000..907743e --- /dev/null +++ b/scan-community-node/actions/setup/action.yml @@ -0,0 +1,23 @@ +name: Setup tools +description: Install uv (and optionally Node.js) and create the report directory for the security scan jobs. + +inputs: + node: + description: Whether Node.js should be installed. + required: false + default: 'false' + +runs: + using: composite + steps: + - shell: bash + run: mkdir -p security-report + + - if: ${{ inputs.node == 'true' }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 'lts/*' + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false diff --git a/scan-community-node/actions/upload-security-sarif/action.yml b/scan-community-node/actions/upload-security-sarif/action.yml new file mode 100644 index 0000000..4c0d3cd --- /dev/null +++ b/scan-community-node/actions/upload-security-sarif/action.yml @@ -0,0 +1,25 @@ +name: Upload security SARIF +description: Upload the SARIF reports in security-report/ to GitHub code scanning when the caller opts in, skipping package scans so their findings don't pollute this repo's alerts. + +inputs: + category: + description: Code scanning category for this tool. + required: true + package: + description: Scanned package name. When set, the upload is skipped because the findings belong to a third-party package, not this repo. + required: false + default: '' + upload: + description: Whether to upload the report to GitHub code scanning. + required: false + default: 'false' + +runs: + using: composite + steps: + # Skip unless opted in, and skip uploading external package scans (their findings aren't ours). + - if: ${{ always() && inputs.upload == 'true' && inputs.package == '' && hashFiles('security-report/*.sarif') != '' }} + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + with: + category: ${{ inputs.category }} + sarif_file: security-report diff --git a/scan-community-node/examples/ci-security-scan.yml b/scan-community-node/examples/ci-security-scan.yml new file mode 100644 index 0000000..7b6829b --- /dev/null +++ b/scan-community-node/examples/ci-security-scan.yml @@ -0,0 +1,46 @@ +name: 'CI: Security Scan' + +# Copy this file to .github/workflows/ci-security-scan.yml in the consuming repo +# and replace the pinned SHA. +# +# On push, pull_request and schedule the repository itself is scanned and the +# SARIF reports are uploaded to Security → Code scanning. Dispatch the workflow +# with a package name to scan a published community node instead; those +# findings appear in the job summaries only. + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 3 * * 1' + workflow_dispatch: + inputs: + package: + description: 'npm package name to scan (e.g. express or @scope/pkg). Leave empty to scan this repository.' + required: false + type: string + version: + description: 'Version or dist-tag of the package to scan.' + required: false + default: 'latest' + type: string + +# The called workflows declare no permissions and run with this grant. +# security-events: write is needed only for the SARIF upload, and a private +# repository additionally needs actions: read for it. +permissions: + contents: read + security-events: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + uses: n8n-io/github-actions/.github/workflows/scan-community-node.yml@REPLACE_WITH_SHA # v1.0.0 + with: + package: ${{ inputs.package }} + version: ${{ inputs.version || 'latest' }} + upload-sarif: ${{ inputs.package == '' }}