Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .env.release.draft
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,6 @@ SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub

# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes
4 changes: 4 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Security scanner is enabled by default. Set to false to disable scanning.
SKILLHUB_SECURITY_SCANNER_ENABLED=true

# Required for signing anonymous download rate-limit cookies. Use a unique random value per deployment.
# runtime.sh generates and persists one automatically when this placeholder is still present.
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET=replace-with-random-download-secret-32-bytes

# Scanner LLM configuration (optional, for AI-powered scanning features)
SKILL_SCANNER_LLM_API_KEY=
SKILL_SCANNER_LLM_BASE_URL=
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/pr-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- 'Makefile'
- '.github/workflows/pr-cli.yml'

permissions:
contents: read

jobs:
cli:
strategy:
Expand All @@ -16,6 +19,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pr-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up pnpm
uses: pnpm/action-setup@v4
Expand Down
19 changes: 18 additions & 1 deletion .github/workflows/pr-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,33 @@ on:
pull_request:
paths:
- 'scripts/**'
- '.env.release.example'
- '.env.release.draft'
- 'compose.release.yml'
- 'Makefile'
- '.github/workflows/pr-cli.yml'
- '.github/workflows/pr-e2e.yml'
- '.github/workflows/pr-tests.yml'
- '.github/workflows/security.yml'
- '.github/workflows/pr-scripts.yml'

permissions:
contents: read

jobs:
publish-cli-test:
scripts-tests:
name: Script Regression Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '21'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/dev-web-host-test.sh
- run: bash scripts/tests/workflow-security-test.sh
6 changes: 6 additions & 0 deletions .github/workflows/pr-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -52,6 +54,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Java
uses: actions/setup-java@v4
Expand All @@ -74,6 +78,8 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Detect docs changes
id: changed
Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Security

on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
schedule:
- cron: '23 3 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
dependency-review:
name: Dependency Review
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Review dependency changes
uses: actions/dependency-review-action@v4

codeql:
name: CodeQL (${{ matrix.language }})
if: ${{ github.event_name != 'pull_request' }}
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
include:
- language: java-kotlin
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none

steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up Java
if: matrix.language == 'java-kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven

- name: Ensure Maven wrapper is executable
if: matrix.language == 'java-kotlin'
run: chmod +x server/mvnw

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- name: Build Java for CodeQL
if: matrix.language == 'java-kotlin'
run: cd server && ./mvnw -q -DskipTests package

- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ DEV_WEB_PID := $(DEV_DIR)/web.pid
DEV_SERVER_LOG := $(DEV_DIR)/server.log
DEV_WEB_LOG := $(DEV_DIR)/web.log
DEV_WEB_URL := http://localhost:3000
DEV_WEB_HOST ?= 127.0.0.1
DEV_API_URL := http://localhost:8080
DEV_SCANNER_URL := http://localhost:8000
STAGING_API_URL := http://localhost:8080
Expand Down Expand Up @@ -48,7 +49,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
else \
echo "Starting frontend..."; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host 0.0.0.0 --strictPort >/dev/null; \
$(DEV_PROCESS) start --pid-file $(DEV_WEB_PID) --log-file $(DEV_WEB_LOG) --cwd web -- pnpm exec vite --host $(DEV_WEB_HOST) --strictPort >/dev/null; \
fi
@echo "Waiting for backend on $(DEV_API_URL) ..."
@backend_ready=0; \
Expand Down Expand Up @@ -126,7 +127,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + scanner + 后端 + 前端
@echo " Frontend: $(DEV_WEB_LOG)"

dev-server: ## 启动后端开发服务器
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec env $(DEV_SERVER_SCANNER_ENV) $(DEV_SERVER_CMD)'

dev-server-restart: ## 重启后端开发服务器
@mkdir -p $(DEV_DIR)
Expand Down Expand Up @@ -237,7 +238,7 @@ web-install-ci: ## 以 CI 方式安装前端依赖
cd web && CI=true pnpm install --frozen-lockfile

dev-web: ## 启动前端开发服务器
cd web && pnpm run dev
cd web && pnpm exec vite --host $(DEV_WEB_HOST)

build-frontend: web-deps ## 构建前端
cd web && pnpm run build
Expand Down
92 changes: 89 additions & 3 deletions cli/src/platform/archive.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { zipSync, unzipSync } from 'fflate'
import { MAX_PACKAGE_BYTES } from './download'

const MAX_ZIP_ENTRIES = 500
const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
const ZIP64_MARKER_16 = 0xffff
const ZIP64_MARKER_32 = 0xffffffff

/**
* Extract a zip archive buffer into the target directory.
* Pure JS implementation using fflate — no system commands needed.
*/
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
await mkdir(targetDir, { recursive: true })
const files = unzipSync(new Uint8Array(buffer))
for (const [name, data] of Object.entries(files)) {
const filePath = safeJoin(targetDir, name)
const archive = new Uint8Array(buffer)
validateZipCentralDirectory(archive)
const files = unzipSync(archive)
const entries = Object.entries(files).map(([name, data]) => ({
name,
data,
filePath: safeJoin(targetDir, name),
}))
for (const { name, data, filePath } of entries) {
if (name.endsWith('/')) {
await mkdir(filePath, { recursive: true })
continue
Expand All @@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
}
}

function validateZipCentralDirectory(archive: Uint8Array): void {
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
const eocdOffset = findEndOfCentralDirectory(view)
if (eocdOffset < 0) {
throw new Error('invalid zip central directory')
}

const diskNumber = view.getUint16(eocdOffset + 4, true)
const centralDirectoryDisk = view.getUint16(eocdOffset + 6, true)
const entriesOnDisk = view.getUint16(eocdOffset + 8, true)
const totalEntries = view.getUint16(eocdOffset + 10, true)
const centralDirectorySize = view.getUint32(eocdOffset + 12, true)
const centralDirectoryOffset = view.getUint32(eocdOffset + 16, true)

if (
entriesOnDisk === ZIP64_MARKER_16 ||
totalEntries === ZIP64_MARKER_16 ||
centralDirectorySize === ZIP64_MARKER_32 ||
centralDirectoryOffset === ZIP64_MARKER_32
) {
throw new Error('zip64 archives are not supported')
}
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries) {
throw new Error('multi-disk zip archives are not supported')
}
if (totalEntries > MAX_ZIP_ENTRIES) {
throw new Error('zip entry count exceeds limit')
}
if (centralDirectoryOffset + centralDirectorySize > archive.byteLength) {
throw new Error('invalid zip central directory')
}

let offset = centralDirectoryOffset
let totalUncompressedSize = 0
const decoder = new TextDecoder()
for (let i = 0; i < totalEntries; i++) {
if (offset + 46 > archive.byteLength || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_SIGNATURE) {
throw new Error('invalid zip central directory')
}
const uncompressedSize = view.getUint32(offset + 24, true)
const nameLength = view.getUint16(offset + 28, true)
const extraLength = view.getUint16(offset + 30, true)
const commentLength = view.getUint16(offset + 32, true)
const nameStart = offset + 46
const nameEnd = nameStart + nameLength
const nextOffset = nameEnd + extraLength + commentLength
if (nameEnd > archive.byteLength || nextOffset > archive.byteLength) {
throw new Error('invalid zip central directory')
}

const entryName = decoder.decode(archive.subarray(nameStart, nameEnd))
if (!entryName.endsWith('/') && uncompressedSize > MAX_SINGLE_FILE_BYTES) {
throw new Error('zip entry size exceeds limit')
}
totalUncompressedSize += uncompressedSize
if (totalUncompressedSize > MAX_PACKAGE_BYTES) {
throw new Error('zip total uncompressed size exceeds limit')
}
offset = nextOffset
}
}

function findEndOfCentralDirectory(view: DataView): number {
const minOffset = Math.max(0, view.byteLength - 0xffff - 22)
for (let offset = view.byteLength - 22; offset >= minOffset; offset--) {
if (view.getUint32(offset, true) === EOCD_SIGNATURE) {
return offset
}
}
return -1
}

/**
* Create a zip archive from a directory.
* Returns the archive as a Blob.
Expand Down
Loading
Loading