Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
258 changes: 236 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:'")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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)
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.yml

Repository: 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)))
PY

Repository: Suncrest-Labs/nester

Length of output: 610


Security Misconfiguration (CWE-693)

Reachability: External

Include optionalDependencies in dep_names at .github/workflows/ci.yml:365-366. New optional packages currently bypass the Levenshtein typosquat check and warning path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 365 - 370, Update the dep_names
function to include keys from optionalDependencies alongside dependencies and
devDependencies, so newly added optional packages are included in the existing
typosquat detection and warning flow.


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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Loading
Loading