#!/usr/bin/env bash # lysbor-scan: generate a CycloneDX SBOM with Syft (dependencies / CVE), optionally run Semgrep (SAST) # and Trivy (misconfigurations, secrets), and send everything to Lysbor. # # lysbor-scan [options] [TARGET] TARGET = a directory (default: .), a container image (image:tag), # or an existing SBOM / lockfile (sent as is) # lysbor-scan gate [--base FILE] [--strict] [--comment] [--soft] LOCKFILE # pull-request gate: checks the packages LOCKFILE adds or upgrades compared with # --base (the target branch's lockfile); exit 2 on a refused package, 3 on a warning # with --strict, 4 when Lysbor cannot be reached (0 with --soft). --comment posts the # result on the pull request (GitHub: GITHUB_TOKEN ; GitLab: GITLAB_TOKEN). # lysbor-scan install-tools [--bin DIR] [--syft VERSION] [--trivy VERSION] [syft] [trivy] # installs Syft and/or Trivy (default: both) from their published release archives, # SHA-256 verified against the release's checksum file; no script piped into a shell. # Versions: SYFT_VERSION / TRIVY_VERSION or the flags (default: the pinned ones below). # Options: # --sast run Semgrep and upload its SARIF (code analysis) # --trivy run Trivy misconfiguration + secret scanning and upload its SARIF # --all --sast --trivy # --sarif FILE upload an existing SARIF report (ZAP, gitleaks, Checkov, CodeQL…); repeatable # --no-sbom skip the SBOM (only SARIF uploads) # --no-dev leave npm/yarn/pnpm devDependencies out of a directory scan (default: included, as GitHub does) # --fail-on LEVEL critical|high|medium|low|none (default none): exit 2 when open findings >= LEVEL # Environment (or flags): # LYSBOR_URL https://lysbor.example.com (--url) # LYSBOR_API_KEY lys_... (--api-key) # LYSBOR_PROJECT project id (UUID) (--project) # LYSBOR_FAIL_ON same as --fail-on # LYSBOR_INCLUDE_DEV true|false (default true): same as --no-dev when false. Only applies to directory scans: an # image scan lists what is installed. An explicit SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES wins. # SYFT_ARGS extra arguments for syft (e.g. --exclude ./tests/** --exclude ./fixtures/**), split on spaces # Test fixtures and demo inventories are cataloged like the rest: exclude them. # SEMGREP_CONFIG Semgrep config (default: p/default ; e.g. p/security-audit or a local rules file) # TRIVY_ARGS extra arguments for trivy (e.g. --severity HIGH,CRITICAL) set -euo pipefail # ------------------------------------------------------------------ gate (pull-request guard) if [ "${1:-}" = "gate" ]; then shift BASE=""; HEADF=""; STRICT=0; COMMENT=0; SOFT=0 while [ $# -gt 0 ]; do case "$1" in --base) BASE="$2"; shift 2 ;; --strict) STRICT=1; shift ;; --comment) COMMENT=1; shift ;; --soft) SOFT=1; shift ;; --url) LYSBOR_URL="$2"; shift 2 ;; --api-key) LYSBOR_API_KEY="$2"; shift 2 ;; -h|--help) sed -n '2,12p' "$0"; exit 0 ;; *) HEADF="$1"; shift ;; esac done : "${LYSBOR_URL:?LYSBOR_URL is required (https://lysbor.example.com)}" : "${LYSBOR_API_KEY:?LYSBOR_API_KEY is required (a key with the read scope is enough)}" [ -n "$HEADF" ] && [ -f "$HEADF" ] || { echo "lysbor-scan gate: give the lockfile of the branch (package-lock.json, requirements.txt, go.sum, …)" >&2; exit 1; } LYSBOR_URL="${LYSBOR_URL%/}" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT NAME="$(basename "$HEADF")" ARGS=(-F "head=@$HEADF;filename=$NAME") if [ -n "$BASE" ] && [ -s "$BASE" ]; then ARGS+=(-F "base=@$BASE;filename=$NAME"); else echo "lysbor-scan gate: no base lockfile, every package counts as added"; fi HTTP=$(curl -sS -o "$WORK/resp.json" -w "%{http_code}" -X POST "$LYSBOR_URL/api/v1/gate" -H "Authorization: Bearer $LYSBOR_API_KEY" -H "User-Agent: lysbor-scan/1.3" "${ARGS[@]}" 2>/dev/null) || HTTP=000 if [ "$HTTP" != "200" ]; then echo "lysbor-scan gate: NON VÉRIFIÉ, Lysbor n'a pas répondu (HTTP $HTTP): $(head -c 300 "$WORK/resp.json" 2>/dev/null)" >&2 [ "$SOFT" -eq 1 ] && exit 0 || exit 4 fi if command -v jq >/dev/null 2>&1; then jq -r '.text' "$WORK/resp.json"; VERDICT=$(jq -r '.verdict' "$WORK/resp.json") else python3 -c "import json; d=json.load(open('$WORK/resp.json')); print(d['text'])"; VERDICT=$(python3 -c "import json; print(json.load(open('$WORK/resp.json'))['verdict'])"); fi if [ "$COMMENT" -eq 1 ]; then if command -v jq >/dev/null 2>&1; then jq '{body: .markdown}' "$WORK/resp.json" > "$WORK/body.json"; else python3 -c "import json; d=json.load(open('$WORK/resp.json')); json.dump({'body': d['markdown']}, open('$WORK/body.json','w'))"; fi if [ -n "${GITHUB_TOKEN:-}" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ]; then if command -v jq >/dev/null 2>&1; then PRN=$(jq -r '.pull_request.number // .number // empty' "$GITHUB_EVENT_PATH"); else PRN=$(python3 -c "import json; d=json.load(open('$GITHUB_EVENT_PATH')); print((d.get('pull_request') or {}).get('number') or d.get('number') or '')"); fi if [ -n "$PRN" ]; then curl -sS -o /dev/null -w "lysbor-scan gate: comment posted on PR #$PRN (HTTP %{http_code})\n" -X POST "${GITHUB_API_URL:-https://api.github.com}/repos/$GITHUB_REPOSITORY/issues/$PRN/comments" -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" -H "Content-Type: application/json" --data-binary @"$WORK/body.json" || true else echo "lysbor-scan gate: not a pull request event, no comment posted"; fi elif [ -n "${GITLAB_TOKEN:-}" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then curl -sS -o /dev/null -w "lysbor-scan gate: note posted on MR !$CI_MERGE_REQUEST_IID (HTTP %{http_code})\n" -X POST "${CI_API_V4_URL:-https://gitlab.com/api/v4}/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" -H "PRIVATE-TOKEN: $GITLAB_TOKEN" -H "Content-Type: application/json" --data-binary @"$WORK/body.json" || true else echo "lysbor-scan gate: --comment needs GITHUB_TOKEN (pull_request event) or GITLAB_TOKEN + CI_MERGE_REQUEST_IID"; fi fi case "$VERDICT" in block) echo "lysbor-scan gate: paquet refusé -> exit 2"; exit 2 ;; warn) if [ "$STRICT" -eq 1 ]; then echo "lysbor-scan gate: avertissement en mode --strict -> exit 3"; exit 3; fi; exit 0 ;; unknown) echo "lysbor-scan gate: paquets non vérifiables"; [ "$SOFT" -eq 1 ] && exit 0 || exit 4 ;; *) exit 0 ;; esac fi # ------------------------------------------------------------------ install-tools (Syft, Trivy: release archives, checksum verified) SYFT_PINNED=1.52.0 TRIVY_PINNED=0.74.0 if [ "${1:-}" = "install-tools" ]; then shift BIN="/usr/local/bin"; WANT_SYFT=0; WANT_TRIVY=0 SYFT_V="${SYFT_VERSION:-$SYFT_PINNED}"; TRIVY_V="${TRIVY_VERSION:-$TRIVY_PINNED}" while [ $# -gt 0 ]; do case "$1" in --bin) BIN="$2"; shift 2 ;; --syft) SYFT_V="$2"; WANT_SYFT=1; shift 2 ;; --trivy) TRIVY_V="$2"; WANT_TRIVY=1; shift 2 ;; syft) WANT_SYFT=1; shift ;; trivy) WANT_TRIVY=1; shift ;; -h|--help) sed -n '12,15p' "$0"; exit 0 ;; *) echo "lysbor-scan install-tools: unknown argument '$1'" >&2; exit 1 ;; esac done [ "$WANT_SYFT$WANT_TRIVY" = "00" ] && { WANT_SYFT=1; WANT_TRIVY=1; } SYFT_V="${SYFT_V#v}"; TRIVY_V="${TRIVY_V#v}" case "$(uname -s)" in Linux) OS=linux; TOS=Linux ;; Darwin) OS=darwin; TOS=macOS ;; *) echo "lysbor-scan install-tools: unsupported OS $(uname -s)" >&2; exit 1 ;; esac case "$(uname -m)" in x86_64|amd64) ARCH=amd64; TARCH=64bit ;; aarch64|arm64) ARCH=arm64; TARCH=ARM64 ;; *) echo "lysbor-scan install-tools: unsupported architecture $(uname -m)" >&2; exit 1 ;; esac fetch() { if command -v curl >/dev/null 2>&1; then curl -sSfL -o "$2" "$1"; else wget -q -O "$2" "$1"; fi; } # $1 checksums file, $2 asset: the published SHA-256 must match the archive we downloaded (cwd = WORK) verify() { if command -v sha256sum >/dev/null 2>&1; then grep " $2\$" "$1" | sha256sum -c - >/dev/null; else grep " $2\$" "$1" | shasum -a 256 -c - >/dev/null; fi; } WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT mkdir -p "$BIN" # $1 tool, $2 version, $3 asset, $4 release base URL, $5 checksums file name install_release() { fetch "$4/$3" "$WORK/$3" || { echo "lysbor-scan install-tools: download failed: $4/$3" >&2; exit 1; } fetch "$4/$5" "$WORK/$5" || { echo "lysbor-scan install-tools: download failed: $4/$5" >&2; exit 1; } (cd "$WORK" && verify "$5" "$3") || { echo "lysbor-scan install-tools: SHA-256 mismatch for $3, refusing to install" >&2; exit 1; } tar -xzf "$WORK/$3" -C "$WORK" "$1" && install -m 755 "$WORK/$1" "$BIN/$1" echo "lysbor-scan install-tools: $1 $2 -> $BIN/$1 (SHA-256 verified)" } [ "$WANT_SYFT" -eq 1 ] && install_release syft "$SYFT_V" "syft_${SYFT_V}_${OS}_${ARCH}.tar.gz" "https://github.com/anchore/syft/releases/download/v${SYFT_V}" "syft_${SYFT_V}_checksums.txt" [ "$WANT_TRIVY" -eq 1 ] && install_release trivy "$TRIVY_V" "trivy_${TRIVY_V}_${TOS}-${TARCH}.tar.gz" "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_V}" "trivy_${TRIVY_V}_checksums.txt" exit 0 fi TARGET="." FAIL_ON="${LYSBOR_FAIL_ON:-none}" DO_SBOM=1; DO_SAST=0; DO_TRIVY=0 INCLUDE_DEV="${LYSBOR_INCLUDE_DEV:-true}" SARIF_FILES=() while [ $# -gt 0 ]; do case "$1" in --url) LYSBOR_URL="$2"; shift 2 ;; --api-key) LYSBOR_API_KEY="$2"; shift 2 ;; --project) LYSBOR_PROJECT="$2"; shift 2 ;; --fail-on) FAIL_ON="$2"; shift 2 ;; --sast) DO_SAST=1; shift ;; --trivy) DO_TRIVY=1; shift ;; --all) DO_SAST=1; DO_TRIVY=1; shift ;; --sarif) SARIF_FILES+=("$2"); shift 2 ;; --no-sbom) DO_SBOM=0; shift ;; --no-dev) INCLUDE_DEV=false; shift ;; -h|--help) sed -n '2,34p' "$0"; exit 0 ;; *) TARGET="$1"; shift ;; esac done : "${LYSBOR_URL:?LYSBOR_URL is required (https://lysbor.example.com)}" : "${LYSBOR_API_KEY:?LYSBOR_API_KEY is required (create it in Paramètres > Clés API)}" : "${LYSBOR_PROJECT:?LYSBOR_PROJECT is required (project id, see the Intégration CI tab of the project)}" LYSBOR_URL="${LYSBOR_URL%/}" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT python_or_jq() { # $1 jq expr, $2 python expr — reads $WORK/resp.json if command -v jq >/dev/null 2>&1; then jq -r "$1" "$WORK/resp.json" else python3 -c "import json,sys; d=json.load(open('$WORK/resp.json')); print(d$2)"; fi } upload() { # $1 endpoint (sboms|sarif), $2 file, $3 filename HTTP=$(curl -sS -o "$WORK/resp.json" -w "%{http_code}" -X POST "$LYSBOR_URL/api/v1/projects/$LYSBOR_PROJECT/$1" \ -H "Authorization: Bearer $LYSBOR_API_KEY" -H "User-Agent: lysbor-scan/1.3" -F "file=@$2;filename=$3") if [ "$HTTP" != "201" ]; then echo "lysbor-scan: upload to $1 failed (HTTP $HTTP): $(cat "$WORK/resp.json" 2>/dev/null | head -c 600)" >&2 exit 1 fi } sev_rank() { case "$1" in critical) echo 4 ;; high) echo 3 ;; medium) echo 2 ;; low) echo 1 ;; none) echo 0 ;; *) echo "lysbor-scan: unknown --fail-on value '$1'" >&2; exit 1 ;; esac; } THRESHOLD=$(sev_rank "$FAIL_ON") FAILED=0 check_summary() { # $1 label ; reads summary counters from $WORK/resp.json under $2 (json path prefix) CRIT=$(python_or_jq "$2.critical // 0" "$3.get('critical',0)"); HIGH=$(python_or_jq "$2.high // 0" "$3.get('high',0)") MED=$(python_or_jq "$2.medium // 0" "$3.get('medium',0)"); LOW=$(python_or_jq "$2.low // 0" "$3.get('low',0)") NEW=$(python_or_jq '.new_findings' "['new_findings']") echo "lysbor-scan: $1 -> critical=$CRIT high=$HIGH medium=$MED low=$LOW (new since last upload: $NEW)" local hit=0 [ "$THRESHOLD" -ge 1 ] && [ "$THRESHOLD" -le 4 ] && [ "$CRIT" -gt 0 ] && hit=1 [ "$THRESHOLD" -ge 1 ] && [ "$THRESHOLD" -le 3 ] && [ "$HIGH" -gt 0 ] && hit=1 [ "$THRESHOLD" -ge 1 ] && [ "$THRESHOLD" -le 2 ] && [ "$MED" -gt 0 ] && hit=1 [ "$THRESHOLD" -eq 1 ] && [ "$LOW" -gt 0 ] && hit=1 [ "$hit" -eq 1 ] && FAILED=1 return 0 } # ------------------------------------------------------------------ SBOM (dependencies) if [ "$DO_SBOM" -eq 1 ]; then if [ -f "$TARGET" ]; then FILENAME="$(basename "$TARGET")"; SBOM="$TARGET" echo "lysbor-scan: sending $FILENAME as is" else command -v syft >/dev/null 2>&1 || { echo "lysbor-scan: syft not found. Install: lysbor-scan install-tools syft" >&2; exit 1; } if [ -d "$TARGET" ]; then SRC="dir:$TARGET"; NAME="$(basename "$(cd "$TARGET" && pwd)")"; else SRC="$TARGET"; NAME="$TARGET"; fi # Syft leaves npm devDependencies out by default: a repository scan then misses the whole build chain (bundler, # linters, test runner and their native binaries) that GitHub's dependency graph lists, and a compromised build # plugin ships in the product. Included by default for a directory; an image scan keeps what is installed. if [ -d "$TARGET" ] && [ -z "${SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES:-}" ]; then case "$INCLUDE_DEV" in false|0|no) export SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES=false; echo "lysbor-scan: npm devDependencies left out (--no-dev)" ;; *) export SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES=true ;; esac fi echo "lysbor-scan: generating SBOM for $SRC with syft $(syft version -o raw 2>/dev/null || true)" SBOM="$WORK/sbom.cdx.json" # Extra arguments are split on spaces without pathname expansion: a pattern such as ./tests/** must reach syft as is. set -f; read -r -a SYFT_EXTRA <<< "${SYFT_ARGS:-}"; set +f syft "$SRC" -o cyclonedx-json="$SBOM" -q ${SYFT_EXTRA[@]+"${SYFT_EXTRA[@]}"} # Syft does not say which packages come from devDependencies. A second pass without them gives the runtime set: # every npm component missing from it is marked scope "excluded" (CycloneDX: not part of the runtime), so Lysbor # can tell the build chain from what ships. Same set as the "dev": true flags of package-lock.json. if [ -d "$TARGET" ] && [ "${SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES:-}" = "true" ]; then SYFT_JAVASCRIPT_INCLUDE_DEV_DEPENDENCIES=false syft "$SRC" -o cyclonedx-json="$WORK/runtime.cdx.json" -q ${SYFT_EXTRA[@]+"${SYFT_EXTRA[@]}"} if command -v jq >/dev/null 2>&1; then jq --slurpfile rt "$WORK/runtime.cdx.json" '([$rt[0].components[]?.purl // empty] | map({key: ., value: true}) | from_entries) as $run | .components |= map(if ((.purl // "") | startswith("pkg:npm/")) and ($run[.purl] | not) then .scope = "excluded" else . end)' \ "$SBOM" > "$WORK/marked.cdx.json" else python3 -c " import json, sys sbom, rt, out = sys.argv[1:] run = {c.get('purl') for c in json.load(open(rt)).get('components', [])} d = json.load(open(sbom)) for c in d.get('components', []): if (c.get('purl') or '').startswith('pkg:npm/') and c['purl'] not in run: c['scope'] = 'excluded' json.dump(d, open(out, 'w'))" "$SBOM" "$WORK/runtime.cdx.json" "$WORK/marked.cdx.json" fi mv "$WORK/marked.cdx.json" "$SBOM" DEVN=$(grep -o '"scope": *"excluded"' "$SBOM" | wc -l | tr -d ' ') echo "lysbor-scan: $DEVN npm components only used to build or test (scope excluded)" fi FILENAME="sbom-$(echo "$NAME" | tr -c 'A-Za-z0-9._-' '-').cdx.json" fi echo "lysbor-scan: uploading SBOM to $LYSBOR_URL (project $LYSBOR_PROJECT)" upload sboms "$SBOM" "$FILENAME" COMPONENTS=$(python_or_jq '.sbom.component_count' "['sbom']['component_count']") echo "lysbor-scan: $COMPONENTS components analysed" check_summary "dependencies" ".sbom.findings_summary" "['sbom']['findings_summary']" fi # ------------------------------------------------------------------ Semgrep (SAST) if [ "$DO_SAST" -eq 1 ]; then if [ -f "$TARGET" ] || [ ! -d "$TARGET" ]; then echo "lysbor-scan: --sast needs a directory target" >&2; exit 1; fi command -v semgrep >/dev/null 2>&1 || { echo "lysbor-scan: semgrep not found. Install: pip install semgrep" >&2; exit 1; } echo "lysbor-scan: running semgrep ($(semgrep --version 2>/dev/null | head -1)) with config ${SEMGREP_CONFIG:-p/default}" semgrep scan --config "${SEMGREP_CONFIG:-p/default}" --sarif -o "$WORK/semgrep.sarif" --metrics=off --quiet "$TARGET" || true [ -s "$WORK/semgrep.sarif" ] || { echo "lysbor-scan: semgrep produced no report" >&2; exit 1; } upload sarif "$WORK/semgrep.sarif" "semgrep.sarif" check_summary "semgrep" ".scan.summary" "['scan']['summary']" fi # ------------------------------------------------------------------ Trivy (misconfigurations + secrets) if [ "$DO_TRIVY" -eq 1 ]; then command -v trivy >/dev/null 2>&1 || { echo "lysbor-scan: trivy not found. Install: lysbor-scan install-tools trivy" >&2; exit 1; } if [ -d "$TARGET" ]; then MODE=fs; elif [ -f "$TARGET" ]; then MODE=fs; else MODE=image; fi echo "lysbor-scan: running trivy $MODE ($(trivy --version 2>/dev/null | head -1)) scanners=misconfig,secret" set -f; read -r -a TRIVY_EXTRA <<< "${TRIVY_ARGS:-}"; set +f trivy "$MODE" --scanners misconfig,secret --format sarif -o "$WORK/trivy.sarif" --quiet ${TRIVY_EXTRA[@]+"${TRIVY_EXTRA[@]}"} "$TARGET" || true [ -s "$WORK/trivy.sarif" ] || { echo "lysbor-scan: trivy produced no report" >&2; exit 1; } upload sarif "$WORK/trivy.sarif" "trivy.sarif" check_summary "trivy" ".scan.summary" "['scan']['summary']" fi # ------------------------------------------------------------------ Any other SARIF (ZAP, gitleaks, Checkov, CodeQL…) for f in "${SARIF_FILES[@]:-}"; do [ -n "$f" ] || continue [ -f "$f" ] || { echo "lysbor-scan: SARIF file not found: $f" >&2; exit 1; } upload sarif "$f" "$(basename "$f")" check_summary "$(basename "$f")" ".scan.summary" "['scan']['summary']" done if [ "$FAILED" -eq 1 ]; then echo "lysbor-scan: open findings at or above '$FAIL_ON' -> failing (exit 2)" exit 2 fi echo "lysbor-scan: done"