Skip to content

feat(security): supply-chain security - SBOM, dependency pinning, pro… #6

feat(security): supply-chain security - SBOM, dependency pinning, pro…

feat(security): supply-chain security - SBOM, dependency pinning, pro… #6

Workflow file for this run

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

Check failure on line 280 in .github/workflows/ci.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/ci.yml

Invalid workflow file

You have an error in your yaml syntax on line 280
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