Repository navigation
feat(security): supply-chain security - SBOM, dependency pinning and provenance verification #1001
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
343b081
6002bfb
dfdfff1
451a41c
be21772
8aa6174
865e120
3d5c48e
fe06b00
d3039c9
ccd7123
2d326cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,9 +26,9 @@ jobs: | |
| contracts: ${{ steps.filter.outputs.contracts }} | ||
| shared: ${{ steps.filter.outputs.shared }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: dorny/paths-filter@v3 | ||
| - uses: dorny/paths-filter@b41dfa943b1939b9b646f67753bfe35cf6e4de03 # v3.0.2 | ||
| id: filter | ||
| with: | ||
| # On push events, compare against the previous commit so the first | ||
|
|
@@ -62,11 +62,11 @@ jobs: | |
| if: needs.changes.outputs.website == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| with: | ||
| node-version: 20 | ||
| cache: pnpm | ||
|
|
@@ -90,9 +90,9 @@ jobs: | |
| run: | ||
| working-directory: apps/intelligence | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: actions/setup-python@v5 | ||
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.5.0 | ||
| with: | ||
| python-version: "3.12" | ||
|
|
||
|
|
@@ -117,11 +117,11 @@ jobs: | |
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
|
|
@@ -148,7 +148,7 @@ jobs: | |
| needs: changes | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
|
|
@@ -161,7 +161,7 @@ jobs: | |
| - name: Run gitleaks | ||
| run: gitleaks detect --source . --no-git --redact --no-banner --config .gitleaks.toml | ||
|
|
||
| - uses: dtolnay/rust-toolchain@stable | ||
| - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | ||
| if: needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
|
|
||
| - name: Install cargo-audit | ||
|
|
@@ -174,7 +174,7 @@ jobs: | |
| working-directory: packages/contracts | ||
| continue-on-error: true | ||
|
|
||
| - uses: actions/setup-go@v5 | ||
| - 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 | ||
|
|
@@ -192,10 +192,65 @@ jobs: | |
| OUTPUT=$(govulncheck ./... 2>&1) | ||
| EXIT_CODE=$? | ||
| echo "$OUTPUT" | ||
|
|
||
| # Validate .vulnignore unconditionally: an expired, malformed, or | ||
| # ownerless waiver fails the job regardless of scan results. The | ||
| # printed accepted IDs are used to filter findings below. | ||
| ACCEPTED=$(python3 - "${{ github.workspace }}/.vulnignore" <<'PY' | ||
| import re | ||
| import sys | ||
| from datetime import date | ||
| from pathlib import Path | ||
|
|
||
| path = Path(sys.argv[1]) | ||
| if not path.exists(): | ||
| print(f"::error::Missing waiver file: {path}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| text = path.read_text() | ||
| lines = text.splitlines() | ||
| errors = [] | ||
|
|
||
| # Vulnerability IDs are the bare "GO-YYYY-NNNN" lines in the file. | ||
| vuln_ids = [ln.strip() for ln in lines if re.fullmatch(r"GO-\d{4}-\d+", ln.strip())] | ||
| if not vuln_ids: | ||
| print("::error::No vulnerability waivers found in .vulnignore", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| for vid in vuln_ids: | ||
| idx = text.rfind(vid) | ||
| block = text[max(0, idx - 800):idx] | ||
| review = re.search(r"Review date:[ \t]*(\d{4}-\d{2}-\d{2})", block) | ||
| owner = re.search(r"Owner:[ \t]+\S+", block) | ||
| status = re.search(r"Status:[ \t]+\S+", block) | ||
| if not review: | ||
| errors.append(f"{vid}: missing required 'Review date: YYYY-MM-DD'") | ||
| else: | ||
| try: | ||
| if date.fromisoformat(review.group(1)) < date.today(): | ||
| errors.append( | ||
| f"{vid}: waiver review date {review.group(1)} has expired " | ||
| f"(today {date.today().isoformat()})" | ||
| ) | ||
| except ValueError: | ||
| errors.append(f"{vid}: invalid review date {review.group(1)}") | ||
| if not owner: | ||
| errors.append(f"{vid}: missing required 'Owner:'") | ||
| if not status: | ||
| errors.append(f"{vid}: missing required 'Status:'") | ||
|
|
||
| if errors: | ||
| for err in errors: | ||
| print(f"::error::{err}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| print(" ".join(vuln_ids)) | ||
| PY | ||
| ) || exit 1 | ||
|
|
||
| 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) | ||
|
|
@@ -219,10 +274,10 @@ jobs: | |
| working-directory: apps/api | ||
| continue-on-error: true | ||
|
|
||
| - uses: pnpm/action-setup@v4 | ||
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v4.1.0 | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| - uses: actions/setup-node@e51e5fe84fc33b4c73ebe40526b2694712b5b858 # v4.3.0 | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| with: | ||
| node-version: 22 | ||
|
|
@@ -238,7 +293,7 @@ jobs: | |
| working-directory: apps/dapp/frontend | ||
| continue-on-error: true | ||
|
|
||
| - uses: actions/setup-python@v5 | ||
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.5.0 | ||
| if: needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.shared == 'true' | ||
| with: | ||
| python-version: "3.12" | ||
|
|
@@ -261,7 +316,7 @@ jobs: | |
|
|
||
| - name: Semgrep SAST (TypeScript/Next.js) | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| uses: semgrep/semgrep-action@v1 | ||
| uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 | ||
| with: | ||
| config: >- | ||
| p/typescript | ||
|
|
@@ -271,6 +326,100 @@ jobs: | |
| 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' | ||
| env: | ||
| BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} | ||
| run: | | ||
| # Detect typosquatting patterns in npm packages newly added to the | ||
| # dapp manifest. Compares the base manifest (merge base for PRs, the | ||
| # previous commit for pushes) against the head manifest and checks only | ||
| # dependencies added in this change set, using full scoped package | ||
| # names so a spoofed @attacker/react cannot hide behind a name | ||
| # collision with the unscoped "react". | ||
| python3 - <<'PY' | ||
| import json | ||
| import os | ||
| import subprocess | ||
| from pathlib import Path | ||
|
|
||
| MANIFEST = "apps/dapp/frontend/package.json" | ||
|
|
||
| def load_manifest(ref): | ||
| """Return dependency names from the manifest at git ref (empty if unavailable).""" | ||
| if not ref: | ||
| return {} | ||
| try: | ||
| out = subprocess.run( | ||
| ["git", "show", f"{ref}:{MANIFEST}"], | ||
| capture_output=True, | ||
| text=True, | ||
| timeout=30, | ||
| ) | ||
| if out.returncode != 0: | ||
| return {} | ||
| return json.loads(out.stdout) | ||
| except Exception: | ||
| return {} | ||
|
|
||
| def dep_names(pkg): | ||
| return set(pkg.get("dependencies", {})) | set(pkg.get("devDependencies", {})) | ||
|
|
||
| base_pkg = load_manifest(os.environ.get("BASE_REF", "")) | ||
| head_pkg = json.loads(Path(MANIFEST).read_text()) | ||
| new_deps = dep_names(head_pkg) - dep_names(base_pkg) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- workflow context ---'
sed -n '320,395p' .github/workflows/ci.yml
printf '%s\n' '--- related dependency-analysis references ---'
rg -n -C 3 'dep_names|new_deps|optionalDependencies|Levenshtein|levenshtein' .github/workflows/ci.ymlRepository: Suncrest-Labs/nester Length of output: 4810 🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
import json
from pathlib import Path
text = Path(".github/workflows/ci.yml").read_text()
start = text.index(" def dep_names(pkg):")
end = text.index("\n", text.index(" new_deps =", start))
source = text[start:end]
print("--- extracted logic ---")
print(source)
# Model the exact set operation used by the workflow without executing repository code.
def current_dep_names(pkg):
return set(pkg.get("dependencies", {})) | set(pkg.get("devDependencies", {}))
base = {"dependencies": {"safe-package": "^1.0.0"}}
head = {
"dependencies": {"safe-package": "^1.0.0"},
"optionalDependencies": {"typo-squatted-package": "^1.0.0"},
}
print("--- behavior probe ---")
print("current head names:", sorted(current_dep_names(head)))
print("current new names:", sorted(current_dep_names(head) - current_dep_names(base)))
print("optional package detected:", "typo-squatted-package" in
(current_dep_names(head) - current_dep_names(base)))
PYRepository: Suncrest-Labs/nester Length of output: 610 Security Misconfiguration (CWE-693) Reachability: External Include 🤖 Prompt for AI Agents |
||
|
|
||
| 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 sorted(new_deps): | ||
| for known in known_packages: | ||
| if dep == known: | ||
| continue | ||
| if len(dep) > 3 and len(known) > 3: | ||
| distance = levenshtein(dep.lower(), known.lower()) | ||
| if 0 < distance <= 2: | ||
| suspicious.append((dep, known, distance)) | ||
|
|
||
| if suspicious: | ||
| print("::warning::Potential typosquatted dependencies detected:") | ||
| for dep, known, distance in suspicious: | ||
| print(f" {dep} (edit distance {distance} from {known})") | ||
| else: | ||
| print("No potential typosquats detected") | ||
| PY | ||
|
|
||
| api: | ||
| name: API (Go) | ||
| needs: changes | ||
|
|
@@ -312,9 +461,9 @@ jobs: | |
| # silently skips in CI even though the redis service below is running. | ||
| REDIS_ADDR: localhost:6379 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: actions/setup-go@v5 | ||
| - uses: actions/setup-go@5064d95ba18d4a9dda7c4cc67adeb03aea1a2a14 # v5.3.0 | ||
| with: | ||
| go-version-file: apps/api/go.mod | ||
| cache: true | ||
|
|
@@ -357,9 +506,9 @@ jobs: | |
| run: | ||
| working-directory: packages/contracts | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: dtolnay/rust-toolchain@stable | ||
| - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable | ||
| with: | ||
| targets: wasm32-unknown-unknown | ||
|
|
||
|
|
@@ -406,3 +555,68 @@ jobs: | |
| exit 1 | ||
| fi | ||
|
|
||
| sbom: | ||
| name: SBOM Generation | ||
| needs: changes | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v4.2.2 | ||
|
|
||
| - uses: actions/setup-go@5064d95ba18d4a9dda7c4cc67adeb03aea1a2a14 # v5.3.0 | ||
| with: | ||
| go-version-file: apps/api/go.mod | ||
|
|
||
| - name: Install cyclonedx-gomod (Go, pinned release) | ||
| run: go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@v1.10.0 | ||
|
|
||
| - name: Install syft (pinned release, checksum-verified) | ||
| run: | | ||
| SYFT_VERSION=1.50.0 | ||
| ASSET="syft_${SYFT_VERSION}_linux_amd64.tar.gz" | ||
| curl -sSfL -o "/tmp/${ASSET}" \ | ||
| "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${ASSET}" | ||
| curl -sSfL -o "/tmp/syft_${SYFT_VERSION}_checksums.txt" \ | ||
| "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_checksums.txt" | ||
| cd /tmp | ||
| grep " ${ASSET}$" "syft_${SYFT_VERSION}_checksums.txt" | sha256sum -c - | ||
| tar -xzf "${ASSET}" | ||
| sudo mv /tmp/syft /usr/local/bin/syft | ||
|
|
||
| - 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 | ||
|
|
||
| - name: Generate frontend SBOM (npm/pnpm) | ||
| if: needs.changes.outputs.dapp == 'true' || needs.changes.outputs.shared == 'true' | ||
| working-directory: apps/dapp/frontend | ||
| run: | | ||
| # syft catalogs the pnpm lockfile directly. @cyclonedx/cyclonedx-npm | ||
| # dropped --pnpm support in v6, so the already-checksum-verified syft | ||
| # binary (installed above) is used for the frontend SBOM. | ||
| syft dir:. --output cyclonedx-json=../../../sbom-frontend.json | ||
|
|
||
| - 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 | ||
|
|
||
| - 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 | ||
|
|
||
| - name: Upload SBOM artifacts | ||
| # Only run when at least one SBOM-covered component changed; a docs- or | ||
| # website-only change generates no SBOMs and must not fail the job. | ||
| if: needs.changes.outputs.api == 'true' || needs.changes.outputs.dapp == 'true' || needs.changes.outputs.intelligence == 'true' || needs.changes.outputs.contracts == 'true' || needs.changes.outputs.shared == 'true' | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 | ||
| with: | ||
| name: sbom-artifacts | ||
| path: sbom-*.json | ||
| if-no-files-found: error | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.