feat(security): supply-chain security - SBOM, dependency pinning, pro… #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | ||
| on: | ||
| push: | ||
| branches: ["*"] | ||
| pull_request: | ||
| branches: ["*"] | ||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | ||
| cancel-in-progress: true | ||
| jobs: | ||
| # Detect which areas of the monorepo changed so downstream jobs can skip | ||
| # work they don't need to run. Each filter is an OR of globs; if ANY match, | ||
| # that area's job will execute its steps. Workflow/shared changes force | ||
| # everything to run. | ||
| changes: | ||
| name: Detect changes | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| website: ${{ steps.filter.outputs.website }} | ||
| intelligence: ${{ steps.filter.outputs.intelligence }} | ||
| dapp: ${{ steps.filter.outputs.dapp }} | ||
| api: ${{ steps.filter.outputs.api }} | ||
| contracts: ${{ steps.filter.outputs.contracts }} | ||
| shared: ${{ steps.filter.outputs.shared }} | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: dorny/paths-filter@b41dfa943b1939b9b646f67753bfe35cf6e4de03 # v3.0.2 | ||
| id: filter | ||
| with: | ||
| # On push events, compare against the previous commit so the first | ||
| # push to a branch doesn't mark every filter as "changed". On PRs | ||
| # this defaults to the merge base. | ||
| base: ${{ github.event_name == 'push' && github.event.before || '' }} | ||
| filters: | | ||
| shared: | ||
| - '.github/workflows/**' | ||
| - 'pnpm-lock.yaml' | ||
| - 'package.json' | ||
| - 'pnpm-workspace.yaml' | ||
| website: | ||
| - 'apps/website/**' | ||
| - 'pnpm-lock.yaml' | ||
| - 'pnpm-workspace.yaml' | ||
| intelligence: | ||
| - 'apps/intelligence/**' | ||
| dapp: | ||
| - 'apps/dapp/**' | ||
| api: | ||
| - 'apps/api/**' | ||
| contracts: | ||
| - 'packages/contracts/**' | ||
| - 'scripts/contract-audit.sh' | ||
| - '.github/workflows/contract-audit.yml' | ||
| website: | ||
| name: Website (Next.js) | ||
| needs: changes | ||
| if: needs.changes.outputs.website == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| with: | ||
| node-version: 20 | ||
| cache: pnpm | ||
| - name: Install dependencies | ||
| run: pnpm install --frozen-lockfile --ignore-scripts | ||
| - name: Build website | ||
| run: pnpm --filter @nester/website build | ||
| - name: Lint website | ||
| run: pnpm --filter @nester/website lint | ||
| intelligence: | ||
| name: Intelligence (Python) | ||
| needs: changes | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: apps/intelligence | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.5.0 | ||
| with: | ||
| python-version: "3.12" | ||
| - name: Install dependencies | ||
| run: | | ||
| pip install -r requirements.txt | ||
| pip install ruff mypy pytest | ||
| - name: Lint | ||
| run: ruff check . | ||
| - name: Type check | ||
| run: mypy app | ||
| - name: Test | ||
| run: pytest | ||
| dapp-frontend: | ||
| name: Dapp Frontend (Next.js) | ||
| needs: changes | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
| - name: Install dependencies | ||
| run: pnpm install --frozen-lockfile --ignore-scripts | ||
| - name: Lint (security rules) | ||
| run: pnpm --filter @nester/dapp run lint | ||
| continue-on-error: true | ||
| - name: Build | ||
| run: pnpm --filter @nester/dapp run build | ||
| - name: Test | ||
| run: pnpm --filter @nester/dapp run test:coverage | ||
| - name: Audit JS dependencies (dapp) | ||
| run: pnpm audit --audit-level=moderate | ||
| continue-on-error: true | ||
| security: | ||
| name: Security Scanning | ||
| needs: changes | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| with: | ||
| fetch-depth: 0 | ||
| - name: Install gitleaks | ||
| run: | | ||
| curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.27.2/gitleaks_8.27.2_linux_x64.tar.gz \ | ||
| | tar -xz gitleaks | ||
| sudo mv gitleaks /usr/local/bin/gitleaks | ||
| - name: Run gitleaks | ||
| run: gitleaks detect --source . --no-git --redact --no-banner --config .gitleaks.toml | ||
| - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| - name: Install cargo-audit | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: cargo install cargo-audit --locked | ||
| - name: Audit Rust dependencies | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: cargo audit | ||
| working-directory: packages/contracts | ||
| continue-on-error: true | ||
| - uses: actions/setup-go@5064d95ba18d4a9dda7c4cc67adeb03aea1a2a14 # v5.3.0 | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| with: | ||
| go-version-file: apps/api/go.mod | ||
| cache: true | ||
| - name: Install govulncheck | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: go install golang.org/x/vuln/cmd/govulncheck@latest | ||
| - name: Audit Go dependencies | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: apps/api | ||
| run: | | ||
| set +e | ||
| OUTPUT=$(govulncheck ./... 2>&1) | ||
| EXIT_CODE=$? | ||
| echo "$OUTPUT" | ||
| if [ $EXIT_CODE -eq 0 ]; then | ||
| exit 0 | ||
| elif [ $EXIT_CODE -eq 3 ]; then | ||
| ACCEPTED="GO-2026-4316 GO-2026-5004 GO-2026-5037 GO-2026-5039" | ||
| UNKNOWN=$(echo "$OUTPUT" | grep -oE 'GO-[0-9]{4}-[0-9]+' | sort -u | while read id; do | ||
| echo "$ACCEPTED" | grep -qw "$id" || echo "$id" | ||
| done) | ||
| if [ -z "$UNKNOWN" ]; then | ||
| echo "⚠️ Only known/accepted vulnerabilities found" | ||
| exit 0 | ||
| fi | ||
| echo "Unknown vulnerabilities: $UNKNOWN" | ||
| exit 3 | ||
| else | ||
| exit $EXIT_CODE | ||
| fi | ||
| - name: Install gosec | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: go install github.com/securego/gosec/v2/cmd/gosec@latest | ||
| - name: Run gosec | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: gosec -severity medium ./... | ||
| working-directory: apps/api | ||
| continue-on-error: true | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
| - name: Install dapp dependencies for audit | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: pnpm install --frozen-lockfile --ignore-scripts | ||
| - name: Audit JavaScript dependencies | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: pnpm audit --audit-level=high | ||
| working-directory: apps/dapp/frontend | ||
| continue-on-error: true | ||
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.5.0 | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| with: | ||
| python-version: "3.12" | ||
| - name: Install Python audit tools | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: pip install pip-audit bandit | ||
| - name: Audit Python dependencies | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: pip-audit -r requirements.txt | ||
| working-directory: apps/intelligence | ||
| continue-on-error: true | ||
| - name: Run bandit | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: bandit -r app -ll | ||
| working-directory: apps/intelligence | ||
| continue-on-error: true | ||
| - name: Semgrep SAST (TypeScript/Next.js) | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 | ||
| with: | ||
| config: >- | ||
| p/typescript | ||
| p/react | ||
| p/nextjs | ||
| p/secrets | ||
| env: | ||
| SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} | ||
| - name: Typosquat detection (new npm dependencies) | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| run: | | ||
| # Check for typosquatting patterns in package.json dependencies | ||
| # Uses npm package name similarity to detect potential typosquats | ||
| python3 -c " | ||
| import json, sys, re | ||
| from pathlib import Path | ||
| pkg = json.loads(Path('apps/dapp/frontend/package.json').read_text()) | ||
| known_packages = {'react', 'react-dom', 'next', 'next/router', 'next/navigation', | ||
| '@tanstack/react-query', 'framer-motion', 'recharts', 'zod', 'react-hook-form', | ||
| 'zustand', 'uuid', 'date-fns', 'ethers', 'viem', 'wagmi', 'axios', 'lodash', | ||
| '@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', '@radix-ui/react-tabs', | ||
| '@radix-ui/react-tooltip', '@radix-ui/react-popover', '@radix-ui/react-select', | ||
| 'tailwindcss', 'postcss', 'autoprefixer', 'typescript', 'vitest', 'eslint', | ||
| '@typescript-eslint/eslint-plugin', '@typescript-eslint/parser', 'prettier', | ||
| 'clsx', 'tailwind-merge', 'lucide-react', 'class-variance-authority', | ||
| '@hookform/resolvers', 'sonner', 'react-hot-toast', 'next-themes', | ||
| '@starknet-react/core', '@starknet-react/chains', 'starknet', | ||
| '@stellar/stellar-sdk', '@blend-capital/blend-sdk'} | ||
| def levenshtein(a, b): | ||
| m, n = len(a), len(b) | ||
| dp = [[0]*(n+1) for _ in range(m+1)] | ||
| for i in range(m+1): dp[i][0] = i | ||
| for j in range(n+1): dp[0][j] = j | ||
| for i in range(1, m+1): | ||
| for j in range(1, n+1): | ||
| dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, | ||
| dp[i-1][j-1] + (a[i-1] != b[j-1])) | ||
| return dp[m][n] | ||
| suspicious = [] | ||
| for dep in pkg.get('dependencies', {}): | ||
| name = dep.split('/')[-1] if '/' in dep else dep | ||
| for known in known_packages: | ||
| kname = known.split('/')[-1] if '/' in known else known | ||
| if name != kname and len(name) > 3 and len(kname) > 3: | ||
| d = levenshtein(name.lower(), kname.lower()) | ||
| if d <= 2 and d > 0: | ||
| suspicious.append((dep, known, d)) | ||
| for dep in pkg.get('devDependencies', {}): | ||
| name = dep.split('/')[-1] if '/' in dep else dep | ||
| for known in known_packages: | ||
| kname = known.split('/')[-1] if '/' in known else known | ||
| if name != kname and len(name) > 3 and len(kname) > 3: | ||
| d = levenshtein(name.lower(), kname.lower()) | ||
| if d <= 2 and d > 0: | ||
| suspicious.append((dep, known, d)) | ||
| if suspicious: | ||
| print('::warning::Potential typosquatted dependencies detected:') | ||
| for dep, known, dist in suspicious: | ||
| print(f' {dep} (edit distance {dist} from {known})' | ||
| else: | ||
| print('No potential typosquats detected') | ||
| " | ||
| api: | ||
| name: API (Go) | ||
| needs: changes | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: apps/api | ||
| services: | ||
| postgres: | ||
| image: postgres:16 | ||
| env: | ||
| POSTGRES_USER: nester | ||
| POSTGRES_PASSWORD: nester | ||
| POSTGRES_DB: nester_test | ||
| ports: | ||
| - 5432:5432 | ||
| options: >- | ||
| --health-cmd pg_isready | ||
| --health-interval 10s | ||
| --health-timeout 5s | ||
| --health-retries 5 | ||
| redis: | ||
| image: redis:7 | ||
| ports: | ||
| - 6379:6379 | ||
| options: >- | ||
| --health-cmd "redis-cli ping" | ||
| --health-interval 10s | ||
| --health-timeout 5s | ||
| --health-retries 5 | ||
| env: | ||
| DATABASE_URL: postgres://nester:nester@localhost:5432/nester_test?sslmode=disable | ||
| REDIS_URL: redis://localhost:6379 | ||
| # Redis-backed tests (internal/cache, internal/notifications, | ||
| # internal/ws) skip via t.Skip when this is unset, following the | ||
| # convention in internal/middleware/ratelimit_backend_test.go. REDIS_URL | ||
| # above is what the app itself reads; without this, every such test | ||
| # silently skips in CI even though the redis service below is running. | ||
| REDIS_ADDR: localhost:6379 | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: actions/setup-go@5064d95ba18d4a9dda7c4cc67adeb03aea1a2a14 # v5.3.0 | ||
| with: | ||
| go-version-file: apps/api/go.mod | ||
| cache: true | ||
| - name: Check migration collisions | ||
| run: | | ||
| COLLISIONS=$(ls migrations/*.sql \ | ||
| | sed -E 's/\.(up|down)\.sql$//' \ | ||
| | sort -u \ | ||
| | sed -E 's/.*\/([0-9]+)_.*/\1/' \ | ||
| | sort | uniq -d) | ||
| if [ -n "$COLLISIONS" ]; then | ||
| echo "FAIL: duplicate migration prefixes found:" | ||
| for prefix in $COLLISIONS; do | ||
| echo "Prefix $prefix:" | ||
| ls migrations/${prefix}_*.sql | ||
| done | ||
| exit 1 | ||
| else | ||
| echo "OK: No migration prefix collisions." | ||
| fi | ||
| - name: Build | ||
| run: go build ./... | ||
| - name: Test (unit) | ||
| run: go test -race -timeout 120s -count=1 -short ./... | ||
| - name: Test (integration) | ||
| run: go test -race -timeout 120s -count=1 -run Integration ./... | ||
| continue-on-error: true | ||
| contracts: | ||
| name: Contracts (Rust) | ||
| needs: changes | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| defaults: | ||
| run: | ||
| working-directory: packages/contracts | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable | ||
| with: | ||
| targets: wasm32-unknown-unknown | ||
| - name: Add wasm32-unknown-unknown target | ||
| run: rustup target add wasm32-unknown-unknown | ||
| - name: Build yield_registry WASM | ||
| run: cargo build --release --target wasm32-unknown-unknown -p yield-registry-contract | ||
| - name: Ensure yield_registry.wasm is available for allocation_strategy | ||
| run: test -f target/wasm32-unknown-unknown/release/yield_registry.wasm | ||
| - name: Build allocation_strategy WASM | ||
| run: cargo build --release --target wasm32-unknown-unknown -p allocation-strategy-contract | ||
| - name: Build vault_token WASM (required for vault-contract tests) | ||
| run: cargo build --release --target wasm32-unknown-unknown -p vault-token-contract | ||
| - name: Ensure vault_token.wasm is available for vault-contract tests | ||
| run: test -f target/wasm32-unknown-unknown/release/vault_token.wasm | ||
| - name: Test contracts | ||
| run: cargo test --lib | ||
| # The vault contract's integration tests link against the pre-compiled | ||
| # vault_token.wasm artifact built above. Run them explicitly and fail the | ||
| # job if zero tests execute — a green run with no tests would otherwise | ||
| # give false confidence that the contract logic was verified. | ||
| - name: Run vault-contract tests | ||
| run: | | ||
| # Capture output but still fail the job if the build/test command | ||
| # itself errors (e.g. a missing WASM artifact aborts compilation). | ||
| if ! output=$(cargo test -p vault-contract 2>&1); then | ||
| echo "$output" | ||
| echo "ERROR: vault-contract tests failed to build or run" | ||
| exit 1 | ||
| fi | ||
| echo "$output" | ||
| # \b0 only matches a standalone zero, so "10 tests" / "40 passed" | ||
| # never trip this guard — only a genuinely empty run does. | ||
| if echo "$output" | grep -qE '\b0 tests'; then | ||
| echo "ERROR: No vault-contract tests ran — possible missing WASM artifact" | ||
| exit 1 | ||
| fi | ||
| sbom: | ||
| name: SBOM Generation | ||
| needs: changes | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| - name: Install cyclonedx-bom (Go) | ||
| run: go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1 | ||
| - name: Install syft | ||
| run: | | ||
| curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.19.0 | ||
| - name: Generate Go API SBOM | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: apps/api | ||
| run: | | ||
| cyclonedx-gomod mod -json -output ../../sbom-api.json . | ||
| syft dir:. --output cyclonedx-json=sbom-api-syft.json 2>/dev/null || true | ||
| - name: Generate frontend SBOM (npm) | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: apps/dapp/frontend | ||
| run: | | ||
| npx @cyclonedx/cyclonedx-npm --output-file ../../../sbom-frontend.json --pnpm 2>/dev/null || \ | ||
| npx @cyclonedx/cyclonedx-npm --output-file ../../../sbom-frontend.json 2>/dev/null || true | ||
| - name: Generate Python SBOM | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: apps/intelligence | ||
| run: | | ||
| syft dir:. --output cyclonedx-json=../../sbom-intelligence.json 2>/dev/null || true | ||
| - name: Generate contracts SBOM (Rust) | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: packages/contracts | ||
| run: | | ||
| syft dir:. --output cyclonedx-json=../../sbom-contracts.json 2>/dev/null || true | ||
| - name: Upload SBOM artifacts | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 | ||
| with: | ||
| name: sbom-artifacts | ||
| path: sbom-*.json | ||
| if-no-files-found: warn | ||