Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
398 changes: 398 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,398 @@
name: 'SecureCheck'
description: 'Multi-scanner security pipeline (secrets, SAST, dependency CVEs, IaC and license checks) with SARIF upload to code scanning, severity gating, and optional AI PR review.'
author: 'w1ck3ds0d4'

branding:
icon: 'shield'
color: 'green'

inputs:
node-version:
description: 'Node.js version for JS/TS tooling.'
default: '20'
python-version:
description: 'Python version for Semgrep / ruff / lizard.'
default: '3.11'
dotnet-version:
description: 'Optional .NET SDK version (only used when a .NET project is detected).'
default: '10.0.x'
fail-on:
description: 'Minimum finding severity that fails the build: none | low | medium | high | critical. Detected secrets are always treated as critical.'
default: 'none'
upload-sarif:
description: 'Upload SARIF results to GitHub code scanning. Requires the calling job to grant `security-events: write`.'
default: 'true'
run-claude:
description: 'Optional Claude PR review: auto | true | false. `auto` runs only on pull_request events when an API key is supplied.'
default: 'auto'
gitleaks-version:
description: 'Pinned Gitleaks release to install.'
default: '8.24.3'
anthropic-api-key:
description: 'Anthropic API key. When set (and on a pull request), enables the Claude security review.'
default: ''
discord-webhook:
description: 'Optional Discord webhook URL. When set, posts a single summary embed.'
default: ''

runs:
using: 'composite'
steps:
- name: SecureCheck init
shell: bash
run: |
set -euo pipefail
echo "REPORTS=${RUNNER_TEMP}/securecheck" >> "$GITHUB_ENV"
mkdir -p "${RUNNER_TEMP}/securecheck"

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}

- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: securecheck-pip-${{ runner.os }}

- name: Cache npm
uses: actions/cache@v4
with:
path: ~/.npm
key: securecheck-npm-${{ runner.os }}

- name: Cache Trivy DB
uses: actions/cache@v4
with:
path: ~/.cache/trivy
key: securecheck-trivy-db-${{ runner.os }}

- name: Detect stack
id: detect
shell: bash
run: |
set -euo pipefail
has_js=false; has_py=false; has_rust=false; has_dotnet=false
[ -f package.json ] && has_js=true
{ [ -f requirements.txt ] || [ -f pyproject.toml ]; } && has_py=true || true
[ -f Cargo.toml ] || find . -maxdepth 4 -name Cargo.toml -not -path './node_modules/*' -not -path './target/*' -print -quit | grep -q . && has_rust=true || true
find . -maxdepth 4 \( -name '*.sln' -o -name '*.slnx' -o -name '*.csproj' \) -print -quit | grep -q . && has_dotnet=true || true
eslint_cfg=false
if [ "${has_js}" = "true" ]; then
for f in .eslintrc .eslintrc.json .eslintrc.js .eslintrc.cjs .eslintrc.yml .eslintrc.yaml eslint.config.js eslint.config.cjs eslint.config.mjs; do
[ -f "$f" ] && eslint_cfg=true && break
done
fi
{
echo "has_js=${has_js}"
echo "has_py=${has_py}"
echo "has_rust=${has_rust}"
echo "has_dotnet=${has_dotnet}"
echo "eslint_cfg=${eslint_cfg}"
} >> "$GITHUB_OUTPUT"

- name: Install Gitleaks
shell: bash
env:
GITLEAKS_VERSION: ${{ inputs.gitleaks-version }}
run: |
set -euo pipefail
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar -xz -C "${RUNNER_TEMP}"
sudo mv "${RUNNER_TEMP}/gitleaks" /usr/local/bin/

- name: Gitleaks (secrets)
id: gitleaks
shell: bash
run: |
T0=$(date +%s)
gitleaks detect --source . --redact --no-banner \
--report-format sarif --report-path "${REPORTS}/gitleaks.sarif" --exit-code 0 || true
COUNT=$(jq '[.runs[].results[]?] | length' "${REPORTS}/gitleaks.sarif" 2>/dev/null || echo 0)
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "Gitleaks findings: ${COUNT}"

- name: Semgrep (SAST)
id: semgrep
shell: bash
run: |
T0=$(date +%s)
python -m pip install --quiet semgrep
semgrep --config=auto --sarif --output "${REPORTS}/semgrep.sarif" --quiet . || true
COUNT=$(jq '[.runs[].results[]?] | length' "${REPORTS}/semgrep.sarif" 2>/dev/null || echo 0)
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "Semgrep findings: ${COUNT}"

# Two Trivy passes: JSON drives the detailed counts and the severity gate;
# SARIF feeds code scanning. The DB download is shared via the Trivy cache.
- name: Trivy (JSON for counts + gate)
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
format: json
output: ${{ env.REPORTS }}/trivy.json
exit-code: '0'
scanners: vuln,misconfig,license
ignore-unfixed: false

- name: Trivy (SARIF for code scanning)
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
format: sarif
output: ${{ env.REPORTS }}/trivy.sarif
exit-code: '0'
scanners: vuln,misconfig,license
ignore-unfixed: false

- name: Count Trivy findings
id: trivy
shell: bash
run: |
T0=$(date +%s)
VULN=$(jq '[.Results[]?.Vulnerabilities // [] | length] | add // 0' "${REPORTS}/trivy.json" 2>/dev/null || echo 0)
MISC=$(jq '[.Results[]?.Misconfigurations // [] | length] | add // 0' "${REPORTS}/trivy.json" 2>/dev/null || echo 0)
LIC=$(jq '[.Results[]?.Licenses // [] | length] | add // 0' "${REPORTS}/trivy.json" 2>/dev/null || echo 0)
COUNT=$(( VULN + MISC + LIC ))
{
echo "count=${COUNT}"
echo "vuln=${VULN}"
echo "misconfig=${MISC}"
echo "license=${LIC}"
echo "duration=$(( $(date +%s) - T0 ))"
} >> "$GITHUB_OUTPUT"
echo "Trivy findings: total=${COUNT} vuln=${VULN} misconfig=${MISC} license=${LIC}"

- name: ESLint (JS/TS)
id: eslint
if: steps.detect.outputs.has_js == 'true' && steps.detect.outputs.eslint_cfg == 'true'
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
set +e
if [ -f pnpm-lock.yaml ]; then
corepack enable >/dev/null 2>&1 || true
npm install -g pnpm@latest >/dev/null 2>&1
pnpm install --frozen-lockfile --ignore-scripts >"${REPORTS}/eslint-install.log" 2>&1
pnpm exec eslint . --format json --output-file "${REPORTS}/eslint.json"
elif [ -f yarn.lock ]; then
corepack enable >/dev/null 2>&1 || true
yarn install --frozen-lockfile --ignore-scripts >"${REPORTS}/eslint-install.log" 2>&1
yarn eslint . --format json --output-file "${REPORTS}/eslint.json"
elif [ -f package-lock.json ]; then
npm ci --ignore-scripts >"${REPORTS}/eslint-install.log" 2>&1
npx --no-install eslint . --format json --output-file "${REPORTS}/eslint.json"
else
npm install --no-audit --no-fund --ignore-scripts >"${REPORTS}/eslint-install.log" 2>&1
npx eslint . --format json --output-file "${REPORTS}/eslint.json"
fi
COUNT=$(jq '[.[].messages | length] | add // 0' "${REPORTS}/eslint.json" 2>/dev/null || echo 0)
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "ESLint findings: ${COUNT}"

- name: ruff (Python)
id: ruff
if: steps.detect.outputs.has_py == 'true'
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
python -m pip install --quiet ruff
ruff check --output-format=json --exit-zero . > "${REPORTS}/ruff-check.json" 2>/dev/null || echo '[]' > "${REPORTS}/ruff-check.json"
ruff format --check . > "${REPORTS}/ruff-format.txt" 2>&1 || true
CHECK_COUNT=$(jq 'length' "${REPORTS}/ruff-check.json" 2>/dev/null || echo 0)
FORMAT_COUNT=$(grep -cE '^Would reformat:' "${REPORTS}/ruff-format.txt" || echo 0)
COUNT=$(( CHECK_COUNT + FORMAT_COUNT ))
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "ruff findings: ${COUNT} (lint=${CHECK_COUNT}, format=${FORMAT_COUNT})"

- name: Set up Rust toolchain
if: steps.detect.outputs.has_rust == 'true'
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt,clippy

- name: cargo fmt + clippy (Rust)
id: rust
if: steps.detect.outputs.has_rust == 'true'
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
CARGO_DIR=$(find . -maxdepth 4 -name Cargo.toml -not -path './node_modules/*' -not -path './target/*' | head -n 1 | xargs -r dirname)
[ -z "${CARGO_DIR}" ] && CARGO_DIR="."
cd "${CARGO_DIR}"
set +e
cargo fmt --all --check > "${REPORTS}/cargo-fmt.txt" 2>&1
FMT_EC=$?
cargo clippy --all-targets --message-format json -- -D warnings > "${REPORTS}/cargo-clippy.json" 2>/dev/null
CLIPPY_COUNT=$(jq -s '[.[] | select(.reason=="compiler-message" and .message.level=="warning") | .message.level] | length' "${REPORTS}/cargo-clippy.json" 2>/dev/null || echo 0)
FMT_COUNT=$([ "$FMT_EC" -ne 0 ] && echo 1 || echo 0)
COUNT=$(( CLIPPY_COUNT + FMT_COUNT ))
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "Rust findings: ${COUNT} (clippy=${CLIPPY_COUNT}, fmt_dirty=${FMT_COUNT})"

- name: Set up .NET SDK
if: steps.detect.outputs.has_dotnet == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ inputs.dotnet-version }}

- name: dotnet format (.NET)
id: dotnet
if: steps.detect.outputs.has_dotnet == 'true'
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
set +e
SLN=$(find . -maxdepth 4 \( -name '*.slnx' -o -name '*.sln' \) | head -n 1)
if [ -z "${SLN}" ]; then
SLN=$(find . -maxdepth 4 -name '*.csproj' | head -n 1)
fi
dotnet format "${SLN}" --verify-no-changes --report "${REPORTS}/dotnet-format.json" --severity error > "${REPORTS}/dotnet-format.log" 2>&1
EC=$?
if [ -f "${REPORTS}/dotnet-format.json" ]; then
COUNT=$(jq '[.[] | .FileChanges // [] | length] | add // 0' "${REPORTS}/dotnet-format.json" 2>/dev/null || echo 0)
else
COUNT=$([ "$EC" -ne 0 ] && echo 1 || echo 0)
fi
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "dotnet format findings: ${COUNT}"

- name: Complexity (lizard)
id: lizard
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
python -m pip install --quiet lizard
lizard -C 15 -w . > "${REPORTS}/lizard.txt" 2>&1 || true
COUNT=$(grep -cE '^[^!]' "${REPORTS}/lizard.txt" || echo 0)
echo "count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "lizard hotspots (CCN>=15): ${COUNT}"

- name: Duplication (jscpd)
id: jscpd
continue-on-error: true
shell: bash
run: |
T0=$(date +%s)
npx --yes jscpd \
--silent \
--threshold 0 \
--min-tokens 70 \
--reporters json \
--output "${REPORTS}/jscpd" \
--ignore "**/node_modules/**,**/dist/**,**/build/**,**/target/**,**/bin/**,**/obj/**,**/vendor/**,**/*.lock,**/*.min.*" \
. > "${REPORTS}/jscpd-stdout.txt" 2>&1 || true
COUNT=$(jq '.statistics.total.duplicatedLines // 0' "${REPORTS}/jscpd/jscpd-report.json" 2>/dev/null || echo 0)
CLONES=$(jq '.statistics.total.clones // 0' "${REPORTS}/jscpd/jscpd-report.json" 2>/dev/null || echo 0)
echo "count=${CLONES}" >> "$GITHUB_OUTPUT"
echo "duplicated_lines=${COUNT}" >> "$GITHUB_OUTPUT"
echo "duration=$(( $(date +%s) - T0 ))" >> "$GITHUB_OUTPUT"
echo "jscpd clones: ${CLONES} (duplicated lines: ${COUNT})"

- name: Upload SARIF to code scanning
if: ${{ always() && inputs.upload-sarif == 'true' }}
continue-on-error: true
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ env.REPORTS }}

- name: Claude review (optional)
id: claude
if: ${{ always() && github.event_name == 'pull_request' && inputs.run-claude != 'false' && inputs.anthropic-api-key != '' }}
shell: bash
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic-api-key }}
BASE_REF: ${{ github.base_ref }}
run: |
T0=$(date +%s)
npm --prefix "${{ github.action_path }}" install --silent --no-audit --no-fund
node "${{ github.action_path }}/scripts/claude-review.mjs" > "${REPORTS}/claude.json"
COUNT=$(jq 'length' "${REPORTS}/claude.json" 2>/dev/null || echo 0)
{
echo "count=${COUNT}"
echo "enabled=true"
echo "duration=$(( $(date +%s) - T0 ))"
} >> "$GITHUB_OUTPUT"
echo "Claude findings: ${COUNT}"

- name: Notify (Discord)
if: ${{ always() && inputs.discord-webhook != '' }}
shell: bash
env:
DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook }}
REPORTS: ${{ env.REPORTS }}
GITLEAKS_COUNT: ${{ steps.gitleaks.outputs.count }}
GITLEAKS_DURATION: ${{ steps.gitleaks.outputs.duration }}
SEMGREP_COUNT: ${{ steps.semgrep.outputs.count }}
SEMGREP_DURATION: ${{ steps.semgrep.outputs.duration }}
TRIVY_COUNT: ${{ steps.trivy.outputs.count }}
TRIVY_VULN: ${{ steps.trivy.outputs.vuln }}
TRIVY_MISCONFIG: ${{ steps.trivy.outputs.misconfig }}
TRIVY_LICENSE: ${{ steps.trivy.outputs.license }}
TRIVY_DURATION: ${{ steps.trivy.outputs.duration }}
ESLINT_COUNT: ${{ steps.eslint.outputs.count }}
ESLINT_DURATION: ${{ steps.eslint.outputs.duration }}
RUFF_COUNT: ${{ steps.ruff.outputs.count }}
RUFF_DURATION: ${{ steps.ruff.outputs.duration }}
RUST_COUNT: ${{ steps.rust.outputs.count }}
RUST_DURATION: ${{ steps.rust.outputs.duration }}
DOTNET_COUNT: ${{ steps.dotnet.outputs.count }}
DOTNET_DURATION: ${{ steps.dotnet.outputs.duration }}
LIZARD_COUNT: ${{ steps.lizard.outputs.count }}
LIZARD_DURATION: ${{ steps.lizard.outputs.duration }}
JSCPD_COUNT: ${{ steps.jscpd.outputs.count }}
JSCPD_DUPLICATED_LINES: ${{ steps.jscpd.outputs.duplicated_lines }}
JSCPD_DURATION: ${{ steps.jscpd.outputs.duration }}
CLAUDE_COUNT: ${{ steps.claude.outputs.count }}
CLAUDE_ENABLED: ${{ steps.claude.outputs.enabled }}
CLAUDE_DURATION: ${{ steps.claude.outputs.duration }}
HAS_JS: ${{ steps.detect.outputs.has_js }}
HAS_PY: ${{ steps.detect.outputs.has_py }}
HAS_RUST: ${{ steps.detect.outputs.has_rust }}
HAS_DOTNET: ${{ steps.detect.outputs.has_dotnet }}
ESLINT_CFG: ${{ steps.detect.outputs.eslint_cfg }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
COMMIT_SHA: ${{ github.sha }}
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node "${{ github.action_path }}/scripts/notify.mjs"

- name: Upload raw reports
if: always()
uses: actions/upload-artifact@v4
with:
name: securecheck-reports
path: ${{ env.REPORTS }}
if-no-files-found: ignore
retention-days: 14

- name: Severity gate
id: gate
if: always()
shell: bash
env:
FAIL_ON: ${{ inputs.fail-on }}
REPORTS: ${{ env.REPORTS }}
run: node "${{ github.action_path }}/scripts/gate.mjs"
Loading