Skip to content

Supply Chain Scanner: npm #687

Supply Chain Scanner: npm

Supply Chain Scanner: npm #687

name: "Supply Chain Scanner: npm"
on:
schedule:
- cron: '0 */3 * * *'
workflow_dispatch:
permissions:
contents: write
issues: write
jobs:
scan:
name: Scan npm packages
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Build knowing
run: GOWORK=off go build -o knowing ./cmd/knowing
- name: Fetch packages to scan
run: |
chmod +x scripts/fetch-recent-publishes.sh scripts/fetch-npm-recent-changes.sh scripts/check-isolation-score.sh
# Try real-time feed, fall back to watchlist, fall back to inline
./scripts/fetch-npm-recent-changes.sh 180 50 > packages.txt 2>/dev/null || true
COUNT=$(grep -cv "^#" packages.txt 2>/dev/null || echo 0)
if [ "$COUNT" -lt 3 ]; then
./scripts/fetch-recent-publishes.sh > packages.txt 2>/dev/null || true
COUNT=$(grep -cv "^#" packages.txt 2>/dev/null || echo 0)
fi
if [ "$COUNT" -lt 1 ]; then
cat > packages.txt << 'FALLBACK'
express 5.1.0 5.0.1
axios 1.9.0 1.8.4
lodash 4.17.21 4.17.20
FALLBACK
fi
echo "Packages to scan:"
cat packages.txt
- name: Scan packages
run: |
mkdir -p scan-output
while IFS=' ' read -r pkg new_ver prev_ver; do
pkg=$(echo "$pkg" | tr -d '[:space:]')
new_ver=$(echo "$new_ver" | tr -d '[:space:]')
prev_ver=$(echo "$prev_ver" | tr -d '[:space:]')
[ -z "$pkg" ] && continue
[[ "$pkg" == \#* ]] && continue
echo ""
echo "=== Scanning $pkg: $prev_ver -> $new_ver ==="
WORK=$(mktemp -d)
npm pack "${pkg}@${prev_ver}" --pack-destination "$WORK" 2>/dev/null || { rm -rf "$WORK"; continue; }
npm pack "${pkg}@${new_ver}" --pack-destination "$WORK" 2>/dev/null || { rm -rf "$WORK"; continue; }
mkdir -p "$WORK/old" "$WORK/new"
for f in "$WORK"/*"${prev_ver}"*.tgz; do [ -f "$f" ] && tar xzf "$f" -C "$WORK/old" --strip-components=1 && break; done
for f in "$WORK"/*"${new_ver}"*.tgz; do [ -f "$f" ] && tar xzf "$f" -C "$WORK/new" --strip-components=1 && break; done
git config --global user.email "ci@knowing.dev" 2>/dev/null || true
git config --global user.name "knowing-ci" 2>/dev/null || true
(cd "$WORK/old" && git init -q && git add . && git commit -q -m "old" 2>/dev/null) || true
(cd "$WORK/new" && git init -q && git add . && git commit -q -m "new" 2>/dev/null) || true
./knowing index -db "$WORK/new.db" -no-enrich "$WORK/new" 2>/dev/null || { rm -rf "$WORK"; continue; }
SNAP=$(sqlite3 "$WORK/new.db" "SELECT hex(snapshot_hash) FROM snapshots LIMIT 1" 2>/dev/null || echo "")
if [ -n "$SNAP" ]; then
./knowing audit-supply-chain -db "$WORK/new.db" -base "$SNAP" -head "$SNAP" -scan-all -o "$WORK/report.json" 2>/dev/null || true
if [ -f "$WORK/report.json" ]; then
MAX_SCORE=$(bash scripts/check-isolation-score.sh "$WORK/report.json" 0.3) && {
echo " *** SUSPICIOUS: isolation score $MAX_SCORE ***"
SAFE=$(echo "${pkg}_${new_ver}" | tr '/@' '__')
cp "$WORK/report.json" "scan-output/${SAFE}.json"
} || echo " Clean (score: $MAX_SCORE)"
fi
fi
rm -rf "$WORK"
done < packages.txt
- name: Report
if: always()
run: |
TOTAL=$(grep -cv "^#" packages.txt 2>/dev/null || echo 0)
ALERTS=$(find scan-output -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
{
echo "## npm Supply Chain Scanner"
echo ""
echo "Scanned **${TOTAL}** packages."
echo ""
if [ "$ALERTS" -gt 0 ]; then
echo "### :rotating_light: $ALERTS suspicious package(s) detected"
echo ""
for f in scan-output/*.json; do
[ -f "$f" ] || continue
PKG=$(basename "$f" .json | tr '_' '/')
echo "<details><summary>$PKG</summary>"
echo ""
echo '```json'
python3 -m json.tool "$f" 2>/dev/null || cat "$f"
echo '```'
echo "</details>"
echo ""
done
else
echo ":white_check_mark: No suspicious packages detected."
fi
} >> "$GITHUB_STEP_SUMMARY"
if [ "$ALERTS" -gt 0 ]; then
gh issue create \
--title "[DRAFT] npm supply chain alert: $(date -u +%Y-%m-%d)" \
--body "$(cat "$GITHUB_STEP_SUMMARY")" \
--label "supply-chain-alert,draft-disclosure" || true
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: npm-scan-${{ github.run_id }}
path: scan-output/
retention-days: 90
if-no-files-found: ignore