Merge remote-tracking branch 'origin/main' into claude/issue-220-20250804-1147

Resolved import-block conflict in orchestrator/scripts/challenge.py by
taking the union of imports (all used by the interactive submit-project
script and main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Riccardo Schirone
2026-05-15 14:50:29 +00:00
332 changed files with 23849 additions and 14443 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
**/.pdm-build/
**/.pdm-python
**/pdm.lock
fuzzer/dockerfiles/runner_image.Dockerfile
fuzzer/Dockerfile
orchestrator/Dockerfile
patcher/Dockerfile
seed-gen/Dockerfile
+6
View File
@@ -0,0 +1,6 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
- gha-ubuntu-8
+1 -10
View File
@@ -1,5 +1,6 @@
export BUTTERCUP_K8S_VALUES_TEMPLATE=./k8s/values-upstream-minikube.template
export DEPLOY_CLUSTER=true
export DEPLOY_SIGNOZ=false
export CLUSTER_TYPE=minikube
export TAILSCALE_ENABLED=false
export COMPETITION_API_ENABLED=false
@@ -13,13 +14,3 @@ export OTEL_PROTOCOL="http"
export LANGFUSE_ENABLED=true
export DOCKER_USERNAME=""
export DOCKER_PAT=""
# Secrets/Env replaced
export OTEL_ENDPOINT=replace-me
export BUTTERCUP_NAMESPACE=replace-me
export GHCR_AUTH=replace-me
export OPENAI_API_KEY=replace-me
export ANTHROPIC_API_KEY=replace-me
export LANGFUSE_HOST=replace-me
export LANGFUSE_PUBLIC_KEY=replace-me
export LANGFUSE_SECRET_KEY=replace-me
+8 -2
View File
@@ -8,7 +8,10 @@ updates:
patterns:
- "*"
schedule:
interval: daily
interval: weekly
# Supply chain security: delay updates to allow community vetting
cooldown:
default-days: 7
- package-ecosystem: github-actions
directory: /
@@ -17,4 +20,7 @@ updates:
patterns:
- "*"
schedule:
interval: daily
interval: weekly
# Supply chain security: delay updates to allow community vetting
cooldown:
default-days: 7
-65
View File
@@ -1,65 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
persist-credentials: false
submodules: true
- name: Run Claude Code
env:
GIT_CONFIG_COUNT: 1
GIT_CONFIG_KEY_0: "url.https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/.insteadof"
GIT_CONFIG_VALUE_0: "https://github.com/"
id: claude
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
allowed_tools: "Bash"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test
+208
View File
@@ -0,0 +1,208 @@
name: Component Integration Tests
on:
push:
branches:
- main
# Always run full test suite on main branch
pull_request:
schedule:
# Run integration tests daily at 2 AM UTC
- cron: '0 2 * * *'
workflow_dispatch:
inputs:
components:
description: 'Components to test (comma-separated: common,patcher,program-model,seed-gen)'
required: false
default: 'common,patcher,program-model,seed-gen'
type: string
run_integration:
description: 'Run integration tests'
required: false
default: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Integration tests - run on schedule, manual trigger, or labeled PRs
test-integration:
permissions:
contents: read
# Run if:
# - Scheduled run
# - Manual dispatch with run_integration=true
# - Push to main branch
# - PR with 'integration-tests' label
if: |
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') ||
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'integration-tests'))
strategy:
fail-fast: false
matrix:
include:
- component: common
coverage_module: buttercup.common
python: "3.12"
- component: patcher
coverage_module: buttercup.patcher
python: "3.12"
- component: program-model
coverage_module: buttercup.program_model
python: "3.12"
- component: seed-gen
coverage_module: buttercup.seed_gen
python: "3.12"
runs-on: ubuntu-latest
services:
redis:
image: redis@sha256:e647cfe134bf5e8e74e620f66346f93418acfc240b71dd85640325cb7cd01402 # 7.4
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: true
- name: Check if component should be tested
id: should_test
if: github.event_name == 'workflow_dispatch'
env:
INPUT_COMPONENTS: ${{ github.event.inputs.components }}
MATRIX_COMPONENT: ${{ matrix.component }}
run: |
if [[ -z "$INPUT_COMPONENTS" ]] || [[ "$INPUT_COMPONENTS" == *"$MATRIX_COMPONENT"* ]]; then
echo "test=true" >> "$GITHUB_OUTPUT"
else
echo "test=false" >> "$GITHUB_OUTPUT"
fi
- name: Install uv
if: steps.should_test.outputs.test != 'false'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Setup uv cache
if: steps.should_test.outputs.test != 'false'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/uv
~/.local/share/uv
key: ${{ runner.os }}-uv-integration-${{ matrix.component }}-${{ hashFiles(format('{0}/uv.lock', matrix.component)) }}
restore-keys: |
${{ runner.os }}-uv-integration-${{ matrix.component }}-
${{ runner.os }}-uv-
- name: Download Wasm runtime
if: matrix.component == 'seed-gen' && steps.should_test.outputs.test != 'false'
run: wget https://github.com/vmware-labs/webassembly-language-runtimes/releases/download/python%2F3.12.0%2B20231211-040d5a6/python-3.12.0.wasm
working-directory: seed-gen
- name: Install integration test dependencies
if: steps.should_test.outputs.test != 'false'
run: |
sudo apt-get update
sudo apt-get install -y codequery ripgrep
make install-cscope
- name: Prepare environment
if: steps.should_test.outputs.test != 'false'
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
sudo mkdir -p /crs_scratch
sudo chmod -R 777 /crs_scratch
- name: Setup ${{ matrix.component }} component
if: steps.should_test.outputs.test != 'false'
run: |
uv sync --all-extras --frozen
uv pip install 'pytest-html>=4.1.1' 'pytest-cov>=6.0.0'
working-directory: ${{ matrix.component }}
- name: Run program-model libpng integration test
if: matrix.component == 'program-model' && steps.should_test.outputs.test != 'false'
run: |
uv run --frozen pytest -svv --runintegration tests/c/test_libpng.py \
--junit-xml=integration-test-results.xml \
--html=integration-test-report.html \
--self-contained-html \
--cov=${{ matrix.coverage_module }} \
--cov-report=xml:integration-coverage.xml \
--cov-report=html:integration-htmlcov \
--cov-report=term
env:
PYTHON_WASM_BUILD_PATH: "python-3.12.0.wasm"
working-directory: ${{ matrix.component }}
timeout-minutes: 30
- name: Run integration tests on ${{ matrix.component }}
if: matrix.component != 'program-model' && steps.should_test.outputs.test != 'false'
run: |
uv run --frozen pytest -svv --runintegration \
--junit-xml=integration-test-results.xml \
--html=integration-test-report.html \
--self-contained-html \
--cov=${{ matrix.coverage_module }} \
--cov-report=xml:integration-coverage.xml \
--cov-report=html:integration-htmlcov \
--cov-report=term
env:
PYTHON_WASM_BUILD_PATH: "python-3.12.0.wasm"
working-directory: ${{ matrix.component }}
timeout-minutes: 30
- name: Generate integration test summary
if: always() && steps.should_test.outputs.test != 'false'
env:
COMPONENT: ${{ matrix.component }}
run: |
echo "### Integration Test Results: ${COMPONENT}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -f "${COMPONENT}/integration-test-results.xml" ]; then
python -c "
import xml.etree.ElementTree as ET
import os
tree = ET.parse(os.environ['COMPONENT'] + '/integration-test-results.xml')
root = tree.getroot()
tests = root.get('tests', '0')
failures = root.get('failures', '0')
errors = root.get('errors', '0')
skipped = root.get('skipped', '0')
time = root.get('time', '0')
print(f'- **Total Tests**: {tests}')
print(f'- **Passed**: {int(tests) - int(failures) - int(errors) - int(skipped)}')
print(f'- **Failed**: {failures}')
print(f'- **Errors**: {errors}')
print(f'- **Skipped**: {skipped}')
print(f'- **Duration**: {float(time):.2f}s')
" >> "$GITHUB_STEP_SUMMARY"
else
echo "No integration test results found" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload integration test results
if: always() && steps.should_test.outputs.test != 'false'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: integration-test-results-${{ matrix.component }}-py${{ matrix.python }}
path: |
${{ matrix.component }}/integration-test-results.xml
${{ matrix.component }}/integration-test-report.html
${{ matrix.component }}/integration-coverage.xml
${{ matrix.component }}/integration-htmlcov/
retention-days: 30
@@ -17,6 +17,7 @@ on:
branches: ["main"]
pull_request:
branches: ["main"]
types: [labeled, synchronize]
env:
REGISTRY: ghcr.io
@@ -31,7 +32,7 @@ jobs:
- component: orchestrator
dockerfile: ./orchestrator/Dockerfile
- component: fuzzer
dockerfile: ./fuzzer/dockerfiles/runner_image.Dockerfile
dockerfile: ./fuzzer/Dockerfile
- component: patcher
dockerfile: ./patcher/Dockerfile
- component: seed-gen
@@ -44,16 +45,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: true
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: ${{ matrix.dockerfile }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
@@ -61,7 +67,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}-${{ matrix.component }}
tags: |
@@ -72,11 +78,11 @@ jobs:
type=semver,pattern={{major}}.{{minor}},suffix=${{ inputs.suffix }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ${{ matrix.dockerfile }}
push: ${{ (github.event_name != 'pull_request' || startsWith(github.head_ref, 'ci/') || inputs.push) && 'true' || 'false' }}
push: ${{ (github.event_name != 'pull_request' || startsWith(github.head_ref, 'ci/') || inputs.push || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'docker-push'))) && 'true' || 'false' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
-214
View File
@@ -1,214 +0,0 @@
name: Docker Compose integration tests
on:
push:
branches:
- main
- "ci/**"
workflow_dispatch:
inputs:
trigger_task_data:
description: "competition-api webhook/trigger_task data (JSON)"
required: true
default: '{"challenge_repo_url": "git@github.com:tob-challenges/example-libpng.git", "challenge_repo_base_ref": "0cc367aaeaac3f888f255cee5d394968996f736e", "challenge_repo_head_ref": "fdacd5a1dcff42175117d674b0fda9f8a005ae88", "fuzz_tooling_url": "git@github.com:tob-challenges/oss-fuzz-aixcc.git", "fuzz_tooling_ref": "d5fbd68fca66e6fa4f05899170d24e572b01853d", "fuzz_tooling_project_name": "libpng", "duration": 7200}'
build_timeout:
description: "Timeout for fuzzer build in minutes"
required: false
default: ""
vuln_timeout:
description: "Timeout for vuln submission in minutes"
required: false
default: ""
patch_timeout:
description: "Timeout for patch submission in minutes"
required: false
default: ""
seed_gen_timeout:
description: "Timeout for seed-gen submission in minutes"
required: false
default: ""
bundle_timeout:
description: "Timeout for bundle submission in minutes"
required: false
default: ""
sarif_timeout:
description: "Timeout for SARIF submission in minutes"
required: false
default: ""
env:
FUZZER_BUILD_TIMEOUT: ${{ inputs.build_timeout || 25 }}
VULN_TIMEOUT: ${{ inputs.vuln_timeout || 25 }}
PATCH_TIMEOUT: ${{ inputs.patch_timeout || 25 }}
BUNDLE_TIMEOUT: ${{ inputs.bundle_timeout || 5 }}
SARIF_TIMEOUT: ${{ inputs.sarif_timeout || 5 }}
SEED_GEN_TIMEOUT: ${{ inputs.seed_gen_timeout || 25 }}
permissions:
contents: read
packages: write
jobs:
integration:
runs-on: gha-ubuntu-32
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: true
- name: Set BUTTERCUP_NAMESPACE for PRs
if: github.event_name == 'pull_request'
run: |
pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
export BUTTERCUP_NAMESPACE=pr-${pull_number}-${{ github.run_number }}
echo "BUTTERCUP_NAMESPACE=${BUTTERCUP_NAMESPACE}" >> $GITHUB_ENV
- name: Set BUTTERCUP_NAMESPACE for branch
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: |
export BUTTERCUP_NAMESPACE=${GITHUB_REF_NAME/\//-}-${{ github.run_number }}
echo "BUTTERCUP_NAMESPACE=${BUTTERCUP_NAMESPACE}" >> $GITHUB_ENV
- name: Configure env file for minikube
run: |
cp ../.github/ci-env.template env
sed -i "s|BUTTERCUP_NAMESPACE=.*|BUTTERCUP_NAMESPACE=${{ env.BUTTERCUP_NAMESPACE }}|" env
sed -i "s|OPENAI_API_KEY=.*|OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}|" env
sed -i "s|ANTHROPIC_API_KEY=.*|ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}|" env
sed -i "s|LANGFUSE_HOST=.*|LANGFUSE_HOST=${{ secrets.LANGFUSE_HOST }}|" env
sed -i "s|LANGFUSE_PUBLIC_KEY=.*|LANGFUSE_PUBLIC_KEY=${{ secrets.LANGFUSE_PUBLIC_KEY }}|" env
sed -i "s|LANGFUSE_SECRET_KEY=.*|LANGFUSE_SECRET_KEY=${{ secrets.LANGFUSE_SECRET_KEY }}|" env
sed -i "s|GHCR_AUTH=.*|GHCR_AUTH=$(echo "USERNAME:${{ secrets.GH_TOKEN }}" | base64 --wrap=0)|" env
sed -i "s|OTEL_ENDPOINT=.*|OTEL_ENDPOINT=${{ secrets.OTEL_ENDPOINT }}|" env
working-directory: deployment
- name: Run CRS
run: make deploy-local
- name: Submit custom task to the CRS
if: github.event_name == 'workflow_dispatch'
run: |
kubectl port-forward -n $BUTTERCUP_NAMESPACE service/buttercup-ui 31323:1323 &
sleep 5
./orchestrator/scripts/custom_task_crs.sh "$DATA"
sleep 5
env:
DATA: ${{ inputs.trigger_task_data }}
- name: Submit example-libpng task to the CRS
if: github.event_name != 'workflow_dispatch'
run: |
kubectl port-forward -n $BUTTERCUP_NAMESPACE service/buttercup-ui 31323:1323 &
sleep 5
./orchestrator/scripts/task_crs.sh
sleep 5
- name: Wait for fuzzer build processing
timeout-minutes: ${{ fromJSON(env.FUZZER_BUILD_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "buttercup.orchestrator.scheduler.scheduler - INFO - .* Processing build output for type FUZZER"; do
sleep 60
done
- name: Wait for vuln to be found
timeout-minutes: ${{ fromJSON(env.VULN_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "] POV submission response: pov_id=" ; do
sleep 60
done
- name: Wait for vuln to enter the passed state in competition api
timeout-minutes: ${{ fromJSON(env.VULN_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Updated POV status. New status PASSED" ; do
sleep 60
done
- name: Wait for seed-gen to submit at least 1 seed
timeout-minutes: ${{ fromJSON(env.SEED_GEN_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=seed-gen -c seed-gen --tail=-1 | grep "Copied [1-9][0-9]* files to corpus"; do
sleep 60
done
- name: Wait for patch to be recorded
timeout-minutes: ${{ fromJSON(env.PATCH_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Appending patch for task"; do
sleep 60
done
- name: Wait for patch to enter the passed state in competition api
timeout-minutes: ${{ fromJSON(env.PATCH_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Patch passed"; do
sleep 60
done
- name: Wait for bundle to be submitted
timeout-minutes: ${{ fromJSON(env.BUNDLE_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Bundle submission response: bundle_id="; do
sleep 60
done
- name: Send a SARIF broadcast
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
TASK_ID=$(kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Submitting bundle for harness" | grep -o "\[[^]]*\]" | grep -o "[^[]*$" | cut -d: -f3 | tr -d ']')
echo "Task ID: $TASK_ID"
while true; do
# Kill any existing port-forward processes
pkill -f "kubectl port-forward.*buttercup-competition-api" || true
# Start port forwarding in background
kubectl port-forward -n $BUTTERCUP_NAMESPACE service/buttercup-ui 31323:1323 &
# Give port-forward time to establish
sleep 5
# Try to send SARIF report
if ./orchestrator/scripts/send_sarif.sh $TASK_ID; then
# Success - exit loop
break
fi
# Failed - wait a bit before retrying
sleep 10
done
- name: Wait for Bundle to be patched to include the SARIF
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Bundle patch submission response: broadcast_sarif_id="; do
sleep 60
done
- name: Wait for SARIF to be submitted as correct
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
while ! kubectl logs -n $BUTTERCUP_NAMESPACE -l app=scheduler --tail=-1 | grep "Matching SARIF submission response"; do
sleep 60
done
# Disable them for now because they can contain sensitive information
# - name: Collect logs
# run: |
# ./collect-logs.sh
# mkdir -p docker_logs
# cp -rv crs_pod_logs_* docker_logs
# if: always()
# working-directory: deployment
- name: Turn off CRS
run: |
make undeploy
make clean-local
if: always()
# - name: Upload Docker logs
# uses: actions/upload-artifact@v4
# with:
# name: docker-logs
# path: deployment/docker_logs/
# retention-days: 4
# if: always()
+141 -29
View File
@@ -4,68 +4,180 @@ on:
push:
branches:
- main
# Always run full suite on main branch
pull_request:
paths:
# Component-specific triggers
- "common/**"
- "orchestrator/**"
- "fuzzer/**"
- "fuzzer_runner/**"
- "program-model/**"
- "seed-gen/**"
- "patcher/**"
# Workflow changes
- ".github/workflows/lint.yml"
- ".github/workflows/tests.yml"
# Global configuration that affects linting
- "Makefile"
- "protoc.sh"
- "**/*.proto"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# Detect which components need linting based on changed files
changes:
permissions:
contents: read
runs-on: ubuntu-latest
outputs:
common: ${{ steps.filter.outputs.common }}
orchestrator: ${{ steps.filter.outputs.orchestrator }}
fuzzer: ${{ steps.filter.outputs.fuzzer }}
fuzzer_runner: ${{ steps.filter.outputs.fuzzer_runner }}
program-model: ${{ steps.filter.outputs.program-model }}
seed-gen: ${{ steps.filter.outputs.seed-gen }}
patcher: ${{ steps.filter.outputs.patcher }}
run-all: ${{ steps.filter.outputs.workflow || steps.filter.outputs.global }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
common:
- 'common/**'
orchestrator:
- 'orchestrator/**'
fuzzer:
- 'fuzzer/**'
fuzzer_runner:
- 'fuzzer_runner/**'
program-model:
- 'program-model/**'
seed-gen:
- 'seed-gen/**'
patcher:
- 'patcher/**'
workflow:
- '.github/workflows/lint.yml'
global:
- 'Makefile'
- 'protoc.sh'
- '**/*.proto'
matrix-generator:
permissions:
contents: read
runs-on: ubuntu-latest
needs: changes
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Generate matrix
id: set-matrix
env:
RUN_ALL: ${{ needs.changes.outputs.run-all }}
CHANGED_COMMON: ${{ needs.changes.outputs.common }}
CHANGED_ORCHESTRATOR: ${{ needs.changes.outputs.orchestrator }}
CHANGED_FUZZER: ${{ needs.changes.outputs.fuzzer }}
CHANGED_FUZZER_RUNNER: ${{ needs.changes.outputs.fuzzer_runner }}
CHANGED_PROGRAM_MODEL: ${{ needs.changes.outputs.program-model }}
CHANGED_SEED_GEN: ${{ needs.changes.outputs.seed-gen }}
CHANGED_PATCHER: ${{ needs.changes.outputs.patcher }}
run: |
components=()
if [[ "$RUN_ALL" == "true" ]]; then
components=("common" "orchestrator" "fuzzer" "fuzzer_runner" "program-model" "seed-gen" "patcher")
else
[[ "$CHANGED_COMMON" == "true" ]] && components+=("common")
[[ "$CHANGED_ORCHESTRATOR" == "true" ]] && components+=("orchestrator")
[[ "$CHANGED_FUZZER" == "true" ]] && components+=("fuzzer")
[[ "$CHANGED_FUZZER_RUNNER" == "true" ]] && components+=("fuzzer_runner")
[[ "$CHANGED_PROGRAM_MODEL" == "true" ]] && components+=("program-model")
[[ "$CHANGED_SEED_GEN" == "true" ]] && components+=("seed-gen")
[[ "$CHANGED_PATCHER" == "true" ]] && components+=("patcher")
fi
if [[ ${#components[@]} -eq 0 ]]; then
echo 'matrix={"component":[]}' >> "$GITHUB_OUTPUT"
else
echo "matrix={\"component\":[$(printf '"%s",' "${components[@]}" | sed 's/,$//')]}" >> "$GITHUB_OUTPUT"
fi
lint:
permissions:
contents: read
needs: [changes, matrix-generator]
strategy:
matrix:
component:
[common, orchestrator, fuzzer, program-model, seed-gen, patcher]
continue-on-error: [true]
include:
- component: patcher
continue-on-error: false
- component: program-model
continue-on-error: false
- component: common
continue-on-error: false
- component: orchestrator
continue-on-error: false
- component: seed-gen
continue-on-error: false
fail-fast: false # Continue running other components even if one fails
matrix: ${{ fromJSON(needs.matrix-generator.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Setup uv cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/uv
~/.local/share/uv
key: ${{ runner.os }}-uv-${{ matrix.component }}-${{ hashFiles(format('{0}/uv.lock', matrix.component)) }}
restore-keys: |
${{ runner.os }}-uv-${{ matrix.component }}-
${{ runner.os }}-uv-
- name: Setup ${{ matrix.component }} component
run: uv sync --all-extras
working-directory: ${{ matrix.component }}
- name: Run ruff on ${{ matrix.component }}
run: uv run ruff format --check && uv run ruff check
run: |
uv run ruff format --check --diff
uv run ruff check --output-format=github
working-directory: ${{ matrix.component }}
- name: Run mypy on ${{ matrix.component }}
run: uv run mypy
- name: Run ty on ${{ matrix.component }}
run: uv run ty check src/
working-directory: ${{ matrix.component }}
continue-on-error: ${{ matrix.continue-on-error }}
check-protobuf:
permissions:
contents: read
needs: changes
# Run if protobuf files changed or we need to run all
if: needs.changes.outputs.run-all == 'true' || contains(github.event.head_commit.modified, '.proto') || contains(github.event.head_commit.modified, 'protoc.sh')
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install protoc
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Generate protobufs
run: |
# Generate fresh protobufs
./protoc.sh
# Generate fresh protobufs (must run from common venv for correct grpcio-tools)
cd common && uv run ../protoc.sh
# Check if any files were modified
if ! git diff --quiet common/src/buttercup/common/datastructures; then
if ! git diff --quiet src/buttercup/common/datastructures; then
echo "Error: Generated protobufs differ from committed files"
exit 1
fi
+99
View File
@@ -0,0 +1,99 @@
name: Static Checks
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
static-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Set up Python
run: uv python install 3.13
- name: Lint GitHub Actions
run: |
# Install actionlint
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
./actionlint -color
- name: Security audit GitHub Actions
run: |
# Run zizmor in an isolated environment using uvx
uvx zizmor .github/workflows/
- name: Check YAML files
run: |
python3 -c "
import yaml
from pathlib import Path
for f in Path('.').rglob('*.yaml'):
if 'deployment/k8s' not in str(f):
list(yaml.safe_load_all(f.read_text()))
for f in Path('.').rglob('*.yml'):
if 'deployment/k8s' not in str(f):
list(yaml.safe_load_all(f.read_text()))
"
- name: Check TOML files
run: |
python3 -c "
import tomllib
from pathlib import Path
for f in Path('.').rglob('*.toml'):
tomllib.load(f.open('rb'))
"
- name: Check JSON files
run: |
python3 -c "
import json
from pathlib import Path
for f in Path('.').rglob('*.json'):
json.load(f.open())
"
- name: Check for merge conflicts
run: |
# Match exact git merge conflict markers:
# - <<<<<<< (followed by space, e.g., "<<<<<<< HEAD")
# - ======= (exactly 7 equals at end of line)
# - >>>>>>> (followed by space, e.g., ">>>>>>> branch-name")
! grep -rE '^(<{7} |>{7} |={7}$)' --include='*.py' --include='*.yaml' --include='*.yml' --include='*.toml' --include='*.json' . || exit 1
- name: Lint shell scripts
run: |
shellcheck --version
# Exclude external directories (node_data_storage contains cloned repos, external has vendored code)
find . -name '*.sh' -type f \
! -path './.git/*' \
! -path '*/.venv/*' \
! -path './node_data_storage/*' \
! -path './external/*' \
-print0 | xargs -0 shellcheck
- name: Lint Dockerfiles
run: |
# Install hadolint
curl -sL -o hadolint "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64"
chmod +x hadolint
./hadolint --version
# Exclude external directories (node_data_storage contains cloned repos, external has vendored code)
find . -name 'Dockerfile*' -type f \
! -path './.git/*' \
! -path './node_data_storage/*' \
! -path './external/*' \
-print0 | xargs -0 ./hadolint
+279
View File
@@ -0,0 +1,279 @@
name: System Integration Tests
on:
# Daily schedule - 3 AM UTC
schedule:
- cron: "0 3 * * 0"
# Pull requests with 'full-integration' label
pull_request:
types: [labeled, synchronize]
# Manual trigger
workflow_dispatch:
inputs:
trigger_task_data:
description: "competition-api webhook/trigger_task data (JSON)"
required: true
default: '{"challenge_repo_url": "git@github.com:tob-challenges/example-libpng.git", "challenge_repo_base_ref": "0cc367aaeaac3f888f255cee5d394968996f736e", "challenge_repo_head_ref": "fdacd5a1dcff42175117d674b0fda9f8a005ae88", "fuzz_tooling_url": "git@github.com:tob-challenges/oss-fuzz-aixcc.git", "fuzz_tooling_ref": "d5fbd68fca66e6fa4f05899170d24e572b01853d", "fuzz_tooling_project_name": "libpng", "duration": 7200}'
build_timeout:
description: "Timeout for fuzzer build in minutes"
required: false
default: ""
vuln_timeout:
description: "Timeout for vuln submission in minutes"
required: false
default: ""
patch_timeout:
description: "Timeout for patch submission in minutes"
required: false
default: ""
seed_gen_timeout:
description: "Timeout for seed-gen submission in minutes"
required: false
default: ""
bundle_timeout:
description: "Timeout for bundle submission in minutes"
required: false
default: ""
sarif_timeout:
description: "Timeout for SARIF submission in minutes"
required: false
default: ""
merger_timeout:
description: "Timeout for merger bot in minutes"
required: false
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
FUZZER_BUILD_TIMEOUT: ${{ inputs.build_timeout || 25 }}
VULN_TIMEOUT: ${{ inputs.vuln_timeout || 25 }}
PATCH_TIMEOUT: ${{ inputs.patch_timeout || 25 }}
BUNDLE_TIMEOUT: ${{ inputs.bundle_timeout || 5 }}
SARIF_TIMEOUT: ${{ inputs.sarif_timeout || 5 }}
SEED_GEN_TIMEOUT: ${{ inputs.seed_gen_timeout || 25 }}
MERGER_TIMEOUT: ${{ inputs.merger_timeout || 10 }}
permissions:
contents: read
jobs:
integration:
# Only run on PRs if they have the 'full-integration' label
if: |
github.event_name == 'schedule' ||
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'full-integration')
runs-on: gha-ubuntu-8
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: true
- name: Set BUTTERCUP_NAMESPACE for PRs
if: github.event_name == 'pull_request'
run: |
pull_number=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
export BUTTERCUP_NAMESPACE="pr-${pull_number}-${{ github.run_number }}"
echo "BUTTERCUP_NAMESPACE=${BUTTERCUP_NAMESPACE}" >> "$GITHUB_ENV"
- name: Set BUTTERCUP_NAMESPACE for branch
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
run: |
export BUTTERCUP_NAMESPACE="${GITHUB_REF_NAME/\//-}-${{ github.run_number }}"
echo "BUTTERCUP_NAMESPACE=${BUTTERCUP_NAMESPACE}" >> "$GITHUB_ENV"
- name: Configure env file for minikube
env:
BUTTERCUP_NS: ${{ env.BUTTERCUP_NAMESPACE }}
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_KEY: ${{ secrets.GEMINI_API_KEY }}
LF_HOST: ${{ secrets.LANGFUSE_HOST }}
LF_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
LF_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
OTEL_EP: ${{ secrets.OTEL_ENDPOINT }}
OTEL_TK: ${{ secrets.OTEL_TOKEN }}
run: |
# The env file is sourced by deployment/crs-architecture.sh, so every
# value must be shell-quoted. printf %q produces a form bash can
# re-parse as the literal string, defending against shell injection
# from secret contents (whitespace, $(), backticks, quotes, etc.).
write_var() {
printf 'export %s=%q\n' "$1" "$2" >> ./env
}
cp ../.github/ci-env.template ./env
write_var BUTTERCUP_NAMESPACE "$BUTTERCUP_NS"
write_var OPENAI_API_KEY "$OPENAI_KEY"
write_var ANTHROPIC_API_KEY "$ANTHROPIC_KEY"
write_var GEMINI_API_KEY "$GEMINI_KEY"
write_var LANGFUSE_HOST "$LF_HOST"
write_var LANGFUSE_PUBLIC_KEY "$LF_PUBLIC_KEY"
write_var LANGFUSE_SECRET_KEY "$LF_SECRET_KEY"
write_var OTEL_ENDPOINT "$OTEL_EP"
write_var OTEL_TOKEN "$OTEL_TK"
working-directory: deployment
- name: Run CRS
run: FORCE=true make deploy
- name: Submit custom task to the CRS
if: github.event_name == 'workflow_dispatch'
run: |
kubectl port-forward -n "$BUTTERCUP_NAMESPACE" service/buttercup-ui 31323:1323 &
sleep 5
./orchestrator/scripts/custom_task_crs.sh "$DATA"
sleep 5
env:
DATA: ${{ inputs.trigger_task_data }}
- name: Submit example-libpng task to the CRS
if: github.event_name != 'workflow_dispatch'
run: |
kubectl port-forward -n "$BUTTERCUP_NAMESPACE" service/buttercup-ui 31323:1323 &
sleep 5
./orchestrator/scripts/task_crs.sh
sleep 5
- name: Wait for fuzzer build processing
timeout-minutes: ${{ fromJSON(env.FUZZER_BUILD_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "buttercup.orchestrator.scheduler.scheduler - INFO - .* Processing build output for type FUZZER"; do
sleep 60
done
- name: Wait for vuln to be found
timeout-minutes: ${{ fromJSON(env.VULN_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "] POV submission response: pov_id=" ; do
sleep 60
done
- name: Wait for vuln to enter the passed state in competition api
timeout-minutes: ${{ fromJSON(env.VULN_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Updated POV status. New status PASSED" ; do
sleep 60
done
- name: Wait for seed-gen to submit at least 1 seed
timeout-minutes: ${{ fromJSON(env.SEED_GEN_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=seed-gen -c seed-gen --tail=-1 | grep "Copied [1-9][0-9]* files to corpus"; do
sleep 60
done
- name: Wait for merger-bot to submit files to remote corpus and prune local corpus
timeout-minutes: ${{ fromJSON(env.MERGER_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=merger-bot -c merger-bot --tail=-1 | grep "Synced [1-9][0-9]* files that add coverage to remote corpus"; do
sleep 60
done
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=merger-bot -c merger-bot --tail=-1 | grep "Removed [1-9][0-9]* files from local corpus .* that don't add coverage"; do
sleep 60
done
- name: Wait for patch to be recorded
timeout-minutes: ${{ fromJSON(env.PATCH_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Appending patch for task"; do
sleep 60
done
- name: Approve the patch in the buttercup-ui
run: |
# Wait until a log line with competition_patch_id= is available, then extract PATCH_ID and TASK_ID from that line
while true; do
PATCH_LINE=$(kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "competition_patch_id=" | head -n 1)
if [ -n "$PATCH_LINE" ]; then
break
fi
echo "Waiting for PATCH_ID to be available..."
sleep 10
done
echo "Patch log line: $PATCH_LINE"
# Extract PATCH_ID from the log line (after 'competition_patch_id=' and before any space or end of line)
PATCH_ID=$(echo "$PATCH_LINE" | sed -n 's/.*competition_patch_id=\([^ ]*\).*/\1/p')
# Extract TASK_ID from the log line (between the first '[' and the first ']'), then after the first ':' (if present)
TASK_ID=$(echo "$PATCH_LINE" | sed -n 's/.*\[\([^]]*\)\].*/\1/p' | sed 's/^[^:]*://')
echo "Patch ID: $PATCH_ID"
echo "Task ID: $TASK_ID"
kubectl port-forward -n "$BUTTERCUP_NAMESPACE" service/buttercup-ui 31323:1323 &
sleep 5
curl -X POST "http://localhost:31323/v1/task/${TASK_ID}/patch/${PATCH_ID}/approve"
sleep 5
- name: Wait for patch to enter the passed state in competition api
timeout-minutes: ${{ fromJSON(env.PATCH_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Patch passed"; do
sleep 60
done
- name: Wait for bundle to be submitted
timeout-minutes: ${{ fromJSON(env.BUNDLE_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Bundle submission response: bundle_id="; do
sleep 60
done
- name: Send a SARIF broadcast
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
TASK_ID=$(kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Submitting bundle for harness" | grep -o "\[[^]]*\]" | grep -o "[^[]*$" | cut -d: -f3 | tr -d ']')
echo "Task ID: $TASK_ID"
while true; do
# Kill any existing port-forward processes
pkill -f "kubectl port-forward.*buttercup-competition-api" || true
# Start port forwarding in background
kubectl port-forward -n "$BUTTERCUP_NAMESPACE" service/buttercup-ui 31323:1323 &
# Give port-forward time to establish
sleep 5
# Try to send SARIF report
if ./orchestrator/scripts/send_sarif.sh "$TASK_ID"; then
# Success - exit loop
break
fi
# Failed - wait a bit before retrying
sleep 10
done
- name: Wait for Bundle to be patched to include the SARIF
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Bundle patch submission response: broadcast_sarif_id="; do
sleep 60
done
- name: Wait for SARIF to be submitted as correct
timeout-minutes: ${{ fromJSON(env.SARIF_TIMEOUT) }}
run: |
while ! kubectl logs -n "$BUTTERCUP_NAMESPACE" -l app=scheduler --tail=-1 | grep "Matching SARIF submission response"; do
sleep 60
done
# Disable them for now because they can contain sensitive information
# - name: Collect logs
# run: |
# ./collect-logs.sh
# mkdir -p docker_logs
# cp -rv crs_pod_logs_* docker_logs
# if: always()
# working-directory: deployment
- name: Turn off CRS
run: |
make undeploy
make clean-local
if: always()
# - name: Upload Docker logs
# uses: actions/upload-artifact@v4
# with:
# name: docker-logs
# path: deployment/docker_logs/
# retention-days: 4
# if: always()
+152 -17
View File
@@ -4,27 +4,51 @@ on:
push:
branches:
- main
# Always run full test suite on main branch
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
permissions:
contents: read
strategy:
fail-fast: false # Continue running other components even if one fails
matrix:
component:
[common, orchestrator, fuzzer, program-model, seed-gen, patcher]
python:
- "3.12"
# TODO: Add integration tests back in
# include:
# - component: common
# pytest_args: "--runintegration"
# - component: program-model
# pytest_args: "--runintegration"
include:
- component: common
coverage_module: buttercup.common
python: "3.12"
- component: orchestrator
coverage_module: buttercup.orchestrator
python: "3.12"
- component: program-model
coverage_module: buttercup.program_model
python: "3.12"
- component: seed-gen
coverage_module: buttercup.seed_gen
python: "3.12"
- component: patcher
coverage_module: buttercup.patcher
python: "3.12"
- component: fuzzer
coverage_module: buttercup.fuzzer
python: "3.12"
- component: fuzzer_runner
coverage_module: buttercup.fuzzer_runner
python: "3.12"
runs-on: ubuntu-latest
# Removed if: matrix.should_run since we're not using path filtering right now
services:
redis:
image: redis
image: redis@sha256:e647cfe134bf5e8e74e620f66346f93418acfc240b71dd85640325cb7cd01402 # 7.4
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
@@ -34,25 +58,50 @@ jobs:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
submodules: true
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Setup uv cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cache/uv
~/.local/share/uv
key: ${{ runner.os }}-uv-${{ matrix.component }}-${{ hashFiles(format('{0}/uv.lock', matrix.component)) }}
restore-keys: |
${{ runner.os }}-uv-${{ matrix.component }}-
${{ runner.os }}-uv-
- name: Download Wasm runtime
run: wget https://github.com/vmware-labs/webassembly-language-runtimes/releases/download/python%2F3.12.0%2B20231211-040d5a6/python-3.12.0.wasm
if: ${{ matrix.component }} == 'seed-gen'
if: matrix.component == 'seed-gen'
working-directory: seed-gen
- name: Install dependencies
- name: Install dependencies for program-model, seed-gen, and patcher
if: matrix.component == 'program-model' || matrix.component == 'seed-gen' || matrix.component == 'patcher'
run: |
sudo apt-get update
sudo apt-get install -y codequery ripgrep git-lfs
sudo apt-get install -y codequery ripgrep
make install-cscope
- name: Install minimal dependencies
if: matrix.component != 'program-model' && matrix.component != 'seed-gen' && matrix.component != 'patcher' && matrix.component != 'fuzzer' && matrix.component != 'fuzzer_runner'
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
# Fuzzer and fuzzer_runner only need ripgrep, no codequery
- name: Install fuzzer dependencies
if: matrix.component == 'fuzzer' || matrix.component == 'fuzzer_runner'
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
- name: Prepare environment
run: |
export DEBIAN_FRONTEND=noninteractive
@@ -63,10 +112,96 @@ jobs:
- name: Setup ${{ matrix.component }} component
run: |
uv sync --all-extras --frozen
# Install test reporting tools into the project venv
# This avoids adding them to every component's dependencies
uv pip install 'pytest-html>=4.1.1' 'pytest-cov>=6.0.0'
working-directory: ${{ matrix.component }}
- name: Run tests on ${{ matrix.component }} component
run: uv run --frozen pytest -svv ${{ matrix.pytest_args }}
run: |
uv run --frozen pytest -svv \
--junit-xml=test-results.xml \
--html=test-report.html \
--self-contained-html \
--cov=${{ matrix.coverage_module }} \
--cov-report=xml \
--cov-report=html \
--cov-report=term
env:
PYTHON_WASM_BUILD_PATH: "python-3.12.0.wasm"
working-directory: ${{ matrix.component }}
- name: Audit dependencies for vulnerabilities
if: always()
run: |
# Ignore CVEs with no available fix:
# - CVE-2026-4539: pygments ReDoS in AdlLexer (no fix available)
# Use --skip-editable to ignore local packages not on PyPI
# Use uvx to run pip-audit in an isolated environment
uvx pip-audit --strict --desc \
--skip-editable \
--ignore-vuln CVE-2026-4539
working-directory: ${{ matrix.component }}
- name: Generate test summary
if: always()
run: |
echo "### Test Results: ${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -f "${{ matrix.component }}/test-results.xml" ]; then
python -c "
import xml.etree.ElementTree as ET
tree = ET.parse('${{ matrix.component }}/test-results.xml')
root = tree.getroot()
tests = root.get('tests', '0')
failures = root.get('failures', '0')
errors = root.get('errors', '0')
skipped = root.get('skipped', '0')
time = root.get('time', '0')
print(f'- **Total Tests**: {tests}')
print(f'- **Passed**: {int(tests) - int(failures) - int(errors) - int(skipped)}')
print(f'- **Failed**: {failures}')
print(f'- **Errors**: {errors}')
print(f'- **Skipped**: {skipped}')
print(f'- **Duration**: {float(time):.2f}s')
" >> "$GITHUB_STEP_SUMMARY"
else
echo "No test results found" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-results-${{ matrix.component }}-py${{ matrix.python }}
path: |
${{ matrix.component }}/test-results.xml
${{ matrix.component }}/test-report.html
${{ matrix.component }}/coverage.xml
${{ matrix.component }}/htmlcov/
retention-days: 30
# Coverage will be uploaded in a separate job after all tests complete
# Consolidated coverage upload after all tests complete
coverage-upload:
permissions:
contents: read
needs: [test]
if: always()
runs-on: ubuntu-latest
steps:
- name: Download all coverage reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-results-*
path: coverage-reports
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: coverage-reports
files: '*/coverage.xml,**/coverage.xml'
fail_ci_if_error: false
verbose: true
+16
View File
@@ -14,6 +14,7 @@ errored.tfstate
deployment/k8s/values-*.yaml
deployment/k8s/base/tailscale-coredns/coredns-custom.yaml
deployment/k8s/base/tailscale-operator/operator.yaml
deployment/k8s/base/tailscale-connections/proxies.yaml
## Python
__pycache__/
@@ -41,6 +42,14 @@ dist/
pdm.lock
.langchain.db
# Mypy type checking cache
.mypy_cache/
**/.mypy_cache/
# Hypothesis property-based testing cache
.hypothesis/
**/.hypothesis/
*.bak
*.orig
@@ -50,3 +59,10 @@ common/common/datastructures/fuzzer_msg*
crs_scratch/*
tasks_storage/*
node_data_storage/*
# Serena runtime files (keep .serena/project.yml tracked)
.serena/memories/
.serena/*.log
# WASM runtimes (downloaded for seed-gen sandbox tests)
.wasm/
+3 -3
View File
@@ -1,3 +1,3 @@
[submodule "external/aixcc-cscope"]
path = external/aixcc-cscope
url = https://github.com/trailofbits/aixcc-cscope.git
[submodule "external/buttercup-cscope"]
path = external/buttercup-cscope
url = https://github.com/trail-of-forks/buttercup-cscope
+12
View File
@@ -0,0 +1,12 @@
# Hadolint configuration for Buttercup
# https://github.com/hadolint/hadolint
ignored:
# Pin versions in apt-get install - impractical for multi-arch builds where
# versions vary by base image and architecture; base image provides reproducibility
- DL3008
trustedRegistries:
- docker.io
- ghcr.io
- gcr.io
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"serena": {
"command": "uvx",
"args": [
"--from", "git+https://github.com/oraios/serena",
"serena", "start-mcp-server",
"--context", "claude-code",
"--project", ".",
"--enable-web-dashboard", "false"
]
}
}
}
+101
View File
@@ -0,0 +1,101 @@
# Pre-commit hooks for Buttercup CRS
# See https://pre-commit.com for more information
repos:
# Standard file fixes
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
args: [--allow-multiple-documents]
exclude: 'deployment/k8s/.*\.(yaml|yml)$'
- id: check-added-large-files
args: [--maxkb=1000]
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
exclude: '(\.proto$|test/data/|tests/data/)'
- id: check-toml
- id: check-json
- id: debug-statements
# Ruff - Python linting and formatting
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.0
hooks:
# Run the formatter first
- id: ruff-format
types_or: [python, pyi]
# Then run the linter with auto-fix
- id: ruff
types_or: [python, pyi]
args: [--fix, --exit-non-zero-on-fix]
# ShellCheck - Shell script linting (all .sh files)
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
args: [--severity=warning]
# shfmt - Shell script formatting (all .sh files)
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-2
hooks:
- id: shfmt
args: [-i, '4', -ci]
# actionlint - GitHub Actions workflow linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# zizmor - GitHub Actions security analysis
- repo: https://github.com/woodruffw/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--min-severity=low, --min-confidence=medium]
# hadolint - Dockerfile linting
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0
hooks:
- id: hadolint
args: [--failure-threshold, warning]
# ty - Astral type checking (local hook)
- repo: local
hooks:
- id: ty
name: ty type checking
entry: >-
bash -c 'for dir in common orchestrator fuzzer fuzzer_runner patcher program-model seed-gen; do
if [[ "$1" == "$dir/"* ]]; then cd "$dir" && uvx ty check src/; exit $?; fi; done' --
language: system
types: [python]
files: ^(common|orchestrator|fuzzer|fuzzer_runner|patcher|program-model|seed-gen)/src/
pass_filenames: true
# Global exclusions for files that should never be checked
exclude: |
(?x)^(
external/.*|
.*\.pb\.py|
.*_pb2\.py|
.*_pb2_grpc\.py|
.*/\.venv/.*|
.*/\.mypy_cache/.*|
.*/\.ruff_cache/.*|
.*/\.pytest_cache/.*|
__pycache__/.*|
.*\.egg-info/.*
)$
# Configuration
fail_fast: false
ci:
autofix_prs: true
autoupdate_schedule: weekly
+1
View File
@@ -0,0 +1 @@
/cache
+66
View File
@@ -0,0 +1,66 @@
languages:
- python
encoding: utf-8
ignore_all_files_in_gitignore: true
roots:
- common
- orchestrator
- fuzzer
- fuzzer_runner
- patcher
- program-model
- seed-gen
ignore:
- ".git"
- ".venv"
- "__pycache__"
- "*.egg-info"
- "*.pyc"
- "build"
- "dist"
- ".ruff_cache"
- ".mypy_cache"
- ".pytest_cache"
- ".hypothesis"
- "htmlcov"
- ".idea"
- ".vscode"
- ".worktrees"
- "crs_scratch"
- "tasks_storage"
- "external"
- "**/node_modules"
- "**/uv.lock"
- "**/*.pb2.py"
- "litellm/**"
- "deployment/k8s/**"
- "competition-server/**"
allow_write: true
excluded_tools:
- onboarding
- check_onboarding_performed
- list_dir
- find_file
- write_memory
- read_memory
- list_memories
- delete_memory
- edit_memory
initial_prompt: |
This project has a comprehensive CLAUDE.md with architecture, development
commands, and component-specific patterns. Refer to it for project context
rather than using onboarding or memories.
Tool usage patterns for this multi-component codebase:
- Use get_symbols_overview before reading files to understand structure
- Use find_symbol to locate definitions across the 7 component packages
- Use find_referencing_symbols to trace Redis message handlers and protobuf usage
- Prefer replace_symbol_body over line-based edits for precision
- Use search_for_pattern for YAML configs and proto files (not indexed by LSP)
- Call think_about_collected_information after exploring before making changes
+6
View File
@@ -0,0 +1,6 @@
# Shellcheck configuration for Buttercup
shell=bash
# Globally disable SC1091 (source file not followed)
# These are dynamically sourced or external files
disable=SC1091
+17
View File
@@ -17,6 +17,9 @@ make lint # Format, lint, and type-check all c
# Example: lint the patcher component
make lint-component COMPONENT=patcher
# Regenerate protobuf files (MUST run from common venv for correct grpcio-tools)
cd common && uv run ../protoc.sh
```
### Testing
@@ -126,6 +129,12 @@ cd <component> && uv lock --upgrade
- Mock external dependencies (Redis, LLM APIs, file system)
- Integration tests use Docker containers
- Test data stored in `<component>/tests/data/`
- **Redis-dependent tests**: Several components (common, orchestrator, patcher, fuzzer, seed-gen) have tests that require a running Redis instance. Do not skip these tests. Start a temporary Redis container before running tests:
```bash
docker run -d --name redis-temp -p 6379:6379 redis:latest
# Run tests...
docker stop redis-temp && docker rm redis-temp
```
### Code Quality
@@ -134,6 +143,14 @@ cd <component> && uv lock --upgrade
- Line length: 120 characters
- Pydantic models for data validation
### Code Navigation
- Use `get_symbols_overview` before reading full files to understand structure
- Use `find_symbol` to locate classes/functions across components
- Use `find_referencing_symbols` to trace message flow between services
- Use `search_for_pattern` for non-code files (YAML, proto, configs)
- Prefer symbol-based edits (`replace_symbol_body`, `insert_after_symbol`) over line-based edits
## Deployment Architecture
The system runs as Kubernetes microservices with Helm charts in `/deployment/k8s/`:
+8
View File
@@ -0,0 +1,8 @@
* @ret2libc @hbrodin @michaelbrownuc
seed-gen @reytchison
fuzzer @reytchison
fuzzer_runner @reytchison
program-model @evandowning
patcher @ret2libc
orchestrator @hbrodin
deployments @ret2libc @hbrodin
+169
View File
@@ -0,0 +1,169 @@
# Contributing to Buttercup CRS
Thank you for contributing to the Buttercup Cyber Reasoning System!
## Quick Start
```bash
# Clone and setup
git clone --recurse-submodules https://github.com/trailofbits/buttercup.git
cd buttercup
make setup-local # Automated setup
make deploy # Start environment
# Setup development tools (optional but recommended)
cargo install prek # Fast Rust-based git hooks
prek install # Auto-runs checks on commit
```
## Development Workflow
### Essential Commands
```bash
make help # View all commands
make lint # Lint all components
make lint-component COMPONENT=orchestrator # Lint specific component
make send-libpng-task # Run test task
make status # Check deployment status
make undeploy # Clean up resources
```
### Code Quality
- **Tools:** `ruff` (formatting/linting), `ty` (type checking)
- **Git hooks:** `prek` automatically validates code, configs, and line endings on commit
- **Manual checks:** `prek run` or `make lint`
### Test Prerequisites
**Redis** (required for unit tests):
```bash
# Start Redis with Docker
docker run -d -p 6379:6379 --name buttercup-redis redis:7.4
# Or on macOS with Homebrew
brew install redis && brew services start redis
```
**System dependencies** (varies by component):
| Component | Dependencies |
|-----------|-------------|
| common, orchestrator | `ripgrep` |
| fuzzer, fuzzer_runner | `ripgrep` |
| program-model, patcher, seed-gen | `ripgrep`, `codequery`, `universal-ctags`, custom cscope |
```bash
# macOS
brew install ripgrep codequery universal-ctags autoconf automake
git submodule update --init external/buttercup-cscope
make install-cscope
# IMPORTANT: universal-ctags must be in PATH before BSD ctags (macOS default).
# BSD ctags is incompatible with cscope/codequery. Add to ~/.zshrc or ~/.bashrc:
export PATH="/opt/homebrew/bin:$PATH"
# Ubuntu/Debian
sudo apt-get install -y ripgrep codequery universal-ctags autoconf automake
git submodule update --init external/buttercup-cscope
make install-cscope
```
**seed-gen only** - requires WASM Python build path:
```bash
export PYTHON_WASM_BUILD_PATH="python-3.12.0.wasm"
```
### Testing Strategy
1. **Unit Tests** (5-10 min): Run on all PRs
```bash
cd <component> && uv run pytest
```
2. **Integration Tests** (15-30 min): Daily or with `integration-tests` label
```bash
# Requires: codequery, ripgrep, cscope (for program-model, patcher, seed-gen)
# Requires: NODE_DATA_DIR env var pointing to writable scratch directory
NODE_DATA_DIR=/tmp/buttercup_test uv run pytest --runintegration
```
**macOS limitations:** Some integration tests require Docker builds that only work on
x86_64 Linux (CI). Tests that work locally on macOS ARM64:
- `common/tests/test_reliable_queue.py` - Redis queue tests
- `program-model/tests/test_tree_sitter.py` - Tree-sitter parsing
- `patcher/tests/test_utils.py`, `test_context_retriever.py` - Patcher utilities
- `seed-gen/test/test_find_harness.py` - Harness finding (some skipped)
3. **Full System** (90+ min): Weekly or with `full-integration` label
```bash
make deploy && make send-libpng-task
```
## Project Structure
| Component | Purpose |
|-----------|---------|
| `/common/` | Shared utilities, protobufs, Redis queues |
| `/orchestrator/` | Task coordination, scheduling, API client |
| `/fuzzer/` | Vulnerability discovery bots |
| `/fuzzer_runner/` | Fuzzer execution runner |
| `/program-model/` | Code analysis (CodeQuery, Tree-sitter) |
| `/patcher/` | LLM-powered patch generation |
| `/seed-gen/` | Intelligent input generation |
## Contribution Process
1. **Branch** from main: `git checkout -b feature/your-feature`
2. **Code** following existing patterns and conventions
3. **Test** your changes at appropriate level
4. **Commit** (prek hooks run automatically if installed)
5. **Push** and create PR with clear description
### Python Dependencies
Each component uses `uv`:
```bash
cd <component>
uv sync # Install dependencies
uv add <package> # Add new dependency
uv lock --upgrade # Update dependencies
```
## Guidelines
### Code Style
- Follow existing patterns in each component
- Use structured logging and Pydantic models
- Handle errors with circuit breakers for external services
- Write tests for new functionality
### PR Labels
- `integration-tests` - Triggers component integration tests
- `full-integration` - Triggers full system test (use sparingly)
## Debugging
```bash
# Kubernetes commands
kubectl logs -n crs -l app=<service> --tail=100
kubectl exec -it -n crs <pod> -- /bin/bash
kubectl port-forward -n crs service/buttercup-competition-api 31323:1323
```
## Getting Help
- **Environment issues?** Run `make validate` to check if your setup is ready
- **Tests failing?** Ensure Redis is running (see [Test Prerequisites](#test-prerequisites)) and dependencies are installed with `cd <component> && uv sync`
- **Missing system tools?** See the [Test Prerequisites](#test-prerequisites) table for component-specific dependencies
## Resources
- [Quick Reference](guides/QUICK_REFERENCE.md) - Common commands and troubleshooting
- [Deployment Guide](deployment/README.md) - Detailed deployment information
- [Custom Challenges](guides/CUSTOM_CHALLENGES.md) - Adding new test cases
## Questions?
Open an issue or reach out to the development team.
+54 -56
View File
@@ -1,6 +1,6 @@
# Makefile for Trail of Bits AIxCC Finals CRS
.PHONY: help setup-local setup-azure validate deploy deploy-local deploy-azure test undeploy install-cscope lint lint-component clean-local wait-crs check-crs crs-instance-id status send-integration-task
.PHONY: help setup-local setup-azure validate deploy test undeploy install-cscope lint lint-component clean-local wait-crs check-crs crs-instance-id status send-integration-task
# Default target
help:
@@ -13,13 +13,12 @@ help:
@echo ""
@echo "Deployment:"
@echo " deploy - Deploy to current environment (local or azure)"
@echo " deploy-local - Deploy to local Minikube environment"
@echo " deploy-azure - Deploy to production AKS environment"
@echo ""
@echo "Status:"
@echo " status - Check the status of the deployment"
@echo " crs-instance-id - Get the CRS instance ID"
@echo " download-artifacts - Download submitted artifacts from the CRS"
@echo " signoz-ui - Open the SigNoz UI"
@echo ""
@echo "Testing:"
@echo " send-integration-task - Run integration-test task"
@@ -48,16 +47,6 @@ validate:
@echo "Validating setup..."
./scripts/validate-setup.sh
# Deployment targets
deploy:
@echo "Deploying to current environment..."
@if [ ! -f external/aixcc-cscope/configure.ac ]; then \
echo "Error: The git submodules have not been initialized. Run 'git submodule update --init --recursive' first."; \
exit 1; \
fi
cd deployment && make up
make wait-crs
wait-crs:
@echo "Waiting for CRS deployment to be ready..."
@if ! kubectl get namespace $${BUTTERCUP_NAMESPACE:-crs} >/dev/null 2>&1; then \
@@ -96,35 +85,32 @@ crs-instance-id:
fi
echo "CRS instance ID: $$(kubectl get configmap -n $${BUTTERCUP_NAMESPACE:-crs} crs-instance-id -o jsonpath='{.data.crs-instance-id}')"
deploy-local:
@echo "Deploying to local Minikube environment..."
deploy:
@echo "Deploying environment..."
@if [ ! -f deployment/env ]; then \
echo "Error: Configuration file not found. Run 'make setup-local' first."; \
echo "Error: Configuration file not found. Run 'make setup-local' or `make setup-azure` first."; \
exit 1; \
fi
@if [ ! -f external/aixcc-cscope/configure.ac ]; then \
@if [ ! -f external/buttercup-cscope/configure.ac ]; then \
echo "Error: The git submodules have not been initialized. Run 'git submodule update --init --recursive' first."; \
exit 1; \
fi
@echo "Deployment configuration:"
@grep 'CLUSTER_TYPE=' deployment/env || echo "CLUSTER_TYPE not set"
@grep 'K8S_VALUES_TEMPLATE' deployment/env || echo "K8S_VALUES_TEMPLATE not set"
@if [ "$${FORCE:-false}" != "true" ]; then \
echo "Are you sure you want to deploy with these settings? Type 'yes' to continue:"; \
read ans; \
ans_lc=$$(echo "$$ans" | tr '[:upper:]' '[:lower:]'); \
if [ "$$ans_lc" != "yes" ] && [ "$$ans_lc" != "y" ]; then \
echo "Aborted by user."; \
exit 1; \
fi; \
fi
cd deployment && make up
make crs-instance-id
make wait-crs
deploy-azure:
@echo "Deploying to production AKS environment..."
@if [ ! -f deployment/env ]; then \
echo "Error: Configuration file not found. Run 'make setup-azure' first."; \
exit 1; \
fi
@if [ ! -f external/aixcc-cscope/configure.ac ]; then \
echo "Error: The git submodules have not been initialized. Run 'git submodule update --init --recursive' first."; \
exit 1; \
fi
cd deployment && make up
crs_instance_id=$$(make crs-instance-id)
echo "CRS instance ID: $$crs_instance_id"
make wait-crs
status:
@echo "----------PODS------------"
@kubectl get pods -n $${BUTTERCUP_NAMESPACE:-crs}
@@ -147,11 +133,11 @@ send-integration-task:
echo "Error: CRS namespace not found. Deploy first with 'make deploy'."; \
exit 1; \
fi
kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-ui 31323:1323 &
@sleep 3
./orchestrator/scripts/task_integration_test.sh
pkill -f "kubectl port-forward" || true
exit 0
@kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-ui 31323:1323 & \
PORT_FORWARD_PID=$$!; \
sleep 3; \
./orchestrator/scripts/task_integration_test.sh; \
kill $$PORT_FORWARD_PID 2>/dev/null || true
send-libpng-task:
@echo "Running libpng task..."
@@ -159,11 +145,11 @@ send-libpng-task:
echo "Error: CRS namespace not found. Deploy first with 'make deploy'."; \
exit 1; \
fi
kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-ui 31323:1323 &
@sleep 3
./orchestrator/scripts/task_crs.sh
pkill -f "kubectl port-forward" || true
exit 0
@kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-ui 31323:1323 & \
PORT_FORWARD_PID=$$!; \
sleep 3; \
./orchestrator/scripts/task_crs.sh; \
kill $$PORT_FORWARD_PID 2>/dev/null || true
submit-project:
@echo "Starting interactive custom challenge submission..."
@@ -179,26 +165,24 @@ submit-project:
# Development targets
lint:
@echo "Linting all Python code..."
@for component in common orchestrator fuzzer program-model seed-gen patcher; do \
@set -e; for component in common orchestrator fuzzer fuzzer_runner program-model seed-gen patcher; do \
make --no-print-directory lint-component COMPONENT=$$component; \
done
# Note: common, patcher, orchestrator, program-model, seed-gen run mypy
# Note: all components run ty type checking
lint-component:
@if [ -z "$(COMPONENT)" ]; then \
echo "Error: COMPONENT not specified. Usage: make lint-component COMPONENT=<component>"; \
echo "Available components: common, fuzzer, orchestrator, patcher, program-model, seed-gen"; \
echo "Available components: common, fuzzer, fuzzer_runner, orchestrator, patcher, program-model, seed-gen"; \
exit 1; \
fi
@echo "Linting $(COMPONENT)..."
@cd $(COMPONENT) && uv sync -q --all-extras && uv run ruff format --check && uv run ruff check
@if [ "$(COMPONENT)" = "common" ] || [ "$(COMPONENT)" = "patcher" ] || [ "$(COMPONENT)" = "orchestrator" ] || [ "$(COMPONENT)" = "program-model" ] || [ "$(COMPONENT)" = "seed-gen" ]; then \
cd $(COMPONENT) && uv run mypy; \
fi
@cd $(COMPONENT) && uv run ty check src/
reformat:
@echo "Reformatting all Python code..."
@for component in common orchestrator fuzzer program-model seed-gen patcher; do \
@for component in common orchestrator fuzzer fuzzer_runner program-model seed-gen patcher; do \
make --no-print-directory reformat-component COMPONENT=$$component; \
done
@@ -216,6 +200,10 @@ undeploy:
@echo "Cleaning up deployment..."
cd deployment && make down
undeploy-k8s:
@echo "Cleaning up kubernetes resources..."
cd deployment && make down-k8s
clean-local:
@echo "Cleaning up local environment..."
minikube delete || true
@@ -223,20 +211,30 @@ clean-local:
# Additional targets migrated from justfile
install-cscope:
cd external/aixcc-cscope/ && autoreconf -i -s && ./configure && make && sudo make install
cd external/buttercup-cscope/ && autoreconf -i -s && ./configure && make && sudo make install
web-ui:
@echo "Opening web UI..."
signoz-ui:
@echo "Opening SigNoz UI..."
@echo ""
@echo "NOTE: If you plan to redeploy and access SigNoz, clear your browser cookies for http://localhost:33301 to avoid login issues."
@echo ""
@if ! kubectl get namespace $${BUTTERCUP_NAMESPACE:-crs} >/dev/null 2>&1; then \
echo "Error: CRS namespace not found. Deploy first with 'make deploy'."; \
exit 1; \
fi
kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-ui 31323:1323 &
@if ! kubectl get service/buttercup-signoz-frontend -n $${BUTTERCUP_NAMESPACE:-crs} >/dev/null 2>&1; then \
echo "Error: SigNoz is not deployed. Set DEPLOY_SIGNOZ=true in deployment/env and redeploy."; \
exit 1; \
fi
kubectl port-forward -n $${BUTTERCUP_NAMESPACE:-crs} service/buttercup-signoz-frontend 33301:3301 &
@sleep 3
@if command -v xdg-open >/dev/null 2>&1; then \
xdg-open http://localhost:31323; \
xdg-open http://localhost:33301; \
elif command -v open >/dev/null 2>&1; then \
open http://localhost:31323; \
open http://localhost:33301; \
else \
echo "Please open http://localhost:31323 in your browser."; \
echo "Please open http://localhost:33301 in your browser."; \
fi
web-ui:
@./scripts/web_ui.sh
+103 -307
View File
@@ -1,23 +1,72 @@
# Buttercup Cyber Reasoning System (CRS)
**Buttercup** is a Cyber Reasoning System (CRS) developed by **Trail of Bits** for the **DARPA AIxCC (AI Cyber Challenge) competition**. It's a comprehensive automated vulnerability detection and patching system designed to compete in AI-driven cybersecurity challenges.
[![Tests](https://github.com/trailofbits/buttercup/actions/workflows/tests.yml/badge.svg)](https://github.com/trailofbits/buttercup/actions/workflows/tests.yml)
[![Component Integration Tests](https://github.com/trailofbits/buttercup/actions/workflows/comp-integration.yml/badge.svg)](https://github.com/trailofbits/buttercup/actions/workflows/comp-integration.yml)
[![System Integration](https://github.com/trailofbits/buttercup/actions/workflows/integration.yml/badge.svg)](https://github.com/trailofbits/buttercup/actions/workflows/integration.yml)
**Buttercup** is a Cyber Reasoning System (CRS) developed by **Trail of Bits** for the **DARPA AIxCC (AI Cyber Challenge)**. Buttercup finds and patches software vulnerabilities in open-source code repositories like [example-libpng](https://github.com/tob-challenges/example-libpng). It starts by running an AI/ML-assisted fuzzing campaign (built on oss-fuzz) for the program. When vulnerabilities are found, Buttercup analyzes them and uses a multi-agent AI-driven patcher to repair the vulnerability. **Buttercup** system consists of several components:
- **Orchestrator**: Coordinates the overall task process and manages the workflow
- **Seed Generator**: Creates inputs for vulnerability discovery
- **Fuzzer**: Discovers vulnerabilities through intelligent fuzzing techniques
- **Program Model**: Analyzes code structure and semantics for better understanding
- **Patcher**: Generates and applies security patches to fix vulnerabilities
## System Requirements
### Minimum Requirements
- **CPU:** 8 cores
- **Memory:** 16 GB RAM
- **Storage:** 100 GB available disk space
- **Network:** Stable internet connection for downloading dependencies
**Note:** Buttercup uses third-party AI providers (LLMs from companies like OpenAI, Anthropic and Google), which cost money. Please ensure that you manage per-deployment costs by using the built-in LLM budget setting.
**Note:** Buttercup works best with access to models from OpenAI **and** Anthropic, but can be run with at least one API key from one third-party provider. You can use a combination of OpenAI, Anthropic, and Google LLMs.
### Supported Systems
- **Linux x86_64** (fully supported)
- **macOS** (local development supported via Docker Desktop or Colima)
- **ARM64** (partial support for upstream Google OSS-Fuzz projects)
### Required System Packages
Before setup, ensure you have these packages installed:
```bash
# Ubuntu/Debian
sudo apt-get update
sudo apt-get install -y make curl git
# RHEL/CentOS/Fedora
sudo yum install -y make curl git
# or
sudo dnf install -y make curl git
# MacOS
brew install make curl git
```
### Supported Targets
Buttercup works with:
- **C source code repositories** that are OSS-Fuzz compatible
- **Java source code repositories** that are OSS-Fuzz compatible
- Projects that build successfully and have existing fuzzing harnesses
## Quick Start
Clone the repo with `--recurse-submodules` as some dependencies are submodules.
1. Clone the repository with submodules:
Choose your deployment method:
```bash
git clone --recurse-submodules https://github.com/trailofbits/buttercup.git
cd buttercup
```
- **[Local Development](#local-development)** - Quick setup for development and testing
- **[Production AKS Deployment](#production-aks-deployment)** - Full production deployment on Azure Kubernetes Service
## Local Development
The fastest way to get started with the **Buttercup CRS** system for development and testing.
### Quick Setup (Recommended)
Use our automated setup script:
1. Run automated setup (Recommended)
```bash
make setup-local
@@ -25,339 +74,86 @@ make setup-local
This script will install all dependencies, configure the environment, and guide you through the setup process.
### Manual Setup
**Note:** If you prefer manual setup, see the [Manual Setup Guide](guides/MANUAL_SETUP.md).
If you prefer to set up manually, follow these steps:
1. Ensure Docker is running before deploying:
```bash
# Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group changes to take effect
# kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
# Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Minikube
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
# Git LFS (for some tests)
sudo apt-get install git-lfs
git lfs install
# Docker Desktop: start the application
# Colima (macOS/Linux alternative):
colima start --cpu 6 --memory 10 --disk 80
```
#### Manual Configuration
1. **Create configuration file:**
1. Start Buttercup locally
```bash
cp deployment/env.template deployment/env
make deploy
```
2. **Configure the environment file** (`deployment/env`):
Look at the comments in the `deployment/env.template` for how to set variables.
### Start Local Development Environment
1. **Start the services:**
```bash
make deploy-local
```
2. **Verify deployment:**
1. Verify local deployment:
```bash
make status
```
When the deployment is succesful, you should see something like
When a deployment is successful, you should see all pods in "Running" or "Completed" status.
```shell
$ make status
----------PODS------------
NAME READY STATUS RESTARTS AGE
buttercup-build-bot-845f5b96d9-7t8bz 1/1 Running 0 5m58s
buttercup-build-bot-845f5b96d9-bfsq9 1/1 Running 0 5m58s
buttercup-build-bot-845f5b96d9-npns4 1/1 Running 0 5m58s
buttercup-build-bot-845f5b96d9-sv5fr 1/1 Running 0 5m58s
buttercup-coverage-bot-6749f57b9d-4gzfd 1/1 Running 0 5m58s
buttercup-dind-452s6 1/1 Running 0 5m58s
buttercup-fuzzer-bot-74bc9b849d-2zkt6 1/1 Running 0 5m58s
buttercup-image-preloader-97nfb 0/1 Completed 0 5m58s
buttercup-litellm-5f87df944-2mq7z 1/1 Running 0 5m58s
buttercup-litellm-migrations-ljjcl 0/1 Completed 0 5m58s
buttercup-merger-bot-fz87v 1/1 Running 0 5m58s
buttercup-patcher-7597c965b8-6968s 1/1 Running 0 5m58s
buttercup-postgresql-0 1/1 Running 0 5m58s
buttercup-pov-reproducer-5f948bd7cc-45rgp 1/1 Running 0 5m58s
buttercup-program-model-67446b5cfc-24zfh 1/1 Running 0 5m58s
buttercup-redis-master-0 1/1 Running 0 5m58s
buttercup-registry-cache-5787f86896-czt9b 1/1 Running 0 5m58s
buttercup-scheduler-7c49bf75c5-swqkb 1/1 Running 0 5m58s
buttercup-scratch-cleaner-hdt6z 1/1 Running 0 5m58s
buttercup-seed-gen-6fdb9c94c9-4xmrp 1/1 Running 0 5m57s
buttercup-task-downloader-54cd9fb577-g4lbg 1/1 Running 0 5m58s
buttercup-task-server-7d8cd7cf49-zkt69 1/1 Running 0 5m58s
buttercup-tracer-bot-5b9fb6c8b5-zcmxd 1/1 Running 0 5m58s
buttercup-ui-5dcf7dfb8-njglh 1/1 Running 0 5m58s
----------SERVICES--------
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
buttercup-litellm ClusterIP 10.96.88.226 <none> 4000/TCP 5m58s
buttercup-postgresql ClusterIP 10.111.161.207 <none> 5432/TCP 5m58s
buttercup-postgresql-hl ClusterIP None <none> 5432/TCP 5m58s
buttercup-redis-headless ClusterIP None <none> 6379/TCP 5m58s
buttercup-redis-master ClusterIP 10.108.61.77 <none> 6379/TCP 5m58s
buttercup-registry-cache ClusterIP 10.103.80.241 <none> 443/TCP 5m58s
buttercup-task-server ClusterIP 10.104.206.197 <none> 8000/TCP 5m58s
buttercup-ui ClusterIP 10.106.49.166 <none> 1323/TCP 5m58s
All CRS pods up and running.
```
1. Send Buttercup a simple task
3. **Submit the libpng project to the CRS (for 30mins):**
**Note:** When tasked, Buttercup will start consuming third-party AI resources.
This command will make Buttercup pull down an example repo [example-libpng](https://github.com/tob-challenges/example-libpng) with a known vulnerability. Buttercup will start fuzzing it to find and patch vulnerabilities.
```bash
make send-libpng-task
```
**Alternative manual commands:**
1. Access Buttercup's web-based GUI
Run:
```bash
# Start services manually
cd deployment && make up
# Port forward manually
kubectl port-forward -n crs service/buttercup-ui 31323:1323
# Test manually
./orchestrator/scripts/task_integration_test.sh
make web-ui
```
## Production AKS Deployment
Then navigate to `http://localhost:31323` in your web browser.
> **⚠️ Notice:**
> The following production deployment instructions have **not been fully tested**.
> Please proceed with caution and verify each step in your environment.
> If you encounter issues, consult the script comments and configuration files for troubleshooting.
In the GUI you can monitor active tasks and see when Buttercup finds bugs and generates patches for them.
Full production deployment of the **Buttercup CRS** on Azure Kubernetes Service with proper networking, monitoring, and scaling for the DARPA AIxCC competition.
1. Stop Buttercup
### Quick Setup (Recommended)
Use our automated setup script:
```bash
make setup-azure
```
This script will check prerequisites, help create service principals, configure the environment, and validate your setup.
#### Manual Setup
If you prefer to set up manually, follow these steps:
##### Prerequisites
- Azure CLI installed and configured
- Terraform installed
- Active Azure subscription
- Access to competition Tailscale tailnet
##### Azure Setup
1. **Login to Azure:**
```bash
az login --tenant <your-tenant-here>
```
2. **Create Service Principal:**
```bash
# Get your subscription ID
az account show --query "{SubscriptionID:id}" --output table
# Create service principal (replace with your subscription ID)
az ad sp create-for-rbac --name "ButtercupCRS" --role Contributor --scopes /subscriptions/<YOUR-SUBSCRIPTION-ID>
```
##### Production Configuration
1. **Configure environment file:**
```bash
cp deployment/env.template deployment/env
```
2. **Update `deployment/env` for production:**
Look at the comments in the `deployment/env.template` for how to set variables.
In particular, set `TF_VAR_*` variables, and `TAILSCALE_*` if used.
### Deploy to AKS
**Deploy the cluster and services:**
```bash
make deploy-azure
```
**Alternative manual command:**
```bash
cd deployment && make up
```
### Scaling and Management
- **Scale nodes:** Update `TF_VAR_usr_node_count` in your env file and run `make up`
- **View logs:** `kubectl logs -n crs <pod-name>`
- **Monitor resources:** `kubectl top pods -A`
## Run Challenges
```bash
kubectl port-forward -n crs service/buttercup-ui 31323:1323 &
./orchestrator/scripts/challenge.py
```
## Cleanup
**Note:** This is an important step to ensure Buttercup shuts down and stops consuming third-party AI resources.
```bash
make undeploy
```
**Alternative manual command:**
## Accessing Logs
Buttercup includes local SigNoz deployment by default for comprehensive system observability. You can access logs, traces, and metrics through the SigNoz UI:
```bash
cd deployment && make down
make signoz-ui
```
## Development Workflow
Then navigate to `http://localhost:33301` in your web browser to view:
### Using Makefile Shortcuts
- Distributed traces
- Application metrics
- Error monitoring
- Performance insights
The **Buttercup CRS** project includes a Makefile with convenient shortcuts for common tasks:
If you configured LangFuse during setup, you can also monitor LLM usage and costs there.
```bash
# View all available commands
make help
For additional log access methods, see the [Quick Reference Guide](guides/QUICK_REFERENCE.md).
# Setup
make setup-local # Automated local development setup
make setup-azure # Automated production AKS setup
make validate # Validate current setup and configuration
## Additional Resources
# Deployment
make deploy # Deploy to current environment (local or azure)
make deploy-local # Deploy to local Minikube environment
make deploy-azure # Deploy to production AKS environment
# Status
make status # Check the status of the deployment
# Testing
make send-integration-task # Run integration test task
make send-libpng-task # Run libpng test task
# Development
make lint # Lint all Python code
make lint-component COMPONENT=orchestrator # Lint specific component
# Cleanup
make undeploy # Remove deployment and clean up resources
make clean-local # Delete Minikube cluster and remove local config
```
### Running Tests
```bash
# Lint all Python code
make lint
# Lint specific component
make lint-component COMPONENT=orchestrator
```
**Alternative manual commands:**
```bash
# Lint Python code
make lint
# Run specific component tests
make lint-component COMPONENT=orchestrator
# Test manually
./orchestrator/scripts/task_upstream_libpng.sh
./orchestrator/scripts/challenge.py
```
### Kubernetes Development
```bash
# Port forward for local access
kubectl port-forward -n crs service/buttercup-competition-api 31323:1323
# View logs
kubectl logs -n crs -l app=scheduler --tail=-1 --prefix
# Debug pods
kubectl exec -it -n crs <pod-name> -- /bin/bash
```
## Troubleshooting
### Common Issues
1. **Minikube won't start:**
```bash
minikube delete
```
2. **Docker permission issues:**
```bash
sudo usermod -aG docker $USER
# Log out and back in
```
3. **Helm chart issues:**
```bash
helm repo update
helm dependency update deployment/k8s/
```
4. **Azure authentication:**
```bash
az login --tenant <your-tenant>
az account set --subscription <your-subscription-id>
```
### Getting Help
- **Validate your setup:** `make validate` - Check if your environment is ready
- Check the [Quick Reference Guide](QUICK_REFERENCE.md) for common commands and troubleshooting
- Check the [deployment README](deployment/README.md) for detailed deployment information
- Check logs: `kubectl logs -n crs <pod-name>`
## Architecture
The **Buttercup CRS** system consists of several components designed to work together for automated vulnerability detection and patching:
- **Orchestrator**: Coordinates the overall repair process and manages the workflow
- **Fuzzer**: Discovers vulnerabilities through intelligent fuzzing techniques
- **Patcher**: Generates and applies security patches to fix vulnerabilities
- **Program Model**: Analyzes code structure and semantics for better understanding
- **Seed Generator**: Creates targeted test cases for vulnerability discovery
- [Quick Reference Guide](guides/QUICK_REFERENCE.md) - Common commands and troubleshooting
- [Manual Setup Guide](guides/MANUAL_SETUP.md) - Detailed manual installation steps
- [AKS Deployment Guide](guides/AKS_DEPLOYMENT.md) - Production deployment on Azure
- [Contributing Guidelines](CONTRIBUTING.md) - Development workflow and standards
- [Deployment Documentation](deployment/README.md) - Advanced deployment configuration
- [Writing Custom Challenges](guides/CUSTOM_CHALLENGES.md) - Custom project configuration and setup
- [Unscored rounds](guides/UNSCORED.md) - Running unscored round challenges
- [Scored round](guides/SCORED.md) - Parsing post-final round results
+75 -41
View File
@@ -1,24 +1,32 @@
[project]
name = "common"
version = "0.1.0"
description = ""
authors = [{ name = "Trail of Bits", email = "opensource@trailofbits" }]
requires-python = ">=3.10,<3.13"
description = "Shared utilities and components for Buttercup CRS"
authors = [{ name = "Trail of Bits", email = "opensource@trailofbits.com" }]
license = "AGPL-3.0-only"
requires-python = ">=3.12,<3.13"
dependencies = [
"protobuf (>=3.20, <=3.20.3)",
"pydantic-settings>=2.7.1",
"pymongo>=4.10.1",
"redis (>=5.2.1,<6.0.0)",
"langchain-core~=0.3.32",
"langchain-openai~=0.3.2",
"langfuse ~= 2.59.2",
"langchain ~= 0.3.18",
"six>=1.17.0",
"openlit == 1.32.12", # 1.33 currently fails to import opentelemetry.sdk._events
"langchain-community ~= 0.3.20", # implicit dep of openlit if you instrument langchain
"langchain-text-splitters ~= 0.3.7", # implicit dep of openlit if you instrument langchain
"pydantic-settings ~=2.10.1",
"pymongo ~=4.10.1",
"redis ~=5.2.1",
"langchain-core~=1.3.3",
"langchain-openai ~=1.1.0",
"langchain ~=1.2.0",
"six ~=1.17.0",
]
[project.optional-dependencies]
full = [
"protobuf>=5.0",
"openlit ~=1.41.0",
"langfuse ~=4.0.1",
]
[project.urls]
Repository = "https://github.com/trailofbits/buttercup"
Issues = "https://github.com/trailofbits/buttercup/issues"
[project.scripts]
buttercup-util = "buttercup.common.util_cli:main"
buttercup-challenge-task = "buttercup.common.challenge_task_cli:main"
@@ -34,17 +42,24 @@ build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=8.3.4",
"ruff>=0.9.2",
"mypy>=1.15.0",
"types-requests",
# Testing tools
"pytest~=9.0.3",
"pytest-cov ~=6.0.0",
"dirty-equals ~=0.9.0",
# Linting and type checking
"ruff ~=0.14.0",
"ty", # Astral type checker (replaces mypy)
# Type stubs
"types-requests ~=2.32.0",
"types-PyYAML",
"types-redis",
"dirty-equals>=0.9.0",
"types-redis ~=4.6.0",
# Protobuf code generation
"grpcio-tools ~=1.73.1",
]
[tool.ruff]
line-length = 120
target-version = "py312"
exclude = [
"./src/buttercup/common/datastructures",
"./src/buttercup/common/clusterfuzz_env",
@@ -52,28 +67,47 @@ exclude = [
"src/buttercup/common/clusterfuzz_utils.py",
]
[tool.mypy]
plugins = ["pydantic.mypy"]
mypy_path = "src"
packages = "buttercup"
allow_redefinition = true
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true
show_error_codes = true
sqlite_cache = true
strict_equality = true
warn_no_return = true
warn_redundant_casts = true
warn_return_any = true
warn_unreachable = true
warn_unused_configs = true
warn_unused_ignores = true
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP"]
ignore = [
"COM812", "ISC001", # Formatter conflicts
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "D"] # Allow asserts, no docstrings in tests
"*/__cli__.py" = ["T201"] # Allow print in CLI modules
"*/util_cli.py" = ["T201"]
"*/challenge_task_cli.py" = ["T201"]
"*/task_registry.py" = ["T201"]
"src/buttercup/common/node_local.py" = ["UP040"] # PEP 695 type aliases break runtime usage as constructors
[tool.coverage.run]
source = ["src/buttercup"]
omit = ["*/tests/*", "*/__cli__.py", "*/_cli.py", "*/util_cli.py", "*/challenge_task_cli.py", "*/task_registry.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"if TYPE_CHECKING:",
]
[tool.ty.environment]
python-version = "3.12"
[tool.ty.src]
# Explicitly include src directory
include = ["src"]
exclude = [
"src/buttercup/common/clusterfuzz_parser",
"src/buttercup/common/clusterfuzz_env",
"src/buttercup/common/clusterfuzz_utils",
"src/buttercup/common/clusterfuzz_utils.py",
"src/buttercup/common/datastructures",
]
[tool.ty.rules]
unresolved-import = "error"
unresolved-attribute = "error"
+221 -81
View File
@@ -1,36 +1,43 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Any, Callable, TypeVar, cast
from os import PathLike
from functools import wraps, cached_property
import contextlib
import logging
import shlex
import os
from contextlib import contextmanager
import tempfile
import shutil
import uuid
import subprocess
import re
import shlex
import shutil
import subprocess
import tempfile
import uuid
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import cached_property, wraps
from os import PathLike
from pathlib import Path
from typing import Any, TypeVar, cast
from packaging.version import Version
from buttercup.common import node_local
from buttercup.common.constants import ARCHITECTURE
from buttercup.common.stack_parsing import get_crash_token
from buttercup.common.task_meta import TaskMeta
from buttercup.common.utils import copyanything, get_diffs
from buttercup.common.stack_parsing import get_crash_token
from typing import Iterator
import buttercup.common.node_local as node_local
from buttercup.common.constants import ARCHITECTURE
from packaging.version import Version
logger = logging.getLogger(__name__)
@contextmanager
def create_tmp_dir(
challenge: ChallengeTask, work_dir: Path | None, delete: bool = True, prefix: str | None = None
challenge: ChallengeTask,
work_dir: Path | None,
delete: bool = True,
prefix: str | None = None,
) -> Iterator[Path]:
"""Create a temporary directory inside a working dir and either keep or
delete it after use."""
delete it after use.
"""
if work_dir:
work_dir.mkdir(parents=True, exist_ok=True)
@@ -60,8 +67,6 @@ def create_tmp_dir(
class ChallengeTaskError(Exception):
"""Base class for Challenge Task errors."""
pass
FAILURE_ERR_RESULT = 201
TIMEOUT_ERR_RESULT = 124
@@ -104,7 +109,7 @@ class ReproduceResult:
"""Determine if the fuzzer at least ran"""
return bool(
(self.command_result.output and b"INFO: Seed: " in self.command_result.output)
or (self.command_result.error and b"INFO: Seed: " in self.command_result.error)
or (self.command_result.error and b"INFO: Seed: " in self.command_result.error),
)
# This is intended to encapsulate heuristics for determining if a run caused a crash
@@ -189,7 +194,8 @@ class ChallengeTask:
def _local_ro_dir(self, path: PathLike[str] | str) -> Path:
"""Return the local path to the read-only task directory.
If the path doesn't exist, it will be downloaded from the remote storage"""
If the path doesn't exist, it will be downloaded from the remote storage
"""
lp = Path(path)
if not lp.exists():
try:
@@ -226,12 +232,15 @@ class ChallengeTask:
return self._find_first_dir(self.OSS_FUZZ_DIR)
def _task_dir_compose_path(
self, subpath_method: Callable[[], Path | None], raise_on_none: bool = False
self,
subpath_method: Callable[[], Path | None],
raise_on_none: bool = False,
) -> Path | None:
subpath = subpath_method()
if subpath is None:
if raise_on_none:
raise ChallengeTaskError(f"Path not found: {subpath_method.__name__}")
method_name = getattr(subpath_method, "__name__", repr(subpath_method))
raise ChallengeTaskError(f"Path not found: {method_name}")
return None
return self.task_dir / subpath
@@ -260,10 +269,11 @@ class ChallengeTask:
except Exception as e:
raise ChallengeTaskError(f"Python executable couldn't be run: {self.python_path}") from e
def _workdir_from_lines(self, lines: list[str], default: Path = Path("/src")) -> Path:
@staticmethod
def _workdir_from_lines(lines: list[str], default: Path = Path("/src")) -> Path:
"""Gets the WORKDIR from the given lines."""
for line in reversed(lines): # reversed to get last WORKDIR.
match = re.match(self.WORKDIR_REGEX, line)
match = re.match(ChallengeTask.WORKDIR_REGEX, line)
if match:
workdir = match.group(1)
workdir = workdir.replace("$SRC", "/src")
@@ -322,7 +332,7 @@ class ChallengeTask:
raise ChallengeTaskError("Challenge Task is read-only, cannot perform this operation")
return func(self, *args, **kwargs)
return cast(F, wrapper)
return cast("F", wrapper)
def _add_optional_arg(self, cmd: list[str], flag: str, arg: Any | None) -> None:
if arg is not None:
@@ -364,49 +374,52 @@ class ChallengeTask:
return current_line
def _run_cmd(
self, cmd: list[str], cwd: Path | None = None, log: bool = True, env_helper: Dict[str, str] | None = None
self,
cmd: list[str],
cwd: Path | None = None,
log: bool = True,
env_helper: dict[str, str] | None = None,
) -> CommandResult:
try:
if env_helper:
logger.debug("Env helper: %s", env_helper)
env_helper = {**os.environ, **env_helper}
logger.debug(f"Running command (cwd={cwd}): {' '.join(cmd)}")
process = subprocess.Popen( # noqa: S603
with subprocess.Popen( # noqa: S603
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=cwd,
env=env_helper,
)
) as process:
# Poll process for new output until finished
stdout = b""
stderr = b""
current_output_line = b""
current_error_line = b""
while True:
stdout_line = process.stdout.readline() if process.stdout else b""
stderr_line = process.stderr.readline() if process.stderr else b""
if stdout_line:
current_output_line = self._log_output_line(current_output_line, stdout_line, log)
stdout += stdout_line
# Poll process for new output until finished
stdout = b""
stderr = b""
current_output_line = b""
current_error_line = b""
while True:
stdout_line = process.stdout.readline() if process.stdout else b""
stderr_line = process.stderr.readline() if process.stderr else b""
if stdout_line:
current_output_line = self._log_output_line(current_output_line, stdout_line, log)
stdout += stdout_line
if stderr_line:
current_error_line = self._log_output_line(current_error_line, stderr_line, log)
stderr += stderr_line
if stderr_line:
current_error_line = self._log_output_line(current_error_line, stderr_line, log)
stderr += stderr_line
# Break if process has finished and we've read all output
if not stdout_line and not stderr_line and process.poll() is not None:
break
# Break if process has finished and we've read all output
if not stdout_line and not stderr_line and process.poll() is not None:
break
returncode = process.wait()
returncode = process.wait()
return CommandResult(
success=returncode == 0,
returncode=returncode,
error=stderr,
output=stdout,
)
return CommandResult(
success=returncode == 0,
returncode=returncode,
error=stderr,
output=stdout,
)
except subprocess.CalledProcessError as e:
logger.error(f"Command failed (cwd={cwd}): {' '.join(cmd)}")
return CommandResult(
@@ -419,7 +432,7 @@ class ChallengeTask:
logger.exception(f"Command failed (cwd={cwd}): {' '.join(cmd)}")
return CommandResult(success=False, returncode=None, error=str(e).encode(), output=None)
def _run_helper_cmd(self, cmd: list[str], env_helper: Dict[str, str] | None = None) -> CommandResult:
def _run_helper_cmd(self, cmd: list[str], env_helper: dict[str, str] | None = None) -> CommandResult:
oss_fuzz_subpath = self.get_oss_fuzz_subpath()
if oss_fuzz_subpath is None:
raise ChallengeTaskError("OSS-Fuzz path not found")
@@ -432,7 +445,7 @@ class ChallengeTask:
try:
result = self._run_helper_cmd(grep_cmd)
except Exception as e:
logger.exception(f"[task {self.task_dir}] Error grep'ing for base-runner version: {str(e)}")
logger.exception(f"[task {self.task_dir}] Error grep'ing for base-runner version: {e!s}")
return None
if not result.success:
return None
@@ -448,11 +461,15 @@ class ChallengeTask:
base_runner_str = m.group(1).strip(":v")
return Version(base_runner_str)
except Exception as e:
logger.exception(f"[task {self.task_dir}] Error parsing base-runner version: {str(e)}")
logger.exception(f"[task {self.task_dir}] Error parsing base-runner version: {e!s}")
return None
@cached_property
def oss_fuzz_container_org(self) -> str:
# Check environment variable first
if env_org := os.environ.get("OSS_FUZZ_CONTAINER_ORG"):
return env_org
# Read the helper_path file and grep for the BASE_RUNNER_IMAGE line.
result = "gcr.io/oss-fuzz"
try:
@@ -465,7 +482,7 @@ class ChallengeTask:
if image.startswith("gcr.io/oss-fuzz"):
logger.info(f"Using oss-fuzz container org: {result}")
break
elif image.startswith("ghcr.io/aixcc-finals"):
if image.startswith("ghcr.io/aixcc-finals"):
result = "aixcc-afc"
logger.info(f"Using aixcc-afc container org: {result}")
break
@@ -478,8 +495,7 @@ class ChallengeTask:
return f"{self.oss_fuzz_container_org}/{self.project_name}"
def container_src_dir(self) -> str:
"""
Name of the src directory in the container (e.g. /src/FreeRDP -> FreeRDP).
"""Name of the src directory in the container (e.g. /src/FreeRDP -> FreeRDP).
This assumes that the src directory is the same as the workdir.
"""
return self.workdir_from_dockerfile().parts[-1]
@@ -493,7 +509,8 @@ class ChallengeTask:
always_build_image: bool = False,
) -> CommandResult:
"""Execute a command inside a docker container. If not specified, the
docker container is the oss-fuzz one."""
docker container is the oss-fuzz one.
"""
return self.exec_docker_cmd_rw(cmd, mount_dirs, container_image, always_build_image=always_build_image)
def exec_docker_cmd_rw(
@@ -564,8 +581,8 @@ class ChallengeTask:
architecture: str | None = ARCHITECTURE,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
env_helper: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
env_helper: dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Building fuzzers for project %s | architecture=%s | engine=%s | sanitizer=%s | env=%s | use_source_dir=%s",
@@ -610,8 +627,8 @@ class ChallengeTask:
engine: str | None = None,
sanitizer: str | None = None,
pull_latest_base_image: bool = True,
env: Dict[str, str] | None = None,
env_helper: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
env_helper: dict[str, str] | None = None,
) -> CommandResult:
check_build_res = self.check_build(architecture=architecture, engine=engine, sanitizer=sanitizer, env=env)
if check_build_res.success:
@@ -639,7 +656,7 @@ class ChallengeTask:
engine: str | None = None,
sanitizer: str | None = None,
pull_latest_base_image: bool = True,
env: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
env_helper = {
"OSS_FUZZ_SAVE_CONTAINERS_NAME": container_name,
@@ -662,7 +679,7 @@ class ChallengeTask:
architecture: str | None = ARCHITECTURE,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Checking build for project %s | architecture=%s | engine=%s | sanitizer=%s | env=%s",
@@ -691,10 +708,11 @@ class ChallengeTask:
fuzzer_args: list[str] | None = None,
*,
architecture: str | None = ARCHITECTURE,
env: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> ReproduceResult:
logger.info(
"Reproducing POV for project %s | fuzzer_name=%s | crash_path=%s | fuzzer_args=%s | architecture=%s | env=%s",
"Reproducing POV for project %s | fuzzer_name=%s | crash_path=%s | "
"fuzzer_args=%s | architecture=%s | env=%s",
self.project_name,
fuzzer_name,
crash_path,
@@ -739,10 +757,11 @@ class ChallengeTask:
architecture: str | None = ARCHITECTURE,
engine: str | None = None,
sanitizer: str | None = None,
env: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Running fuzzer for project %s | harness_name=%s | fuzzer_args=%s | corpus_dir=%s | architecture=%s | engine=%s | sanitizer=%s | env=%s",
"Running fuzzer for project %s | harness_name=%s | fuzzer_args=%s | "
"corpus_dir=%s | architecture=%s | engine=%s | sanitizer=%s | env=%s",
self.project_name,
harness_name,
fuzzer_args,
@@ -774,7 +793,7 @@ class ChallengeTask:
harness_name: str,
corpus_dir: str,
architecture: str | None = ARCHITECTURE,
env: Dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> CommandResult:
logger.info(
"Running coverage for project %s | harness_name=%s | corpus_dir=%s | architecture=%s | env=%s",
@@ -835,17 +854,129 @@ class ChallengeTask:
return True
except FileNotFoundError as e:
logger.error(f"[task {self.task_dir}] File not found: {str(e)}")
raise ChallengeTaskError(f"[task {self.task_dir}] File not found: {str(e)}") from e
logger.error(f"[task {self.task_dir}] File not found: {e!s}")
raise ChallengeTaskError(f"[task {self.task_dir}] File not found: {e!s}") from e
except subprocess.CalledProcessError as e:
logger.error(f"[task {self.task_dir}] Error applying diff: {str(e)}")
logger.error(f"[task {self.task_dir}] Error applying diff: {e!s}")
logger.debug(f"[task {self.task_dir}] Error returncode: {e.returncode}")
logger.debug(f"[task {self.task_dir}] Error stdout: {e.stdout}")
logger.debug(f"[task {self.task_dir}] Error stderr: {e.stderr}")
raise ChallengeTaskError(f"[task {self.task_dir}] Error applying diff: {str(e)}") from e
raise ChallengeTaskError(f"[task {self.task_dir}] Error applying diff: {e!s}") from e
except Exception as e:
logger.exception(f"[task {self.task_dir}] Error applying diff: {str(e)}")
raise ChallengeTaskError(f"[task {self.task_dir}] Error applying diff: {str(e)}") from e
logger.exception(f"[task {self.task_dir}] Error applying diff: {e!s}")
raise ChallengeTaskError(f"[task {self.task_dir}] Error applying diff: {e!s}") from e
def _hack_oss_fuzz_aarch64_dockerfile(self, task: ChallengeTask) -> None:
# We find the oss-fuzz/projects/<project>/Dockerfile and make sure the
# base image has `:manifest-arm64v8` tag
dockerfile_path = task.get_oss_fuzz_path() / "projects" / task.project_name / "Dockerfile"
if not dockerfile_path.exists():
return
dockerfile_content = dockerfile_path.read_text()
# Regex to match FROM gcr.io/oss-fuzz-base/base-builder* [optional tag] [optional as builder]
# Only patch base-builder variants, not base-clang or others that may not have manifest-arm64v8 tag
def _replace_from(match: re.Match) -> str:
image = match.group(1)
as_clause = match.group(2) or ""
# Always ensure tag is :manifest-arm64v8 regardless if there was a tag before
return f"FROM {image}:manifest-arm64v8{as_clause}"
# This regex matches FROM lines with base-builder images only
pattern = r"^FROM\s+(gcr\.io/oss-fuzz-base/base-builder(?:[^\s:]*)?)(?::[^\s]+)?(\s+as\s+\w+)?\s*$"
new_content = re.sub(pattern, _replace_from, dockerfile_content, flags=re.MULTILINE)
if new_content != dockerfile_content:
dockerfile_path.write_text(new_content)
logger.info("Patched oss-fuzz %s/Dockerfile to use the :manifest-arm64v8 tag", task.project_name)
def _hack_oss_fuzz_runner(self, task: ChallengeTask, architecture: str) -> None:
"""Patch OSS-Fuzz helper.py with architecture-specific and common fixes.
Common patches (all architectures):
- reproduce_impl: Pass architecture as keyword arg instead of err_result
- Debug mode: Insert -debug before tag, not after
- Tag handling: Check for existing tag before appending
ARM64-specific patches:
- Add :manifest-arm64v8 tags to image_name and BASE_RUNNER_IMAGE
"""
helper_path = task.get_oss_fuzz_path() / "infra" / "helper.py"
if not helper_path.exists():
return
content = helper_path.read_text()
replaced = False
# Common patches for all architectures
def _replace_debug_append(match: re.Match) -> str:
nonlocal replaced
replaced = True
indent = match.group(1)
return f"{indent}image = image.replace(':', '-debug:', 1) if ':' in image else image + '-debug'"
pattern_debug_append = r"^(\s+)image\s*\+=\s*['\"]-debug['\"]\s*$"
content = re.sub(pattern_debug_append, _replace_debug_append, content, flags=re.MULTILINE)
def _replace_get_base_runner_return(match: re.Match) -> str:
nonlocal replaced
replaced = True
indent = match.group(1)
return f"{indent}return image if ':' in image else f'{{image}}:{{tag}}'"
pattern_get_base_runner = r"^(\s+)return f(['\"])\{image\}:\{tag\}\2\s*$"
content = re.sub(pattern_get_base_runner, _replace_get_base_runner_return, content, flags=re.MULTILINE)
def _replace_reproduce_run_function(match: re.Match) -> str:
nonlocal replaced
replaced = True
indent = match.group(1)
return f"{indent}return run_function(run_args, architecture=architecture)"
pattern_reproduce_run = r"^(\s+)return run_function\(run_args,\s*err_result\)\s*$"
content = re.sub(pattern_reproduce_run, _replace_reproduce_run_function, content, flags=re.MULTILINE)
# ARM64-specific patches
if architecture == "aarch64":
def _replace_image_name(match: re.Match) -> str:
nonlocal replaced
replaced = True
original = match.group(0)
if "base-runner-debug" in original:
return f"{match.group(1)}image_name = 'base-runner-debug:manifest-arm64v8'"
else:
return f"{match.group(1)}image_name = 'base-runner:manifest-arm64v8'"
pattern_img = r"(\s*)image_name\s*=\s*['\"]base-runner(?:-debug)?['\"]"
content = re.sub(pattern_img, _replace_image_name, content, flags=re.MULTILINE)
def _replace_base_runner_image(match: re.Match) -> str:
nonlocal replaced
replaced = True
prefix = match.group(1)
image = match.group(2)
suffix = match.group(3) or ""
if ":manifest-arm64v8" not in image:
if ":" in image:
image = image.rsplit(":", 1)[0]
image = image + ":manifest-arm64v8"
return f"{prefix}BASE_RUNNER_IMAGE = '{image}'{suffix}"
pattern_base_img = (
r"(^\s*)BASE_RUNNER_IMAGE\s*=\s*['\"](gcr\.io/oss-fuzz-base/base-runner(?:[^\s'\"]*)?)['\"](\s*)"
)
content = re.sub(pattern_base_img, _replace_base_runner_image, content, flags=re.MULTILINE)
if replaced:
helper_path.write_text(content)
logger.info("Patched oss-fuzz helper.py for %s", architecture)
def _hack_oss_fuzz_aarch64(self, task: ChallengeTask) -> None:
self._hack_oss_fuzz_aarch64_dockerfile(task)
self._hack_oss_fuzz_runner(task, "aarch64")
@contextmanager
def get_rw_copy(self, work_dir: PathLike | None, delete: bool = True) -> Iterator[ChallengeTask]:
@@ -855,6 +986,7 @@ class ChallengeTask:
Example:
with task.get_rw_copy(work_dir) as local_task:
local_task.build_fuzzers()
"""
work_dir = Path(work_dir) if work_dir else Path(node_local.scratch_path())
work_dir = work_dir / self.task_meta.task_id
@@ -871,6 +1003,13 @@ class ChallengeTask:
python_path=self.python_path,
local_task_dir=tmp_dir,
)
# HACK: Apply OSS-Fuzz patches
# For aarch64: Dockerfile + helper.py with :manifest-arm64v8 tags + common fixes
# For other arches: helper.py with common fixes only
if ARCHITECTURE == "aarch64":
self._hack_oss_fuzz_aarch64(copied_task)
else:
self._hack_oss_fuzz_runner(copied_task, ARCHITECTURE)
try:
yield copied_task
@@ -902,7 +1041,7 @@ class ChallengeTask:
raise ChallengeTaskError("Failed to commit task") from e
logger.error(
f"Failed to commit task {self.local_task_dir} to {new_name}. Retrying with a random suffix..."
f"Failed to commit task {self.local_task_dir} to {new_name}. Retrying with a random suffix...",
)
suffix = None
@@ -911,7 +1050,8 @@ class ChallengeTask:
@read_write_decorator
def restore(self) -> None:
"""Restore the task from the original read-only task directory (if
different from the local task directory)."""
different from the local task directory).
"""
if self.read_only_task_dir == self.local_task_dir:
raise ChallengeTaskError("Task cannot be restored, it doesn't have a local task directory")
@@ -1,17 +1,19 @@
from pydantic_settings import BaseSettings, CliSubCommand, get_subcommand, CliImplicitFlag
from pydantic import BaseModel
from buttercup.common.challenge_task import ChallengeTask, CommandResult, ReproduceResult
from buttercup.common.logger import setup_package_logger
from pathlib import Path
from typing import Dict
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Iterator
from pathlib import Path
from pydantic import BaseModel
from pydantic_settings import BaseSettings, CliImplicitFlag, CliSubCommand, SettingsConfigDict, get_subcommand
from buttercup.common.challenge_task import ChallengeTask, CommandResult, ReproduceResult
from buttercup.common.constants import ARCHITECTURE
from buttercup.common.logger import setup_package_logger
class BuildImageCommand(BaseModel):
pull_latest_base_image: bool = False
cache: bool | None = None
architecture: str | None = None
architecture: str = ARCHITECTURE
class ApplyPatchCommand(BaseModel):
@@ -19,29 +21,38 @@ class ApplyPatchCommand(BaseModel):
class BuildFuzzersCommand(BaseModel):
architecture: str | None = None
architecture: str = ARCHITECTURE
engine: str | None = None
sanitizer: str | None = None
env: Dict[str, str] | None = None
env: dict[str, str] | None = None
use_cache: bool = True
class CheckBuildCommand(BaseModel):
architecture: str | None = None
architecture: str = ARCHITECTURE
engine: str | None = None
sanitizer: str | None = None
env: Dict[str, str] | None = None
env: dict[str, str] | None = None
class ReproducePovCommand(BaseModel):
fuzzer_name: str
crash_path: Path
fuzzer_args: list[str] | None = None
architecture: str | None = None
env: Dict[str, str] | None = None
architecture: str = ARCHITECTURE
env: dict[str, str] | None = None
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BUTTERCUP_CHALLENGE_TASK_",
env_file=".env",
cli_parse_args=True,
nested_model_default_partial_update=True,
env_nested_delimiter="__",
extra="allow",
)
task_dir: Path
python_path: Path = Path("python")
rw: CliImplicitFlag[bool] = False
@@ -52,14 +63,6 @@ class Settings(BaseSettings):
check_build: CliSubCommand[CheckBuildCommand]
reproduce_pov: CliSubCommand[ReproducePovCommand]
class Config:
env_prefix = "BUTTERCUP_CHALLENGE_TASK_"
env_file = ".env"
cli_parse_args = True
nested_model_default_partial_update = True
env_nested_delimiter = "__"
extra = "allow"
def handle_subcommand(task: ChallengeTask, subcommand: BaseModel | None) -> CommandResult | ReproduceResult:
if isinstance(subcommand, BuildImageCommand):
@@ -68,7 +71,7 @@ def handle_subcommand(task: ChallengeTask, subcommand: BaseModel | None) -> Comm
cache=subcommand.cache,
architecture=subcommand.architecture,
)
elif isinstance(subcommand, BuildFuzzersCommand):
if isinstance(subcommand, BuildFuzzersCommand):
if subcommand.use_cache:
return task.build_fuzzers_with_cache(
architecture=subcommand.architecture,
@@ -76,21 +79,20 @@ def handle_subcommand(task: ChallengeTask, subcommand: BaseModel | None) -> Comm
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
else:
return task.build_fuzzers(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
elif isinstance(subcommand, CheckBuildCommand):
return task.build_fuzzers(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
if isinstance(subcommand, CheckBuildCommand):
return task.check_build(
architecture=subcommand.architecture,
engine=subcommand.engine,
sanitizer=subcommand.sanitizer,
env=subcommand.env,
)
elif isinstance(subcommand, ReproducePovCommand):
if isinstance(subcommand, ReproducePovCommand):
return task.reproduce_pov(
fuzzer_name=subcommand.fuzzer_name,
crash_path=subcommand.crash_path,
@@ -98,10 +100,9 @@ def handle_subcommand(task: ChallengeTask, subcommand: BaseModel | None) -> Comm
architecture=subcommand.architecture,
env=subcommand.env,
).command_result
elif isinstance(subcommand, ApplyPatchCommand):
if isinstance(subcommand, ApplyPatchCommand):
return CommandResult(success=task.apply_patch_diff(diff_file=subcommand.diff_file))
else:
raise ValueError(f"Unknown subcommand: {subcommand}")
raise ValueError(f"Unknown subcommand: {subcommand}")
@contextmanager
@@ -114,7 +115,7 @@ def get_task_copy(task: ChallengeTask, use_copy: bool = False) -> Iterator[Chall
def main() -> None:
settings = Settings()
settings = Settings() # type: ignore[call-arg]
setup_package_logger("challenge-task-cli", __name__, "DEBUG")
if settings.rw:
task = ChallengeTask(
+2 -3
View File
@@ -5,10 +5,9 @@ def _detect_architecture() -> str:
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
return "x86_64"
elif machine in ("aarch64", "arm64"):
if machine in ("aarch64", "arm64"):
return "aarch64"
else:
raise RuntimeError(f"Unsupported architecture: {machine}")
raise RuntimeError(f"Unsupported architecture: {machine}")
CORPUS_DIR_NAME = "buttercup_corpus"
+98 -24
View File
@@ -1,21 +1,54 @@
import logging
from typing import BinaryIO, List
import buttercup.common.node_local as node_local
from buttercup.common.constants import CORPUS_DIR_NAME, CRASH_DIR_NAME
import os
import hashlib
import logging
import os
import shutil
import subprocess
from pathlib import Path
from redis import Redis
from buttercup.common.sets import MergedCorpusSet
import tempfile
from pathlib import Path
from typing import BinaryIO
from redis import Redis
from buttercup.common import node_local
from buttercup.common.constants import CORPUS_DIR_NAME, CRASH_DIR_NAME
from buttercup.common.sets import MergedCorpusSet
logger = logging.getLogger(__name__)
# TODO: this file is one of the few files that uses os.path.join. Switch to Path.
def _get_corpus_storage_path(wdir: str, corpus_subpath: str) -> tuple[str, Path]:
"""Calculate the local storage path and remote path for corpus.
When tmpfs is enabled, the local path is on tmpfs but the remote path
is still calculated relative to NODE_DATA_DIR.
Args:
wdir: The working directory (typically NODE_DATA_DIR)
corpus_subpath: The corpus subdirectory (e.g., "{task_id}/buttercup_corpus_{harness}")
Returns:
Tuple of (local_path, remote_path)
"""
tmpfs_path = node_local.get_corpus_tmpfs_path()
if tmpfs_path:
# Store corpus on tmpfs
local_path = os.path.join(str(tmpfs_path), corpus_subpath)
# Remote path is still calculated relative to NODE_DATA_DIR structure
# We compute it as if the corpus were stored in wdir
canonical_path = os.path.join(wdir, corpus_subpath)
remote_path = node_local.remote_path(Path(canonical_path))
logger.debug(f"Using tmpfs for corpus: local={local_path}, remote={remote_path}")
else:
# Standard behavior - store corpus in wdir
local_path = os.path.join(wdir, corpus_subpath)
remote_path = node_local.remote_path(Path(local_path))
return local_path, remote_path
def hash_file(fl: BinaryIO) -> str:
h = hashlib.new("sha256")
bts = fl.read(100)
@@ -26,27 +59,55 @@ def hash_file(fl: BinaryIO) -> str:
class InputDir:
def __init__(self, wdir: str, name: str, copy_corpus_max_size: int | None = None):
self.path = os.path.join(wdir, name)
self.remote_path = node_local.remote_path(Path(self.path))
def __init__(
self,
wdir: str,
name: str,
copy_corpus_max_size: int | None = None,
*,
override_local_path: str | None = None,
override_remote_path: Path | None = None,
):
"""Initialize an InputDir for corpus/crash storage.
Args:
wdir: Working directory (typically NODE_DATA_DIR)
name: Subdirectory name within wdir
copy_corpus_max_size: Maximum size for copied corpus files
override_local_path: If provided, use this as the local path instead of wdir/name
override_remote_path: If provided, use this as the remote path
"""
if override_local_path is not None:
self.path = override_local_path
else:
self.path = os.path.join(wdir, name)
if override_remote_path is not None:
self.remote_path = override_remote_path
else:
self.remote_path = node_local.remote_path(Path(self.path))
self.copy_corpus_max_size = copy_corpus_max_size
os.makedirs(self.path, exist_ok=True)
def basename(self) -> str:
return os.path.basename(self.path)
def copy_file(self, src_file: str) -> str:
def copy_file(self, src_file: str, only_local: bool = False) -> str:
with open(src_file, "rb") as f:
nm = hash_file(f)
dst = os.path.join(self.path, nm)
dst_remote = os.path.join(self.remote_path, nm)
os.makedirs(self.remote_path, exist_ok=True)
# Make the file available both node-local and remote
# Copy to local corpus
shutil.copy(src_file, dst)
shutil.copy(dst, dst_remote)
if not only_local:
dst_remote = os.path.join(self.remote_path, nm)
os.makedirs(self.remote_path, exist_ok=True)
# Copy to remote corpus
shutil.copy(dst, dst_remote)
return dst
def copy_corpus(self, src_dir: str) -> list[str]:
"""Copy files from src_dir to local corpus only."""
files = []
for file in os.listdir(src_dir):
file_path = os.path.join(src_dir, file)
@@ -58,7 +119,7 @@ class InputDir:
self.copy_corpus_max_size,
)
continue
files.append(self.copy_file(file_path))
files.append(self.copy_file(file_path, only_local=True))
return files
def local_corpus_size(self) -> int:
@@ -97,7 +158,7 @@ class InputDir:
return len(name) == 64 and all(c in "0123456789abcdef" for c in name)
@classmethod
def hash_corpus(cls, path: str) -> List[str]:
def hash_corpus(cls, path: str) -> list[str]:
hashed_files = []
for file in os.listdir(path):
# If the file is already a hash, skip it
@@ -105,9 +166,12 @@ class InputDir:
continue
file_path = os.path.join(path, file)
try:
assert os.path.isfile(file_path)
with open(file_path, "rb") as f:
hash_filename = hash_file(f)
os.rename(file_path, os.path.join(path, hash_filename))
dst_path = os.path.join(path, hash_filename)
# Use shutil.move which handles cross-filesystem operations
shutil.move(file_path, dst_path)
hashed_files.append(hash_filename)
except Exception as e:
# Likely already hashed by another pod
@@ -130,7 +194,7 @@ class InputDir:
"--exclude=*",
str(src_path) + "/",
str(dst_path) + "/",
]
],
)
def sync_to_remote(self) -> None:
@@ -139,11 +203,11 @@ class InputDir:
self._do_sync(self.path, self.remote_path)
def sync_specific_files_to_remote(self, files: list[str]) -> None:
"""
Sync only specific files to remote storage.
"""Sync only specific files to remote storage.
Args:
files: List of filenames (basename only, not full path) to sync to remote
"""
self.hash_new_corpus()
os.makedirs(self.remote_path, exist_ok=True)
@@ -166,7 +230,7 @@ class InputDir:
f"--files-from={file_list.name}",
str(self.path) + "/",
str(self.remote_path) + "/",
]
],
)
def sync_from_remote(self) -> None:
@@ -219,7 +283,17 @@ class Corpus(InputDir):
self.task_id = task_id
self.harness_name = harness_name
self.corpus_dir = os.path.join(task_id, f"{CORPUS_DIR_NAME}_{harness_name}")
super().__init__(wdir, self.corpus_dir, copy_corpus_max_size=copy_corpus_max_size)
# Get the storage paths, potentially using tmpfs for the local path
local_path, remote_path = _get_corpus_storage_path(wdir, self.corpus_dir)
super().__init__(
wdir,
self.corpus_dir,
copy_corpus_max_size=copy_corpus_max_size,
override_local_path=local_path,
override_remote_path=remote_path,
)
def remove_any_merged(self, redis: Redis) -> None:
merged_corpus_set = MergedCorpusSet(redis, self.task_id, self.harness_name)
+35 -24
View File
@@ -8,12 +8,13 @@ from pathlib import Path
from typing import Any
from redis import Redis
from buttercup.common.maps import CoverageMap, FunctionCoverage, HarnessWeights
# Add matplotlib import for visualization
try:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.dates as mdates # type: ignore[unresolved-import]
import matplotlib.pyplot as plt # type: ignore[unresolved-import]
MATPLOTLIB_AVAILABLE = True
except ImportError:
@@ -22,12 +23,12 @@ except ImportError:
# Move _print_coverage_metrics to be a free function
def print_coverage_metrics(func_coverage_list: list[FunctionCoverage], snapshot_count: int) -> None:
"""
Print coverage metrics for the given list of function coverage objects.
"""Print coverage metrics for the given list of function coverage objects.
Args:
func_coverage_list: List of FunctionCoverage objects
snapshot_count: The current snapshot count
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total_functions = len(func_coverage_list)
@@ -50,8 +51,7 @@ def print_coverage_metrics(func_coverage_list: list[FunctionCoverage], snapshot_
def coverage_data_equal(old_data: list[dict], new_data: list[dict]) -> bool:
"""
Compare two coverage data lists to check if they are equal.
"""Compare two coverage data lists to check if they are equal.
Args:
old_data: Previous coverage data
@@ -59,6 +59,7 @@ def coverage_data_equal(old_data: list[dict], new_data: list[dict]) -> bool:
Returns:
True if coverage data is the same, False otherwise
"""
if len(old_data) != len(new_data):
return False
@@ -67,7 +68,7 @@ def coverage_data_equal(old_data: list[dict], new_data: list[dict]) -> bool:
old_sorted = sorted(old_data, key=lambda x: x["function_name"])
new_sorted = sorted(new_data, key=lambda x: x["function_name"])
for old_item, new_item in zip(old_sorted, new_sorted):
for old_item, new_item in zip(old_sorted, new_sorted, strict=False):
if (
old_item["function_name"] != new_item["function_name"]
or old_item["total_lines"] != new_item["total_lines"]
@@ -106,14 +107,14 @@ class CoverageMonitor:
}
def monitor_coverage(self, duration_seconds: int | None = None) -> str:
"""
Monitor function coverage over time and save results to a file for all packages and harnesses.
"""Monitor function coverage over time and save results to a file for all packages and harnesses.
Args:
duration_seconds: How long to run the monitor. If None, run indefinitely.
Returns:
Path to the output file.
"""
coverage_snapshots = []
start_time = time.time()
@@ -243,8 +244,7 @@ class CoverageMonitor:
def _extract_metrics(
coverage_snapshots: list[dict],
) -> tuple[list[datetime], list[int], list[int], list[int], list[float]]:
"""
Extract metrics from coverage snapshots for analysis and visualization.
"""Extract metrics from coverage snapshots for analysis and visualization.
Returns:
Tuple containing:
@@ -253,6 +253,7 @@ class CoverageMonitor:
- total_lines: List of total line counts for each snapshot
- covered_lines: List of covered line counts for each snapshot
- coverage_percentages: List of coverage percentages for each snapshot
"""
timestamps = []
function_counts = []
@@ -286,11 +287,11 @@ class CoverageMonitor:
covered_lines: list[int],
coverage_percentages: list[float],
) -> str | None:
"""
Create a visualization of coverage metrics over time.
"""Create a visualization of coverage metrics over time.
Returns:
Path to the generated image file, or None if visualization failed.
"""
if not MATPLOTLIB_AVAILABLE:
print("Matplotlib is not available. Install with 'pip install matplotlib' to enable visualization.")
@@ -355,10 +356,12 @@ class CoverageMonitor:
@staticmethod
def analyze_coverage_file(
file_path: str, harness_key: str | None = None, visualize: bool = False, list_only: bool = False
file_path: str,
harness_key: str | None = None,
visualize: bool = False,
list_only: bool = False,
) -> None:
"""
Analyze a previously recorded coverage file and print summary statistics.
"""Analyze a previously recorded coverage file and print summary statistics.
Args:
file_path: Path to the coverage data file.
@@ -366,9 +369,10 @@ class CoverageMonitor:
If None, analyze all harnesses in the file.
visualize: Whether to generate a visualization of the data.
list_only: If True, only list the available harnesses without analyzing.
"""
try:
with open(file_path, "r") as f:
with open(file_path) as f:
coverage_snapshots = json.load(f)
if not coverage_snapshots:
@@ -382,10 +386,9 @@ class CoverageMonitor:
# List all available harnesses
print(f"Coverage file: {file_path}")
print(
f"Time range: {datetime.fromtimestamp(coverage_snapshots[0]['timestamp']).strftime('%Y-%m-%d %H:%M:%S')} - "
f"{datetime.fromtimestamp(coverage_snapshots[-1]['timestamp']).strftime('%Y-%m-%d %H:%M:%S')}"
)
start_time = datetime.fromtimestamp(coverage_snapshots[0]["timestamp"]).strftime("%Y-%m-%d %H:%M:%S")
end_time = datetime.fromtimestamp(coverage_snapshots[-1]["timestamp"]).strftime("%Y-%m-%d %H:%M:%S")
print(f"Time range: {start_time} - {end_time}")
print(f"Total snapshots: {len(coverage_snapshots)}")
print("\nAvailable harnesses:")
@@ -527,7 +530,9 @@ def main() -> None:
monitor_parser.add_argument("--task-id", help="Task ID to filter harnesses (optional)")
monitor_parser.add_argument("--interval", type=int, default=10, help="Interval between snapshots in seconds")
monitor_parser.add_argument(
"--duration", type=int, help="Duration to run the monitor in seconds (default: run indefinitely)"
"--duration",
type=int,
help="Duration to run the monitor in seconds (default: run indefinitely)",
)
# Analyze command
@@ -535,7 +540,10 @@ def main() -> None:
analyze_parser.add_argument("file_path", help="Path to the coverage data file")
analyze_parser.add_argument("--harness-key", help="Harness key to analyze in format 'package_name-harness_name'")
analyze_parser.add_argument(
"--visualize", "-v", action="store_true", help="Generate visualization of coverage metrics"
"--visualize",
"-v",
action="store_true",
help="Generate visualization of coverage metrics",
)
analyze_parser.add_argument("--list", "-l", action="store_true", help="List available harnesses in the file")
@@ -553,7 +561,10 @@ def main() -> None:
elif args.command == "analyze":
CoverageMonitor.analyze_coverage_file(
args.file_path, harness_key=args.harness_key, visualize=args.visualize, list_only=args.list
args.file_path,
harness_key=args.harness_key,
visualize=args.visualize,
list_only=args.list,
)
else:
File diff suppressed because one or more lines are too long
@@ -2,301 +2,318 @@ from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
from collections.abc import Iterable as _Iterable, Mapping as _Mapping
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
ACCEPTED: SubmissionResult
COVERAGE: BuildType
DEADLINE_EXCEEDED: SubmissionResult
DESCRIPTOR: _descriptor.FileDescriptor
ERRORED: SubmissionResult
FAILED: SubmissionResult
class BuildType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
FUZZER: _ClassVar[BuildType]
COVERAGE: _ClassVar[BuildType]
TRACER_NO_DIFF: _ClassVar[BuildType]
PATCH: _ClassVar[BuildType]
class SubmissionResult(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
NONE: _ClassVar[SubmissionResult]
ACCEPTED: _ClassVar[SubmissionResult]
PASSED: _ClassVar[SubmissionResult]
FAILED: _ClassVar[SubmissionResult]
DEADLINE_EXCEEDED: _ClassVar[SubmissionResult]
ERRORED: _ClassVar[SubmissionResult]
INCONCLUSIVE: _ClassVar[SubmissionResult]
FUZZER: BuildType
INCONCLUSIVE: SubmissionResult
NONE: SubmissionResult
PASSED: SubmissionResult
PATCH: BuildType
COVERAGE: BuildType
TRACER_NO_DIFF: BuildType
PATCH: BuildType
NONE: SubmissionResult
ACCEPTED: SubmissionResult
PASSED: SubmissionResult
FAILED: SubmissionResult
DEADLINE_EXCEEDED: SubmissionResult
ERRORED: SubmissionResult
INCONCLUSIVE: SubmissionResult
class BuildOutput(_message.Message):
__slots__ = ["apply_diff", "build_type", "engine", "internal_patch_id", "sanitizer", "task_dir", "task_id"]
APPLY_DIFF_FIELD_NUMBER: _ClassVar[int]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
class Task(_message.Message):
__slots__ = ("message_id", "message_time", "task_id", "task_type", "sources", "deadline", "cancelled", "project_name", "focus", "metadata")
class TaskType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
TASK_TYPE_FULL: _ClassVar[Task.TaskType]
TASK_TYPE_DELTA: _ClassVar[Task.TaskType]
TASK_TYPE_FULL: Task.TaskType
TASK_TYPE_DELTA: Task.TaskType
class MetadataEntry(_message.Message):
__slots__ = ("key", "value")
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
MESSAGE_ID_FIELD_NUMBER: _ClassVar[int]
MESSAGE_TIME_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
apply_diff: bool
build_type: BuildType
engine: str
internal_patch_id: str
sanitizer: str
task_dir: str
TASK_TYPE_FIELD_NUMBER: _ClassVar[int]
SOURCES_FIELD_NUMBER: _ClassVar[int]
DEADLINE_FIELD_NUMBER: _ClassVar[int]
CANCELLED_FIELD_NUMBER: _ClassVar[int]
PROJECT_NAME_FIELD_NUMBER: _ClassVar[int]
FOCUS_FIELD_NUMBER: _ClassVar[int]
METADATA_FIELD_NUMBER: _ClassVar[int]
message_id: str
message_time: int
task_id: str
def __init__(self, engine: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ..., build_type: _Optional[_Union[BuildType, str]] = ..., apply_diff: bool = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class BuildRequest(_message.Message):
__slots__ = ["apply_diff", "build_type", "engine", "internal_patch_id", "patch", "sanitizer", "task_dir", "task_id"]
APPLY_DIFF_FIELD_NUMBER: _ClassVar[int]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
ENGINE_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
PATCH_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
apply_diff: bool
build_type: BuildType
engine: str
internal_patch_id: str
patch: str
sanitizer: str
task_dir: str
task_id: str
def __init__(self, engine: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ..., build_type: _Optional[_Union[BuildType, str]] = ..., apply_diff: bool = ..., patch: _Optional[str] = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class Bundle(_message.Message):
__slots__ = ["bundle_id", "competition_patch_id", "competition_pov_id", "competition_sarif_id", "task_id"]
BUNDLE_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_POV_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_SARIF_ID_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
bundle_id: str
competition_patch_id: str
competition_pov_id: str
competition_sarif_id: str
task_id: str
def __init__(self, task_id: _Optional[str] = ..., competition_pov_id: _Optional[str] = ..., competition_patch_id: _Optional[str] = ..., competition_sarif_id: _Optional[str] = ..., bundle_id: _Optional[str] = ...) -> None: ...
class ConfirmedVulnerability(_message.Message):
__slots__ = ["crashes", "internal_patch_id"]
CRASHES_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
crashes: _containers.RepeatedCompositeFieldContainer[TracedCrash]
internal_patch_id: str
def __init__(self, crashes: _Optional[_Iterable[_Union[TracedCrash, _Mapping]]] = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class Crash(_message.Message):
__slots__ = ["crash_input_path", "crash_token", "harness_name", "stacktrace", "target"]
CRASH_INPUT_PATH_FIELD_NUMBER: _ClassVar[int]
CRASH_TOKEN_FIELD_NUMBER: _ClassVar[int]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
STACKTRACE_FIELD_NUMBER: _ClassVar[int]
TARGET_FIELD_NUMBER: _ClassVar[int]
crash_input_path: str
crash_token: str
harness_name: str
stacktrace: str
target: BuildOutput
def __init__(self, target: _Optional[_Union[BuildOutput, _Mapping]] = ..., harness_name: _Optional[str] = ..., crash_input_path: _Optional[str] = ..., stacktrace: _Optional[str] = ..., crash_token: _Optional[str] = ...) -> None: ...
class CrashWithId(_message.Message):
__slots__ = ["competition_pov_id", "crash", "result"]
COMPETITION_POV_ID_FIELD_NUMBER: _ClassVar[int]
CRASH_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
competition_pov_id: str
crash: TracedCrash
result: SubmissionResult
def __init__(self, crash: _Optional[_Union[TracedCrash, _Mapping]] = ..., competition_pov_id: _Optional[str] = ..., result: _Optional[_Union[SubmissionResult, str]] = ...) -> None: ...
class FunctionCoverage(_message.Message):
__slots__ = ["covered_lines", "function_name", "function_paths", "total_lines"]
COVERED_LINES_FIELD_NUMBER: _ClassVar[int]
FUNCTION_NAME_FIELD_NUMBER: _ClassVar[int]
FUNCTION_PATHS_FIELD_NUMBER: _ClassVar[int]
TOTAL_LINES_FIELD_NUMBER: _ClassVar[int]
covered_lines: int
function_name: str
function_paths: _containers.RepeatedScalarFieldContainer[str]
total_lines: int
def __init__(self, function_name: _Optional[str] = ..., function_paths: _Optional[_Iterable[str]] = ..., total_lines: _Optional[int] = ..., covered_lines: _Optional[int] = ...) -> None: ...
class IndexOutput(_message.Message):
__slots__ = ["build_type", "package_name", "sanitizer", "task_dir", "task_id"]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
build_type: BuildType
package_name: str
sanitizer: str
task_dir: str
task_id: str
def __init__(self, build_type: _Optional[_Union[BuildType, str]] = ..., package_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
class IndexRequest(_message.Message):
__slots__ = ["build_type", "package_name", "sanitizer", "task_dir", "task_id"]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
build_type: BuildType
package_name: str
sanitizer: str
task_dir: str
task_id: str
def __init__(self, build_type: _Optional[_Union[BuildType, str]] = ..., package_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
class POVReproduceRequest(_message.Message):
__slots__ = ["harness_name", "internal_patch_id", "pov_path", "sanitizer", "task_id"]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
POV_PATH_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
harness_name: str
internal_patch_id: str
pov_path: str
sanitizer: str
task_id: str
def __init__(self, task_id: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., harness_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., pov_path: _Optional[str] = ...) -> None: ...
class POVReproduceResponse(_message.Message):
__slots__ = ["did_crash", "request"]
DID_CRASH_FIELD_NUMBER: _ClassVar[int]
REQUEST_FIELD_NUMBER: _ClassVar[int]
did_crash: bool
request: POVReproduceRequest
def __init__(self, request: _Optional[_Union[POVReproduceRequest, _Mapping]] = ..., did_crash: bool = ...) -> None: ...
class Patch(_message.Message):
__slots__ = ["internal_patch_id", "patch", "task_id"]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
PATCH_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
internal_patch_id: str
patch: str
task_id: str
def __init__(self, task_id: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., patch: _Optional[str] = ...) -> None: ...
task_type: Task.TaskType
sources: _containers.RepeatedCompositeFieldContainer[SourceDetail]
deadline: int
cancelled: bool
project_name: str
focus: str
metadata: _containers.ScalarMap[str, str]
def __init__(self, message_id: _Optional[str] = ..., message_time: _Optional[int] = ..., task_id: _Optional[str] = ..., task_type: _Optional[_Union[Task.TaskType, str]] = ..., sources: _Optional[_Iterable[_Union[SourceDetail, _Mapping]]] = ..., deadline: _Optional[int] = ..., cancelled: bool = ..., project_name: _Optional[str] = ..., focus: _Optional[str] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ...
class SourceDetail(_message.Message):
__slots__ = ["sha256", "source_type", "url"]
__slots__ = ("sha256", "source_type", "url")
class SourceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
SHA256_FIELD_NUMBER: _ClassVar[int]
SOURCE_TYPE_DIFF: SourceDetail.SourceType
SOURCE_TYPE_FIELD_NUMBER: _ClassVar[int]
SOURCE_TYPE_FUZZ_TOOLING: SourceDetail.SourceType
__slots__ = ()
SOURCE_TYPE_REPO: _ClassVar[SourceDetail.SourceType]
SOURCE_TYPE_FUZZ_TOOLING: _ClassVar[SourceDetail.SourceType]
SOURCE_TYPE_DIFF: _ClassVar[SourceDetail.SourceType]
SOURCE_TYPE_REPO: SourceDetail.SourceType
SOURCE_TYPE_FUZZ_TOOLING: SourceDetail.SourceType
SOURCE_TYPE_DIFF: SourceDetail.SourceType
SHA256_FIELD_NUMBER: _ClassVar[int]
SOURCE_TYPE_FIELD_NUMBER: _ClassVar[int]
URL_FIELD_NUMBER: _ClassVar[int]
sha256: str
source_type: SourceDetail.SourceType
url: str
def __init__(self, sha256: _Optional[str] = ..., source_type: _Optional[_Union[SourceDetail.SourceType, str]] = ..., url: _Optional[str] = ...) -> None: ...
class SubmissionEntry(_message.Message):
__slots__ = ["bundles", "crashes", "patch_idx", "patch_submission_attempts", "patches", "stop"]
BUNDLES_FIELD_NUMBER: _ClassVar[int]
CRASHES_FIELD_NUMBER: _ClassVar[int]
PATCHES_FIELD_NUMBER: _ClassVar[int]
PATCH_IDX_FIELD_NUMBER: _ClassVar[int]
PATCH_SUBMISSION_ATTEMPTS_FIELD_NUMBER: _ClassVar[int]
STOP_FIELD_NUMBER: _ClassVar[int]
bundles: _containers.RepeatedCompositeFieldContainer[Bundle]
crashes: _containers.RepeatedCompositeFieldContainer[CrashWithId]
patch_idx: int
patch_submission_attempts: int
patches: _containers.RepeatedCompositeFieldContainer[SubmissionEntryPatch]
stop: bool
def __init__(self, stop: bool = ..., crashes: _Optional[_Iterable[_Union[CrashWithId, _Mapping]]] = ..., bundles: _Optional[_Iterable[_Union[Bundle, _Mapping]]] = ..., patches: _Optional[_Iterable[_Union[SubmissionEntryPatch, _Mapping]]] = ..., patch_idx: _Optional[int] = ..., patch_submission_attempts: _Optional[int] = ...) -> None: ...
class SubmissionEntryPatch(_message.Message):
__slots__ = ["build_outputs", "competition_patch_id", "internal_patch_id", "patch", "result"]
BUILD_OUTPUTS_FIELD_NUMBER: _ClassVar[int]
COMPETITION_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
PATCH_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
build_outputs: _containers.RepeatedCompositeFieldContainer[BuildOutput]
competition_patch_id: str
internal_patch_id: str
patch: str
result: SubmissionResult
def __init__(self, patch: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., competition_patch_id: _Optional[str] = ..., build_outputs: _Optional[_Iterable[_Union[BuildOutput, _Mapping]]] = ..., result: _Optional[_Union[SubmissionResult, str]] = ...) -> None: ...
class Task(_message.Message):
__slots__ = ["cancelled", "deadline", "focus", "message_id", "message_time", "metadata", "project_name", "sources", "task_id", "task_type"]
class TaskType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class MetadataEntry(_message.Message):
__slots__ = ["key", "value"]
KEY_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
key: str
value: str
def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
CANCELLED_FIELD_NUMBER: _ClassVar[int]
DEADLINE_FIELD_NUMBER: _ClassVar[int]
FOCUS_FIELD_NUMBER: _ClassVar[int]
MESSAGE_ID_FIELD_NUMBER: _ClassVar[int]
MESSAGE_TIME_FIELD_NUMBER: _ClassVar[int]
METADATA_FIELD_NUMBER: _ClassVar[int]
PROJECT_NAME_FIELD_NUMBER: _ClassVar[int]
SOURCES_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
TASK_TYPE_DELTA: Task.TaskType
TASK_TYPE_FIELD_NUMBER: _ClassVar[int]
TASK_TYPE_FULL: Task.TaskType
cancelled: bool
deadline: int
focus: str
message_id: str
message_time: int
metadata: _containers.ScalarMap[str, str]
project_name: str
sources: _containers.RepeatedCompositeFieldContainer[SourceDetail]
task_id: str
task_type: Task.TaskType
def __init__(self, message_id: _Optional[str] = ..., message_time: _Optional[int] = ..., task_id: _Optional[str] = ..., task_type: _Optional[_Union[Task.TaskType, str]] = ..., sources: _Optional[_Iterable[_Union[SourceDetail, _Mapping]]] = ..., deadline: _Optional[int] = ..., cancelled: bool = ..., project_name: _Optional[str] = ..., focus: _Optional[str] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ...
class TaskDelete(_message.Message):
__slots__ = ["all", "received_at", "task_id"]
ALL_FIELD_NUMBER: _ClassVar[int]
RECEIVED_AT_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
all: bool
received_at: float
task_id: str
def __init__(self, task_id: _Optional[str] = ..., all: bool = ..., received_at: _Optional[float] = ...) -> None: ...
class TaskDownload(_message.Message):
__slots__ = ["task"]
__slots__ = ("task",)
TASK_FIELD_NUMBER: _ClassVar[int]
task: Task
def __init__(self, task: _Optional[_Union[Task, _Mapping]] = ...) -> None: ...
class TaskReady(_message.Message):
__slots__ = ["task"]
__slots__ = ("task",)
TASK_FIELD_NUMBER: _ClassVar[int]
task: Task
def __init__(self, task: _Optional[_Union[Task, _Mapping]] = ...) -> None: ...
class TaskDelete(_message.Message):
__slots__ = ("task_id", "all", "received_at")
TASK_ID_FIELD_NUMBER: _ClassVar[int]
ALL_FIELD_NUMBER: _ClassVar[int]
RECEIVED_AT_FIELD_NUMBER: _ClassVar[int]
task_id: str
all: bool
received_at: float
def __init__(self, task_id: _Optional[str] = ..., all: bool = ..., received_at: _Optional[float] = ...) -> None: ...
class BuildRequest(_message.Message):
__slots__ = ("engine", "sanitizer", "task_dir", "task_id", "build_type", "apply_diff", "patch", "internal_patch_id")
ENGINE_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
APPLY_DIFF_FIELD_NUMBER: _ClassVar[int]
PATCH_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
engine: str
sanitizer: str
task_dir: str
task_id: str
build_type: BuildType
apply_diff: bool
patch: str
internal_patch_id: str
def __init__(self, engine: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ..., build_type: _Optional[_Union[BuildType, str]] = ..., apply_diff: bool = ..., patch: _Optional[str] = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class BuildOutput(_message.Message):
__slots__ = ("engine", "sanitizer", "task_dir", "task_id", "build_type", "apply_diff", "internal_patch_id")
ENGINE_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
APPLY_DIFF_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
engine: str
sanitizer: str
task_dir: str
task_id: str
build_type: BuildType
apply_diff: bool
internal_patch_id: str
def __init__(self, engine: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ..., build_type: _Optional[_Union[BuildType, str]] = ..., apply_diff: bool = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class WeightedHarness(_message.Message):
__slots__ = ("weight", "package_name", "harness_name", "task_id")
WEIGHT_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
weight: float
package_name: str
harness_name: str
task_id: str
def __init__(self, weight: _Optional[float] = ..., package_name: _Optional[str] = ..., harness_name: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
class Crash(_message.Message):
__slots__ = ("target", "harness_name", "crash_input_path", "stacktrace", "crash_token")
TARGET_FIELD_NUMBER: _ClassVar[int]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
CRASH_INPUT_PATH_FIELD_NUMBER: _ClassVar[int]
STACKTRACE_FIELD_NUMBER: _ClassVar[int]
CRASH_TOKEN_FIELD_NUMBER: _ClassVar[int]
target: BuildOutput
harness_name: str
crash_input_path: str
stacktrace: str
crash_token: str
def __init__(self, target: _Optional[_Union[BuildOutput, _Mapping]] = ..., harness_name: _Optional[str] = ..., crash_input_path: _Optional[str] = ..., stacktrace: _Optional[str] = ..., crash_token: _Optional[str] = ...) -> None: ...
class TracedCrash(_message.Message):
__slots__ = ["crash", "tracer_stacktrace"]
__slots__ = ("crash", "tracer_stacktrace")
CRASH_FIELD_NUMBER: _ClassVar[int]
TRACER_STACKTRACE_FIELD_NUMBER: _ClassVar[int]
crash: Crash
tracer_stacktrace: str
def __init__(self, crash: _Optional[_Union[Crash, _Mapping]] = ..., tracer_stacktrace: _Optional[str] = ...) -> None: ...
class WeightedHarness(_message.Message):
__slots__ = ["harness_name", "package_name", "task_id", "weight"]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
class ConfirmedVulnerability(_message.Message):
__slots__ = ("crashes", "internal_patch_id")
CRASHES_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
crashes: _containers.RepeatedCompositeFieldContainer[TracedCrash]
internal_patch_id: str
def __init__(self, crashes: _Optional[_Iterable[_Union[TracedCrash, _Mapping]]] = ..., internal_patch_id: _Optional[str] = ...) -> None: ...
class Patch(_message.Message):
__slots__ = ("task_id", "internal_patch_id", "patch")
TASK_ID_FIELD_NUMBER: _ClassVar[int]
WEIGHT_FIELD_NUMBER: _ClassVar[int]
harness_name: str
package_name: str
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
PATCH_FIELD_NUMBER: _ClassVar[int]
task_id: str
weight: float
def __init__(self, weight: _Optional[float] = ..., package_name: _Optional[str] = ..., harness_name: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
internal_patch_id: str
patch: str
def __init__(self, task_id: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., patch: _Optional[str] = ...) -> None: ...
class BuildType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class IndexRequest(_message.Message):
__slots__ = ("build_type", "package_name", "sanitizer", "task_dir", "task_id")
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
build_type: BuildType
package_name: str
sanitizer: str
task_dir: str
task_id: str
def __init__(self, build_type: _Optional[_Union[BuildType, str]] = ..., package_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
class SubmissionResult(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class IndexOutput(_message.Message):
__slots__ = ("build_type", "package_name", "sanitizer", "task_dir", "task_id")
BUILD_TYPE_FIELD_NUMBER: _ClassVar[int]
PACKAGE_NAME_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
TASK_DIR_FIELD_NUMBER: _ClassVar[int]
TASK_ID_FIELD_NUMBER: _ClassVar[int]
build_type: BuildType
package_name: str
sanitizer: str
task_dir: str
task_id: str
def __init__(self, build_type: _Optional[_Union[BuildType, str]] = ..., package_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., task_dir: _Optional[str] = ..., task_id: _Optional[str] = ...) -> None: ...
class FunctionCoverage(_message.Message):
__slots__ = ("function_name", "function_paths", "total_lines", "covered_lines")
FUNCTION_NAME_FIELD_NUMBER: _ClassVar[int]
FUNCTION_PATHS_FIELD_NUMBER: _ClassVar[int]
TOTAL_LINES_FIELD_NUMBER: _ClassVar[int]
COVERED_LINES_FIELD_NUMBER: _ClassVar[int]
function_name: str
function_paths: _containers.RepeatedScalarFieldContainer[str]
total_lines: int
covered_lines: int
def __init__(self, function_name: _Optional[str] = ..., function_paths: _Optional[_Iterable[str]] = ..., total_lines: _Optional[int] = ..., covered_lines: _Optional[int] = ...) -> None: ...
class SubmissionEntryPatch(_message.Message):
__slots__ = ("patch", "internal_patch_id", "competition_patch_id", "build_outputs", "result")
PATCH_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
BUILD_OUTPUTS_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
patch: str
internal_patch_id: str
competition_patch_id: str
build_outputs: _containers.RepeatedCompositeFieldContainer[BuildOutput]
result: SubmissionResult
def __init__(self, patch: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., competition_patch_id: _Optional[str] = ..., build_outputs: _Optional[_Iterable[_Union[BuildOutput, _Mapping]]] = ..., result: _Optional[_Union[SubmissionResult, str]] = ...) -> None: ...
class Bundle(_message.Message):
__slots__ = ("task_id", "competition_pov_id", "competition_patch_id", "competition_sarif_id", "bundle_id")
TASK_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_POV_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
COMPETITION_SARIF_ID_FIELD_NUMBER: _ClassVar[int]
BUNDLE_ID_FIELD_NUMBER: _ClassVar[int]
task_id: str
competition_pov_id: str
competition_patch_id: str
competition_sarif_id: str
bundle_id: str
def __init__(self, task_id: _Optional[str] = ..., competition_pov_id: _Optional[str] = ..., competition_patch_id: _Optional[str] = ..., competition_sarif_id: _Optional[str] = ..., bundle_id: _Optional[str] = ...) -> None: ...
class CrashWithId(_message.Message):
__slots__ = ("crash", "competition_pov_id", "result")
CRASH_FIELD_NUMBER: _ClassVar[int]
COMPETITION_POV_ID_FIELD_NUMBER: _ClassVar[int]
RESULT_FIELD_NUMBER: _ClassVar[int]
crash: TracedCrash
competition_pov_id: str
result: SubmissionResult
def __init__(self, crash: _Optional[_Union[TracedCrash, _Mapping]] = ..., competition_pov_id: _Optional[str] = ..., result: _Optional[_Union[SubmissionResult, str]] = ...) -> None: ...
class SubmissionEntry(_message.Message):
__slots__ = ("stop", "crashes", "bundles", "patches", "patch_idx", "patch_submission_attempts")
STOP_FIELD_NUMBER: _ClassVar[int]
CRASHES_FIELD_NUMBER: _ClassVar[int]
BUNDLES_FIELD_NUMBER: _ClassVar[int]
PATCHES_FIELD_NUMBER: _ClassVar[int]
PATCH_IDX_FIELD_NUMBER: _ClassVar[int]
PATCH_SUBMISSION_ATTEMPTS_FIELD_NUMBER: _ClassVar[int]
stop: bool
crashes: _containers.RepeatedCompositeFieldContainer[CrashWithId]
bundles: _containers.RepeatedCompositeFieldContainer[Bundle]
patches: _containers.RepeatedCompositeFieldContainer[SubmissionEntryPatch]
patch_idx: int
patch_submission_attempts: int
def __init__(self, stop: bool = ..., crashes: _Optional[_Iterable[_Union[CrashWithId, _Mapping]]] = ..., bundles: _Optional[_Iterable[_Union[Bundle, _Mapping]]] = ..., patches: _Optional[_Iterable[_Union[SubmissionEntryPatch, _Mapping]]] = ..., patch_idx: _Optional[int] = ..., patch_submission_attempts: _Optional[int] = ...) -> None: ...
class POVReproduceRequest(_message.Message):
__slots__ = ("task_id", "internal_patch_id", "harness_name", "sanitizer", "pov_path")
TASK_ID_FIELD_NUMBER: _ClassVar[int]
INTERNAL_PATCH_ID_FIELD_NUMBER: _ClassVar[int]
HARNESS_NAME_FIELD_NUMBER: _ClassVar[int]
SANITIZER_FIELD_NUMBER: _ClassVar[int]
POV_PATH_FIELD_NUMBER: _ClassVar[int]
task_id: str
internal_patch_id: str
harness_name: str
sanitizer: str
pov_path: str
def __init__(self, task_id: _Optional[str] = ..., internal_patch_id: _Optional[str] = ..., harness_name: _Optional[str] = ..., sanitizer: _Optional[str] = ..., pov_path: _Optional[str] = ...) -> None: ...
class POVReproduceResponse(_message.Message):
__slots__ = ("request", "did_crash")
REQUEST_FIELD_NUMBER: _ClassVar[int]
DID_CRASH_FIELD_NUMBER: _ClassVar[int]
request: POVReproduceRequest
did_crash: bool
def __init__(self, request: _Optional[_Union[POVReproduceRequest, _Mapping]] = ..., did_crash: bool = ...) -> None: ...
@@ -1,11 +1,12 @@
from abc import ABC, abstractmethod
from redis import Redis
from buttercup.common.utils import serve_loop
from buttercup.common.datastructures.msg_pb2 import WeightedHarness, BuildOutput, BuildType
from buttercup.common.maps import HarnessWeights, BuildMap
import random
import logging
import random
from abc import ABC, abstractmethod
from redis import Redis
from buttercup.common.datastructures.msg_pb2 import BuildOutput, BuildType, WeightedHarness
from buttercup.common.maps import BuildMap, HarnessWeights
from buttercup.common.utils import serve_loop
logger = logging.getLogger(__name__)
+45 -33
View File
@@ -1,15 +1,16 @@
import functools
import logging
import os
from enum import Enum
from typing import Any
import requests
import functools
from langchain_core.language_models import BaseChatModel
from langchain_openai.chat_models import ChatOpenAI
from langfuse.callback import CallbackHandler
from langchain.callbacks.base import BaseCallbackHandler
from langchain_core.runnables import ConfigurableField
import logging
import requests
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.language_models import BaseChatModel
from langchain_core.runnables import ConfigurableField, Runnable
from langchain_openai.chat_models import ChatOpenAI
from langfuse.langchain import CallbackHandler
from pydantic import SecretStr
logger = logging.getLogger(__name__)
@@ -29,9 +30,12 @@ class ButtercupLLM(Enum):
OPENAI_GPT_4_1_NANO = "openai-gpt-4.1-nano"
OPENAI_GPT_4_1_MINI = "openai-gpt-4.1-mini"
OPENAI_GPT_4_1 = "openai-gpt-4.1"
CLAUDE_3_5_SONNET = "claude-3.5-sonnet"
CLAUDE_3_7_SONNET = "claude-3.7-sonnet"
CLAUDE_4_SONNET = "claude-4-sonnet"
CLAUDE_4_5_SONNET = "claude-4.5-sonnet"
GEMINI_PRO = "gemini-pro"
GEMINI_2_5_FLASH = "gemini-2.5-flash"
GEMINI_2_5_FLASH_EXP = "gemini-2.5-flash-exp"
@functools.cache
@@ -43,7 +47,7 @@ def is_langfuse_available() -> bool:
return False
try:
response = requests.post(f"{langfuse_host}/api/public/ingestion", timeout=2)
return bool(response.status_code == 401) # expect that we aren't authenticated
return response.status_code == 401 # expect that we aren't authenticated
except requests.RequestException:
return False
@@ -62,9 +66,11 @@ def langfuse_auth_check() -> bool:
try:
response = requests.post(
f"{langfuse_host}/api/public/ingestion", timeout=2, auth=(langfuse_public_key, langfuse_secret_key)
f"{langfuse_host}/api/public/ingestion",
timeout=2,
auth=(langfuse_public_key, langfuse_secret_key),
)
return bool(response.status_code == 400) # expect that we authenticate, but the request is invalid
return response.status_code == 400 # expect that we authenticate, but the request is invalid
except requests.RequestException:
return False
@@ -74,11 +80,7 @@ def get_langfuse_callbacks() -> list[BaseCallbackHandler]:
"""Get Langchain callbacks for monitoring LLM calls with LangFuse, if available."""
if is_langfuse_available():
try:
langfuse_handler = CallbackHandler(
public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
host=os.getenv("LANGFUSE_HOST"),
)
langfuse_handler = CallbackHandler()
if langfuse_auth_check():
logger.info("Tracing with LangFuse enabled")
return [langfuse_handler]
@@ -92,31 +94,41 @@ def get_langfuse_callbacks() -> list[BaseCallbackHandler]:
return []
def create_default_llm(**kwargs: Any) -> BaseChatModel:
def create_default_llm(**kwargs: Any) -> Runnable:
"""Create an LLM object with the default configuration."""
fallback_models = kwargs.pop("fallback_models", [])
fallback_models = [create_default_llm(**{**kwargs, "model_name": m.value}) for m in fallback_models]
return create_llm(
model_name=kwargs.pop("model_name", ButtercupLLM.OPENAI_GPT_4_1.value),
temperature=kwargs.pop("temperature", 0.1),
timeout=420.0,
max_retries=3,
**kwargs,
)
).with_fallbacks(fallback_models)
def create_default_llm_with_temperature(**kwargs: Any) -> BaseChatModel:
def create_default_llm_with_temperature(**kwargs: Any) -> Runnable:
"""Create an LLM object with the default configuration and temperature."""
return create_llm( # type: ignore
model_name=kwargs.pop("model_name", ButtercupLLM.OPENAI_GPT_4_1.value),
temperature=kwargs.pop("temperature", 0.1),
timeout=420.0,
max_retries=3,
**kwargs,
).configurable_fields(
temperature=ConfigurableField(
id="llm_temperature",
name="LLM temperature",
description="The temperature for the LLM model",
),
fallback_models = kwargs.pop("fallback_models", [])
fallback_models = [
create_default_llm_with_temperature(**{**kwargs, "model_name": m.value}) for m in fallback_models
]
return (
create_llm(
model_name=kwargs.pop("model_name", ButtercupLLM.OPENAI_GPT_4_1.value),
temperature=kwargs.pop("temperature", 0.1),
timeout=420.0,
max_retries=3,
**kwargs,
)
.configurable_fields(
temperature=ConfigurableField(
id="llm_temperature",
name="LLM temperature",
description="The temperature for the LLM model",
),
)
.with_fallbacks(fallback_models)
)
@@ -124,6 +136,6 @@ def create_llm(**kwargs: Any) -> BaseChatModel:
"""Create an LLM object with the given configuration."""
return ChatOpenAI(
openai_api_base=os.environ["BUTTERCUP_LITELLM_HOSTNAME"],
openai_api_key=os.environ["BUTTERCUP_LITELLM_KEY"],
openai_api_key=SecretStr(os.environ["BUTTERCUP_LITELLM_KEY"]),
**kwargs,
)
+44 -28
View File
@@ -1,18 +1,27 @@
from opentelemetry._logs import set_logger_provider
import atexit
import os
if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "grpc":
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
else:
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter # type: ignore
try:
from opentelemetry._logs import set_logger_provider
if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "grpc":
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
else:
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
_opentelemetry_enabled = True
except ImportError:
_opentelemetry_enabled = False
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.resources import Resource
from buttercup.common.telemetry import crs_instance_id, service_instance_id
import logging
import tempfile
from buttercup.common.telemetry import crs_instance_id, service_instance_id
_is_initialized = False
PACKAGE_LOGGER_NAME = "buttercup"
@@ -30,7 +39,10 @@ class MaxLengthFormatter(logging.Formatter):
def setup_package_logger(
application_name: str, logger_name: str, log_level: str = "info", max_line_length: int | None = None
application_name: str,
logger_name: str,
log_level: str = "info",
max_line_length: int | None = None,
) -> logging.Logger:
global _is_initialized
@@ -42,26 +54,27 @@ def setup_package_logger(
root.removeHandler(handler)
# Create resource with service and environment information
resource = Resource.create(
attributes={
"service.name": application_name,
"service.instance.id": service_instance_id,
"crs.instance.id": crs_instance_id,
}
)
# Initialize the LoggerProvider with the created resource.
logger_provider = LoggerProvider(resource=resource)
# Configure the span exporter and processor based on whether the endpoint is effectively set.
otlp_handler = None
if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
set_logger_provider(logger_provider)
exporter = OTLPLogExporter()
if _opentelemetry_enabled:
resource = Resource.create(
attributes={
"service.name": application_name,
"service.instance.id": service_instance_id,
"crs.instance.id": crs_instance_id,
}
)
# add the batch processors to the trace provider
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
otlp_handler = LoggingHandler(level=logging.DEBUG, logger_provider=logger_provider)
# Initialize the LoggerProvider with the created resource.
logger_provider = LoggerProvider(resource=resource)
# Configure the span exporter and processor based on whether the endpoint is effectively set.
if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
set_logger_provider(logger_provider)
exporter = OTLPLogExporter()
# add the batch processors to the trace provider
logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
otlp_handler = LoggingHandler(level=logging.DEBUG, logger_provider=logger_provider)
persistent_log_dir = os.getenv("PERSISTENT_LOG_DIR", None)
@@ -86,6 +99,9 @@ def setup_package_logger(
handlers=handlers,
)
# Ensure all logging handlers are properly closed at program exit
atexit.register(logging.shutdown)
_package_logger = logging.getLogger(PACKAGE_LOGGER_NAME)
_package_logger.setLevel(log_level.upper())
+18 -8
View File
@@ -1,16 +1,21 @@
from typing import Generic, TypeVar, Type, Iterator
from buttercup.common.datastructures.msg_pb2 import WeightedHarness, BuildOutput, FunctionCoverage, BuildType
from redis import Redis
from bson.json_util import dumps, CANONICAL_JSON_OPTIONS
from collections.abc import Iterator
from typing import Generic, TypeVar
from bson.json_util import CANONICAL_JSON_OPTIONS, dumps
from google.protobuf.message import Message
from redis import Redis
from buttercup.common.datastructures.msg_pb2 import BuildOutput, BuildType, FunctionCoverage, WeightedHarness
from buttercup.common.sets import RedisSet
# ruff: noqa: UP046
MsgType = TypeVar("MsgType", bound=Message)
MSG_FIELD_NAME = b"msg"
class RedisMap(Generic[MsgType]):
def __init__(self, redis: Redis, hash_name: str, msg_builder: Type[MsgType]):
def __init__(self, redis: Redis, hash_name: str, msg_builder: type[MsgType]):
self.redis = redis
self.msg_builder = msg_builder
self.hash_name = hash_name
@@ -22,7 +27,7 @@ class RedisMap(Generic[MsgType]):
msg = self.msg_builder()
msg.ParseFromString(it)
return msg # type: ignore[no-any-return]
return msg
def set(self, key: str, value: MsgType) -> None:
self.redis.hset(self.hash_name, key, value.SerializeToString())
@@ -52,7 +57,8 @@ class BuildMap:
def _build_output_key(self, task_id: str, build_type: BuildType, san: str, internal_patch_id: str) -> str:
return dumps(
[task_id, BUILD_SAN_MAP_NAME, build_type, san, internal_patch_id], json_options=CANONICAL_JSON_OPTIONS
[task_id, BUILD_SAN_MAP_NAME, build_type, san, internal_patch_id],
json_options=CANONICAL_JSON_OPTIONS,
)
def add_build(self, build: BuildOutput) -> None:
@@ -78,7 +84,11 @@ class BuildMap:
return builds
def get_build_from_san(
self, task_id: str, build_type: BuildType, san: str, internal_patch_id: str = ""
self,
task_id: str,
build_type: BuildType,
san: str,
internal_patch_id: str = "",
) -> BuildOutput | None:
if internal_patch_id != "":
assert build_type == BuildType.PATCH, "internal_patch_id is only valid for PATCH builds"
+67 -8
View File
@@ -1,18 +1,24 @@
# Store the node local path for subsequent use
from contextlib import contextmanager
import errno
import logging
import os
from pathlib import Path
import shutil
import tarfile
from tempfile import NamedTemporaryFile
import tempfile
from typing import Any, Iterator, TypeAlias
from collections.abc import Iterator
from contextlib import AbstractContextManager, contextmanager
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any, TypeAlias
logger = logging.getLogger(__name__)
node_local_path = os.getenv("NODE_DATA_DIR")
# Optional tmpfs path for corpus storage
# When set, the fuzzing corpus will be stored on this tmpfs mount for improved performance
corpus_tmpfs_path = os.getenv("CORPUS_TMPFS_PATH")
NodeLocalPath: TypeAlias = Path
RemotePath: TypeAlias = Path
@@ -23,6 +29,18 @@ def _get_root_path() -> NodeLocalPath:
return NodeLocalPath(node_local_path)
def get_corpus_tmpfs_path() -> Path | None:
"""Return the tmpfs path for corpus storage if configured, otherwise None."""
if corpus_tmpfs_path:
return Path(corpus_tmpfs_path)
return None
def is_corpus_tmpfs_enabled() -> bool:
"""Check if tmpfs corpus storage is enabled."""
return corpus_tmpfs_path is not None
class TmpDir:
def __init__(self, path: Path):
self.path = path
@@ -47,7 +65,10 @@ def temp_dir(root_path: Path) -> Iterator[TmpDir]:
def rename_atomically(src: Path, dst: Path) -> Path | None:
"""Rename a file atomically"""
"""Rename a file atomically.
Falls back to copy+delete for cross-filesystem operations.
"""
src = Path(src)
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
@@ -56,13 +77,50 @@ def rename_atomically(src: Path, dst: Path) -> Path | None:
except OSError as e:
# If the path already exists, it means another pod already downloaded it
# we can just ignore this error and return None to signify that the path already exists
if e.errno == 39:
if e.errno == errno.ENOTEMPTY: # 39 = Directory not empty
logger.debug(f"Local path {dst} already exists for {src}")
return None
# Handle cross-filesystem rename (errno 18 = EXDEV = Invalid cross-device link)
if e.errno == errno.EXDEV:
logger.debug(f"Cross-filesystem rename from {src} to {dst}, using copy+delete")
return _copy_and_delete(src, dst)
raise e
return dst
def _copy_and_delete(src: Path, dst: Path) -> Path | None:
"""Copy a file/directory and delete the source. Used for cross-filesystem operations.
Note: Unlike os.rename, this intentionally returns None if dst already exists
rather than overwriting. This supports the "first pod wins" pattern used by
callers in distributed scenarios where concurrent pods may race to create the same file.
"""
try:
if src.is_dir():
# For directories, use copytree
if dst.exists():
logger.debug(f"Destination {dst} already exists, skipping copy")
shutil.rmtree(src, ignore_errors=True)
return None
shutil.copytree(src, dst)
shutil.rmtree(src, ignore_errors=True)
else:
# For files, use copy2 to preserve metadata
if dst.exists():
logger.debug(f"Destination {dst} already exists, skipping copy")
try:
os.unlink(src)
except OSError:
pass
return None
shutil.copy2(src, dst)
os.unlink(src)
return dst
except Exception as e:
logger.error(f"Failed to copy {src} to {dst}: {e}")
raise
def remote_path(local_path: NodeLocalPath) -> RemotePath:
"""Convert the node local path to a remote path"""
local_path = Path(local_path)
@@ -89,7 +147,7 @@ def scratch_path() -> NodeLocalPath:
return scratch_dir
def scratch_dir() -> Any:
def scratch_dir() -> AbstractContextManager[TmpDir]:
"""Return a temporary directory in the scratch directory"""
return temp_dir(scratch_path())
@@ -187,6 +245,7 @@ def dir_to_remote_archive(local_path: NodeLocalPath) -> RemotePath:
def lopen(local_path: NodeLocalPath, mode: str) -> Any:
"""Open a file in the node local storage
If it doesn't exist, it will be downloaded from the remote storage"""
If it doesn't exist, it will be downloaded from the remote storage
"""
make_locally_available(local_path)
return open(local_path, mode)
+9 -4
View File
@@ -1,11 +1,14 @@
from dataclasses import dataclass, field
import yaml
from buttercup.common.challenge_task import ChallengeTask
from enum import Enum
import yaml
from buttercup.common.challenge_task import ChallengeTask
class Language(str, Enum):
C = "c"
CPP = "cpp"
JAVA = "java"
@@ -43,9 +46,11 @@ class ProjectYaml:
@property
def unified_language(self) -> Language:
"""Language field but with a more consistent naming convention."""
if self.language.lower() in ["c", "c++", "cpp"]:
if self.language.lower() in ["c"]:
return Language.C
elif self.language.lower() in ["java", "jvm"]:
if self.language.lower() in ["c++", "cpp"]:
return Language.CPP
if self.language.lower() in ["java", "jvm"]:
return Language.JAVA
raise ValueError(f"Unsupported language: {self.language}")
+90 -61
View File
@@ -1,31 +1,34 @@
from __future__ import annotations
import logging
import os
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from redis import Redis, RedisError
from google.protobuf.message import Message
from enum import Enum
from functools import wraps
from typing import Any, Generic, Literal, TypeVar, cast, overload
from google.protobuf.message import Message
from redis import Redis, RedisError
from buttercup.common.datastructures.msg_pb2 import (
BuildRequest,
BuildOutput,
Crash,
TaskDownload,
TaskReady,
TaskDelete,
Patch,
BuildRequest,
ConfirmedVulnerability,
IndexRequest,
Crash,
IndexOutput,
TracedCrash,
IndexRequest,
Patch,
POVReproduceRequest,
POVReproduceResponse,
TaskDelete,
TaskDownload,
TaskReady,
TracedCrash,
)
import logging
from typing import Type, Generic, TypeVar, Literal, overload, Callable, cast
import uuid
import os
from enum import Enum
from typing import Any
# ruff: noqa: UP046
TIMES_DELIVERED_FIELD = "times_delivered"
@@ -86,9 +89,7 @@ MsgType = TypeVar("MsgType", bound=Message)
@dataclass
class RQItem(Generic[MsgType]):
"""
A single item in a reliable queue.
"""
"""A single item in a reliable queue."""
item_id: str
deserialized: MsgType
@@ -96,13 +97,11 @@ class RQItem(Generic[MsgType]):
@dataclass
class ReliableQueue(Generic[MsgType]):
"""
A queue that is reliable and can be used to process tasks in a distributed environment.
"""
"""A queue that is reliable and can be used to process tasks in a distributed environment."""
redis: Redis
queue_name: str
msg_builder: Type[MsgType]
msg_builder: type[MsgType]
group_name: str | None = None
task_timeout_ms: int = 180000
reader_name: str | None = None
@@ -113,7 +112,7 @@ class ReliableQueue(Generic[MsgType]):
def __post_init__(self) -> None:
if self.reader_name is None:
self.reader_name = f"rqueue_{str(uuid.uuid4())}"
self.reader_name = f"rqueue_{uuid.uuid4()!s}"
if self.group_name is not None:
# Create consumer group if it doesn't exist
@@ -125,9 +124,10 @@ class ReliableQueue(Generic[MsgType]):
pass
else:
logger.exception(
"Failed to create consumer group %s for queue %s", self.group_name, self.queue_name
"Failed to create consumer group %s for queue %s",
self.group_name,
self.queue_name,
)
pass
def size(self) -> int:
return self.redis.xlen(self.queue_name)
@@ -145,7 +145,7 @@ class ReliableQueue(Generic[MsgType]):
return func(self, *args, **kwargs)
return cast(F, wrapper)
return cast("F", wrapper)
@_ensure_group_name
def pop(self) -> RQItem[MsgType] | None:
@@ -205,7 +205,7 @@ class ReliableQueue(Generic[MsgType]):
if pending is None or len(pending) == 0:
return 0
return cast(int, pending[0][TIMES_DELIVERED_FIELD])
return cast("int", pending[0][TIMES_DELIVERED_FIELD])
@_ensure_group_name
def claim_item(self, item_id: str, min_idle_time: int = 0) -> None:
@@ -215,7 +215,7 @@ class ReliableQueue(Generic[MsgType]):
@dataclass
class QueueConfig:
queue_name: QueueNames
msg_builder: Type
msg_builder: type
task_timeout_ms: int
group_names: list[GroupNames] = field(default_factory=list)
@@ -305,81 +305,126 @@ class QueueFactory:
POV_REPRODUCER_RESPONSES_TASK_TIMEOUT_MS,
[GroupNames.ORCHESTRATOR],
),
}
},
)
@overload
def create(
self, queue_name: Literal[QueueNames.BUILD], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.BUILD],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[BuildRequest]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.BUILD_OUTPUT], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.BUILD_OUTPUT],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[BuildOutput]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.DOWNLOAD_TASKS], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.DOWNLOAD_TASKS],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[TaskDownload]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.READY_TASKS], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.READY_TASKS],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[TaskReady]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.CRASH], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.CRASH],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[Crash]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.TRACED_VULNERABILITIES], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.TRACED_VULNERABILITIES],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[TracedCrash]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.CONFIRMED_VULNERABILITIES], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.CONFIRMED_VULNERABILITIES],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[ConfirmedVulnerability]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.DELETE_TASK], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.DELETE_TASK],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[TaskDelete]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.PATCHES], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.PATCHES],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[Patch]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.INDEX], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.INDEX],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[IndexRequest]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.INDEX_OUTPUT], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.INDEX_OUTPUT],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[IndexOutput]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.POV_REPRODUCER_REQUESTS], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.POV_REPRODUCER_REQUESTS],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[POVReproduceRequest]: ...
@overload
def create(
self, queue_name: Literal[QueueNames.POV_REPRODUCER_RESPONSES], group_name: GroupNames, **kwargs: Any
self,
queue_name: Literal[QueueNames.POV_REPRODUCER_RESPONSES],
group_name: GroupNames,
**kwargs: Any,
) -> ReliableQueue[POVReproduceResponse]: ...
@overload
def create(
self, queue_name: QueueNames, group_name: GroupNames | None = None, **kwargs: Any
self,
queue_name: QueueNames,
group_name: GroupNames | None = None,
**kwargs: Any,
) -> ReliableQueue[MsgType]: ...
def create(
self, queue_name: QueueNames, group_name: GroupNames | None = None, **kwargs: Any
self,
queue_name: QueueNames,
group_name: GroupNames | None = None,
**kwargs: Any,
) -> ReliableQueue[MsgType]:
if queue_name not in self._config:
raise ValueError(f"Invalid queue name: {queue_name}")
@@ -399,19 +444,3 @@ class QueueFactory:
queue_args.update(kwargs)
return ReliableQueue(**queue_args) # type: ignore[arg-type]
@dataclass
class FuzzConfiguration:
corpus_dir: str
target_path: str
engine: str
sanitizer: str
@dataclass
class BuildConfiguration:
project_id: str
engine: str
sanitizer: str
source_path: str | None
@@ -1,17 +1,21 @@
from buttercup.common.datastructures.msg_pb2 import BuildOutput
from buttercup.common.challenge_task import ReproduceResult, ChallengeTask
from pathlib import Path
from typing import Generator
from contextlib import contextmanager
import contextlib
import logging
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from buttercup.common.challenge_task import ChallengeTask, ReproduceResult
from buttercup.common.datastructures.msg_pb2 import BuildOutput
logger = logging.getLogger(__name__)
class ReproduceMultiple:
def __init__(
self, wdir: Path, build_outputs: list[BuildOutput], build_cache: list[ChallengeTask] | None = None
self,
wdir: Path,
build_outputs: list[BuildOutput],
build_cache: list[ChallengeTask] | None = None,
) -> None:
self.build_outputs = build_outputs
self.wdir = wdir
@@ -32,11 +36,13 @@ class ReproduceMultiple:
pass
def attempt_reproduce(
self, pov: Path, harness_name: str
self,
pov: Path,
harness_name: str,
) -> Generator[tuple[BuildOutput, ReproduceResult], None, None]:
if self.builds_cache is None:
raise RuntimeError("Build cache is not populated")
for build, task in zip(self.build_outputs, self.builds_cache):
for build, task in zip(self.build_outputs, self.builds_cache, strict=False):
yield (build, task.reproduce_pov(harness_name, pov))
def get_first_crash(self, pov: Path, harness_name: str) -> tuple[BuildOutput, ReproduceResult] | None:
+22 -20
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import Dict, Any, List
from typing import Any
from pydantic import BaseModel, Field
from redis import Redis
@@ -8,11 +9,12 @@ from redis import Redis
class SARIFBroadcastDetail(BaseModel):
"""Model for SARIF broadcast details, matches the model in types.py"""
metadata: Dict[str, Any] = Field(
metadata: dict[str, Any] = Field(
...,
description="String to string map containing data that should be attached to outputs like log messages and OpenTelemetry trace attributes for traceability",
description="String to string map containing data that should be attached to outputs "
"like log messages and OpenTelemetry trace attributes for traceability",
)
sarif: Dict[str, Any] = Field(..., description="SARIF Report compliant with provided schema")
sarif: dict[str, Any] = Field(..., description="SARIF Report compliant with provided schema")
sarif_id: str
task_id: str
@@ -21,47 +23,47 @@ class SARIFStore:
"""Store and retrieve SARIF objects in Redis"""
def __init__(self, redis: Redis):
"""
Initialize the SARIF store with a Redis connection.
"""Initialize the SARIF store with a Redis connection.
Args:
redis: Redis connection
"""
self.redis = redis
self.key_prefix = "sarif:"
def _get_key(self, task_id: str) -> str:
"""
Get the Redis key for a task_id.
"""Get the Redis key for a task_id.
Args:
task_id: Task ID
Returns:
Redis key
"""
return f"{self.key_prefix}{task_id.lower()}"
def _decode_key(self, key: str | bytes) -> str:
"""
Decode a Redis key if it's bytes, otherwise return as is.
"""Decode a Redis key if it's bytes, otherwise return as is.
Args:
key: Redis key, either bytes or string
Returns:
Decoded key as string
"""
if isinstance(key, bytes):
return key.decode("utf-8")
return key
def store(self, sarif_detail: SARIFBroadcastDetail) -> None:
"""
Store a SARIF broadcast detail in Redis.
"""Store a SARIF broadcast detail in Redis.
Args:
sarif_detail: The SARIF broadcast detail to store
"""
task_id = sarif_detail.task_id
key = self._get_key(task_id)
@@ -73,12 +75,12 @@ class SARIFStore:
# Add to the list for this task
self.redis.rpush(key, sarif_json)
def get_all(self) -> List[SARIFBroadcastDetail]:
"""
Retrieve all SARIF objects from Redis.
def get_all(self) -> list[SARIFBroadcastDetail]:
"""Retrieve all SARIF objects from Redis.
Returns:
List of SARIF broadcast details
"""
# Get all SARIF keys in Redis
all_keys = self.redis.keys(f"{self.key_prefix}*")
@@ -97,15 +99,15 @@ class SARIFStore:
return result
def get_by_task_id(self, task_id: str) -> List[SARIFBroadcastDetail]:
"""
Retrieve all SARIF objects for a specific task.
def get_by_task_id(self, task_id: str) -> list[SARIFBroadcastDetail]:
"""Retrieve all SARIF objects for a specific task.
Args:
task_id: Task ID
Returns:
List of SARIF broadcast details for this task
"""
key = self._get_key(task_id)
sarif_list = self.redis.lrange(key, 0, -1)
@@ -118,14 +120,14 @@ class SARIFStore:
return result
def delete_by_task_id(self, task_id: str) -> int:
"""
Remove all SARIF objects for a specific task.
"""Remove all SARIF objects for a specific task.
Args:
task_id: Task ID
Returns:
Number of removed keys (0 or 1)
"""
key = self._get_key(task_id)
return self.redis.delete(key)
+8 -9
View File
@@ -1,21 +1,20 @@
"""
Utility script for SARIF storage and retrieval operations.
"""
"""Utility script for SARIF storage and retrieval operations."""
import argparse
import json
from redis import Redis
from buttercup.common.sarif_store import SARIFStore
def list_all_sarifs(redis_url: str, verbose: bool = False) -> None:
"""
List all SARIF objects in the database.
"""List all SARIF objects in the database.
Args:
redis_url: Redis URL
verbose: Whether to print the full SARIF object
"""
redis_client = Redis.from_url(redis_url)
sarif_store = SARIFStore(redis_client)
@@ -32,13 +31,13 @@ def list_all_sarifs(redis_url: str, verbose: bool = False) -> None:
def list_task_sarifs(redis_url: str, task_id: str, verbose: bool = False) -> None:
"""
List all SARIF objects for a specific task.
"""List all SARIF objects for a specific task.
Args:
redis_url: Redis URL
task_id: Task ID
verbose: Whether to print the full SARIF object
"""
redis_client = Redis.from_url(redis_url)
sarif_store = SARIFStore(redis_client)
@@ -55,12 +54,12 @@ def list_task_sarifs(redis_url: str, task_id: str, verbose: bool = False) -> Non
def delete_task_sarifs(redis_url: str, task_id: str) -> None:
"""
Delete all SARIF objects for a specific task.
"""Delete all SARIF objects for a specific task.
Args:
redis_url: Redis URL
task_id: Task ID
"""
redis_client = Redis.from_url(redis_url)
sarif_store = SARIFStore(redis_client)
+21 -16
View File
@@ -1,12 +1,12 @@
from typing import Iterator
import json
import random
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from functools import lru_cache
from bson.json_util import CANONICAL_JSON_OPTIONS, dumps
from redis import Redis
from redis.exceptions import ResponseError
from bson.json_util import dumps, CANONICAL_JSON_OPTIONS
from contextlib import contextmanager
import random
import json
from functools import lru_cache
from typing import Generator
# Import POVReproduceRequest for the refactored PoVReproduceStatus
from buttercup.common.datastructures.msg_pb2 import POVReproduceRequest, POVReproduceResponse
@@ -64,7 +64,7 @@ class RedisLock:
@contextmanager
def acquire(self) -> Generator[None, None, None]:
if not self.redis.set(self.key, "1", ex=self.lock_timeout_seconds, nx=True):
raise FailedToAcquireLock()
raise FailedToAcquireLock
try:
yield
finally:
@@ -116,6 +116,7 @@ class PoVReproduceStatus:
Returns:
False if mitigated (didn't crash), True if non-mitigated (did crash), None if not in final states
"""
pipeline = self.redis.pipeline()
pipeline.sismember(POV_REPRODUCE_MITIGATED_SET_NAME, key)
@@ -124,10 +125,9 @@ class PoVReproduceStatus:
if result[0]:
return False # Mitigated - didn't crash
elif result[1]:
if result[1]:
return True # Non-mitigated - did crash
else:
return None # Not in final states
return None # Not in final states
def _make_key(self, request: POVReproduceRequest) -> str:
"""Create a unique key from a POVReproduceRequest by serializing it to string."""
@@ -144,6 +144,7 @@ class PoVReproduceStatus:
Returns:
None if pending, POVReproduceResponse if completed
"""
key = self._make_key(request)
@@ -161,13 +162,13 @@ class PoVReproduceStatus:
if result[0]:
return None # Pending
elif result[1]:
if result[1]:
return POVReproduceResponse(request=request, did_crash=False) # Completed and mitigated
elif result[2]:
if result[2]:
return POVReproduceResponse(request=request, did_crash=True) # Completed and not mitigated
else: # First time, schedule it for testing
self.redis.sadd(POV_REPRODUCE_PENDING_SET_NAME, key)
return None
# First time, schedule it for testing
self.redis.sadd(POV_REPRODUCE_PENDING_SET_NAME, key)
return None
def mark_mitigated(self, request: POVReproduceRequest) -> bool:
"""Mark a POV reproduction as mitigated (patch successfully prevented the crash).
@@ -177,6 +178,7 @@ class PoVReproduceStatus:
Returns:
True if the item was moved from pending to mitigated, False if item wasn't pending.
"""
key = self._make_key(request)
moved_count = self.redis.smove(POV_REPRODUCE_PENDING_SET_NAME, POV_REPRODUCE_MITIGATED_SET_NAME, key)
@@ -190,6 +192,7 @@ class PoVReproduceStatus:
Returns:
True if the item was moved from pending to non-mitigated, False if item wasn't pending.
"""
key = self._make_key(request)
moved_count = self.redis.smove(POV_REPRODUCE_PENDING_SET_NAME, POV_REPRODUCE_NON_MITIGATED_SET_NAME, key)
@@ -203,6 +206,7 @@ class PoVReproduceStatus:
Returns:
True if the item was moved from pending to expired, False if item wasn't pending.
"""
# NOTE: This function isn't strictly needed. We could just have keys expire after a certain time.
# However, this allows us to track which items have expired.
@@ -215,6 +219,7 @@ class PoVReproduceStatus:
Returns:
POVReproduceRequest if one is available, None otherwise
"""
pending_set = self.redis.smembers(POV_REPRODUCE_PENDING_SET_NAME)
if len(pending_set) == 0:
+6 -4
View File
@@ -1,9 +1,11 @@
import re
from buttercup.common.clusterfuzz_parser import StackParser, CrashInfo
import logging
from buttercup.common.sets import RedisSet
import re
from bson.json_util import CANONICAL_JSON_OPTIONS, dumps
from redis import Redis
from bson.json_util import dumps, CANONICAL_JSON_OPTIONS
from buttercup.common.clusterfuzz_parser import CrashInfo, StackParser
from buttercup.common.sets import RedisSet
logger = logging.getLogger(__name__)
+4 -2
View File
@@ -1,7 +1,7 @@
from dataclasses import dataclass, asdict
import json
from pathlib import Path
from dataclasses import asdict, dataclass
from os import PathLike
from pathlib import Path
@dataclass
@@ -27,6 +27,7 @@ class TaskMeta:
Raises:
FileNotFoundError: If the file doesn't exist
JSONDecodeError: If the file contains invalid JSON
"""
path = Path(directory) / cls.METADATA_FILENAME
with path.open() as f:
@@ -38,6 +39,7 @@ class TaskMeta:
Args:
directory: Directory where to save the metadata file
"""
path = Path(directory) / self.METADATA_FILENAME
with path.open("w") as f:
+33 -17
View File
@@ -1,11 +1,14 @@
from functools import lru_cache
from buttercup.common.datastructures.msg_pb2 import Task
from redis import Redis
from buttercup.common.queues import HashNames
from dataclasses import dataclass
from google.protobuf import text_format
import builtins
import time
from typing import Set, Iterator
from collections.abc import Iterator
from dataclasses import dataclass
from functools import lru_cache
from google.protobuf import text_format
from redis import Redis
from buttercup.common.datastructures.msg_pb2 import Task
from buttercup.common.queues import HashNames
# Redis set keys for tracking task states
CANCELLED_TASKS_SET = "cancelled_tasks"
@@ -68,6 +71,7 @@ class TaskRegistry:
Returns:
Task object if found, None otherwise
"""
prepared_key = self._prepare_key(task_id)
task_bytes = self.redis.hget(self.hash_name, prepared_key)
@@ -87,6 +91,7 @@ class TaskRegistry:
Args:
task_id: The ID of the task to delete
"""
prepared_key = self._prepare_key(task_id)
@@ -104,6 +109,7 @@ class TaskRegistry:
Args:
task_or_id: Either a Task object or a task ID string to be added to the cancelled set
"""
# Extract task_id based on the type of input
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -119,6 +125,7 @@ class TaskRegistry:
Returns:
True if the task is in the cancelled tasks set, False otherwise
"""
# Get task_id
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -140,6 +147,7 @@ class TaskRegistry:
Returns:
True if the task is expired (deadline has passed), False otherwise.
Returns False if the task doesn't exist.
"""
@lru_cache(maxsize=1000)
@@ -166,6 +174,7 @@ class TaskRegistry:
Returns:
list[Task]: List of active tasks
"""
# Iterate through all tasks, filtering out cancelled and expired ones
# The cancelled flag is already set correctly by the __iter__ method
@@ -176,13 +185,14 @@ class TaskRegistry:
Returns:
list[str]: List of task IDs that are in the cancelled tasks set
"""
# Get all cancelled task IDs from the Redis set
cancelled_ids = self.redis.smembers(CANCELLED_TASKS_SET)
# Decode bytes to strings if needed
return [task_id.decode("utf-8") if isinstance(task_id, bytes) else task_id for task_id in cancelled_ids]
def should_stop_processing(self, task_or_id: str | Task, cancelled_ids: Set[str] | None = None) -> bool:
def should_stop_processing(self, task_or_id: str | Task, cancelled_ids: builtins.set[str] | None = None) -> bool:
"""Check if a task should no longer be processed due to cancellation or expiration.
Args:
@@ -193,8 +203,8 @@ class TaskRegistry:
Returns:
bool: True if the task should not be processed (is cancelled or expired),
False otherwise
"""
"""
# Extract task_id for cancelled IDs check
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -223,6 +233,7 @@ class TaskRegistry:
Args:
task_or_id: Either a Task object or a task ID string to be added to the successful set
"""
# Extract task_id based on the type of input
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -238,6 +249,7 @@ class TaskRegistry:
Returns:
True if the task is in the successful tasks set, False otherwise
"""
# Get task_id
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -255,6 +267,7 @@ class TaskRegistry:
Args:
task_or_id: Either a Task object or a task ID string to be added to the errored set
"""
# Extract task_id based on the type of input
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -270,6 +283,7 @@ class TaskRegistry:
Returns:
True if the task is in the errored tasks set, False otherwise
"""
# Get task_id
task_id = task_or_id.task_id if isinstance(task_or_id, Task) else task_or_id
@@ -282,20 +296,22 @@ class TaskRegistry:
def task_registry_cli() -> None:
"""CLI for the task registry"""
from pydantic_settings import BaseSettings
from typing import Annotated
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class TaskRegistrySettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BUTTERCUP_TASK_REGISTRY_",
env_file=".env",
cli_parse_args=True,
extra="allow",
)
redis_url: Annotated[str, Field(default="redis://localhost:6379", description="Redis URL")]
class Config:
env_prefix = "BUTTERCUP_TASK_REGISTRY_"
env_file = ".env"
cli_parse_args = True
extra = "allow"
settings = TaskRegistrySettings()
settings = TaskRegistrySettings() # type: ignore[call-arg]
redis = Redis.from_url(settings.redis_url, decode_responses=False)
registry = TaskRegistry(redis)
+26 -23
View File
@@ -1,31 +1,25 @@
import os
import logging
from enum import Enum
import os
import uuid
from enum import Enum
from typing import Any
import openlit
import opentelemetry.attributes
from opentelemetry import trace
from opentelemetry.trace import Span, Tracer, Status, StatusCode
from langchain_core.prompt_values import ChatPromptValue
# Monkey patch the _clean_attribute function to handle ChatPromptValue
_clean_attribute_orig = opentelemetry.attributes._clean_attribute
def _clean_attribute_wrapper(key: str, value: Any, max_len: int | None = None) -> Any:
"""Wrapper around _clean_attribute to add custom behavior"""
if isinstance(value, ChatPromptValue):
value = value.to_string()
return _clean_attribute_orig(key, value, max_len)
opentelemetry.attributes._clean_attribute = _clean_attribute_wrapper
logger = logging.getLogger(__name__)
try:
import openlit
from opentelemetry import trace
from opentelemetry.trace import Span, Status, StatusCode, Tracer
_opentelemetry_enabled = True
except ImportError:
logger.warning("OpenTelemetry is not installed, skipping telemetry")
Span: type = Any
Tracer: type = Any
_opentelemetry_enabled = False
crs_instance_id = os.getenv("CRS_INSTANCE_ID", str(uuid.uuid4()))
service_instance_id = str(uuid.uuid4())
@@ -47,6 +41,9 @@ class CRSActionCategory(Enum):
def init_telemetry(application_name: str) -> None:
"""Initialize the telemetry for the application."""
if not _opentelemetry_enabled:
return
logger.info("Initializing telemetry for %s", application_name)
if not os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"):
logger.error("OTEL_EXPORTER_OTLP_ENDPOINT not set, disabling telemetry")
@@ -75,6 +72,9 @@ def set_crs_attributes(
task_metadata: dict,
extra_attributes: dict | None = None,
) -> None:
if not _opentelemetry_enabled:
return
extra_attributes = extra_attributes or {}
span.set_attribute("crs.action.category", crs_action_category.value)
span.set_attribute("crs.action.name", crs_action_name)
@@ -95,6 +95,9 @@ def log_crs_action_ok(
task_metadata: dict,
extra_attributes: dict | None = None,
) -> None:
if not _opentelemetry_enabled:
return
extra_attributes = extra_attributes or {}
with tracer.start_as_current_span(crs_action_name) as span:
span.set_attribute("crs.action.category", crs_action_category.value)
+9
View File
@@ -0,0 +1,9 @@
from dataclasses import dataclass
@dataclass
class FuzzConfiguration:
corpus_dir: str
target_path: str
engine: str
sanitizer: str
+254 -28
View File
@@ -1,26 +1,29 @@
import json
import logging
import subprocess
from pathlib import Path
from typing import Annotated
from uuid import uuid4
from google.protobuf.text_format import Parse
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, CliPositionalArg, CliSubCommand, SettingsConfigDict, get_subcommand
from redis import Redis
from buttercup.common.datastructures.msg_pb2 import (
BuildOutput,
BuildType,
SubmissionEntry,
SubmissionResult,
WeightedHarness,
)
from buttercup.common.logger import setup_package_logger
from buttercup.common.maps import (
BuildMap,
HarnessWeights,
)
from buttercup.common.datastructures.msg_pb2 import (
BuildOutput,
BuildType,
WeightedHarness,
SubmissionEntry,
SubmissionResult,
)
from buttercup.common.queues import QueueFactory, QueueNames, ReliableQueue
from buttercup.common.task_registry import TaskRegistry
from uuid import uuid4
from redis import Redis
from pydantic_settings import BaseSettings, CliSubCommand, CliPositionalArg, get_subcommand
from pydantic import BaseModel
from typing import Annotated
from pydantic import Field
from pathlib import Path
from google.protobuf.text_format import Parse
import logging
logger = logging.getLogger(__name__)
@@ -95,6 +98,7 @@ class ReadBuildsSettings(BaseModel):
class ReadSubmissionsSettings(BaseModel):
task_id: str = Field(default="", description="Task ID")
verbose: bool = Field(False, description="Show full stacktraces instead of truncated versions")
filter_stop: bool = Field(False, description="Filter out submissions that are stopped")
@@ -112,7 +116,26 @@ class DeleteSettings(BaseModel):
item_id: Annotated[str | None, Field(description="Item ID")] = None
class ExtractPovsSettings(BaseModel):
output_dir: CliPositionalArg[Path] = Field(
description="Output directory for extracted PoVs, stack traces, and patches"
)
task_id: str = Field(default="", description="Filter by task ID (optional)")
passed_only: bool = Field(default=False, description="Only extract vulnerabilities with PASSED PoV result")
namespace: str = Field(default="crs", description="Kubernetes namespace")
pod_label: str = Field(default="app=scheduler", description="Label selector for pod to copy files from")
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="BUTTERCUP_MSG_PUBLISHER_",
env_file=".env",
cli_parse_args=True,
nested_model_default_partial_update=True,
env_nested_delimiter="__",
extra="allow",
)
redis_url: Annotated[str, Field(default="redis://localhost:6379", description="Redis URL")]
log_level: Annotated[str, Field(default="info", description="Log level")]
send_queue: CliSubCommand[SendSettings]
@@ -124,14 +147,210 @@ class Settings(BaseSettings):
read_harnesses: CliSubCommand[ReadHarnessWeightSettings]
read_builds: CliSubCommand[ReadBuildsSettings]
read_submissions: CliSubCommand[ReadSubmissionsSettings]
extract_povs: CliSubCommand[ExtractPovsSettings]
class Config:
env_prefix = "BUTTERCUP_MSG_PUBLISHER_"
env_file = ".env"
cli_parse_args = True
nested_model_default_partial_update = True
env_nested_delimiter = "__"
extra = "allow"
def get_pod_name(namespace: str, label: str) -> str | None:
"""Get the name of a pod matching the label selector."""
try:
result = subprocess.run(
["kubectl", "get", "pods", "-n", namespace, "-l", label, "-o", "jsonpath={.items[0].metadata.name}"],
capture_output=True,
text=True,
check=True,
)
pod_name = result.stdout.strip()
return pod_name if pod_name else None
except subprocess.CalledProcessError as e:
logger.error(f"Failed to get pod name: {e.stderr}")
return None
def kubectl_cp(namespace: str, pod_name: str, remote_path: str, local_path: Path) -> bool:
"""Copy a file from a pod using kubectl cp."""
try:
result = subprocess.run(
["kubectl", "cp", "-n", namespace, f"{pod_name}:{remote_path}", str(local_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.warning(f"kubectl cp failed for {remote_path}: {result.stderr}")
return False
return True
except Exception as e:
logger.warning(f"kubectl cp exception for {remote_path}: {e}")
return False
def extract_povs(redis: Redis, command: ExtractPovsSettings) -> None:
"""Extract PoVs, stack traces, and patches into a directory structure.
Directory structure:
output_dir/
project_name/
task_id/
vuln_NNN/
crashes/
crash_001/
pov.bin
stacktrace.txt
tracer_stacktrace.txt
metadata.json
patches/
patch_001.patch
patch_002.patch
metadata.json
"""
SUBMISSIONS_KEY = "submissions"
raw_submissions: list = redis.lrange(SUBMISSIONS_KEY, 0, -1)
registry = TaskRegistry(redis)
if not raw_submissions:
logger.info("No submissions found")
return
# Get pod name for kubectl cp
pod_name = get_pod_name(command.namespace, command.pod_label)
if not pod_name:
logger.error(f"No pod found matching label '{command.pod_label}' in namespace '{command.namespace}'")
return
logger.info(f"Using pod '{pod_name}' for file extraction")
output_dir = command.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Found {len(raw_submissions)} submissions, extracting to {output_dir}")
vuln_counter: dict[str, int] = {} # task_id -> vulnerability counter
stats = {"povs_copied": 0, "povs_failed": 0}
for i, raw in enumerate(raw_submissions):
try:
submission = SubmissionEntry.FromString(raw)
if submission.stop:
logger.debug(f"Skipping stopped submission {i}")
continue
if not submission.crashes:
logger.debug(f"Skipping submission {i} with no crashes")
continue
# Get task info from the first crash
first_crash = submission.crashes[0].crash.crash
task_id = first_crash.target.task_id
if command.task_id and task_id != command.task_id:
logger.debug(f"Skipping submission {i} for task {task_id}")
continue
# Check if any crash passed (if passed_only filter is set)
if command.passed_only:
has_passed = any(c.result == SubmissionResult.PASSED for c in submission.crashes)
if not has_passed:
logger.debug(f"Skipping submission {i} - no PASSED crashes")
continue
# Get task metadata
task = registry.get(task_id)
project_name = task.project_name if task else "unknown"
# Create vulnerability directory
if task_id not in vuln_counter:
vuln_counter[task_id] = 0
vuln_counter[task_id] += 1
vuln_num = vuln_counter[task_id]
vuln_dir = output_dir / project_name / task_id / f"vuln_{vuln_num:03d}"
crashes_dir = vuln_dir / "crashes"
patches_dir = vuln_dir / "patches"
crashes_dir.mkdir(parents=True, exist_ok=True)
patches_dir.mkdir(parents=True, exist_ok=True)
# Extract crashes
for crash_idx, crash_with_id in enumerate(submission.crashes, start=1):
crash = crash_with_id.crash
crash_dir = crashes_dir / f"crash_{crash_idx:03d}"
crash_dir.mkdir(parents=True, exist_ok=True)
# Copy PoV file using kubectl cp
pov_remote_path = crash.crash.crash_input_path
pov_local_path = crash_dir / "pov.bin"
if kubectl_cp(command.namespace, pod_name, pov_remote_path, pov_local_path):
stats["povs_copied"] += 1
else:
stats["povs_failed"] += 1
# Store the path for reference
(crash_dir / "pov_path.txt").write_text(pov_remote_path)
# Write stacktrace
if crash.crash.stacktrace:
(crash_dir / "stacktrace.txt").write_text(crash.crash.stacktrace)
# Write tracer stacktrace
if crash.tracer_stacktrace:
(crash_dir / "tracer_stacktrace.txt").write_text(crash.tracer_stacktrace)
# Write crash metadata
crash_metadata = {
"competition_pov_id": crash_with_id.competition_pov_id,
"result": SubmissionResult.Name(crash_with_id.result) if crash_with_id.result else "NONE",
"harness_name": crash.crash.harness_name,
"crash_token": crash.crash.crash_token,
"crash_input_path": crash.crash.crash_input_path,
"sanitizer": crash.crash.target.sanitizer,
"engine": crash.crash.target.engine,
}
(crash_dir / "metadata.json").write_text(json.dumps(crash_metadata, indent=2))
# Extract patches (skip empty patch trackers - these are placeholders that never received content)
patch_num = 0
for patch_entry in submission.patches:
if not patch_entry.patch:
continue # Skip empty patch trackers
patch_num += 1
patch_file = patches_dir / f"patch_{patch_num:03d}.patch"
patch_file.write_text(patch_entry.patch)
# Write patch metadata
patch_metadata = {
"internal_patch_id": patch_entry.internal_patch_id,
"competition_patch_id": patch_entry.competition_patch_id,
"result": SubmissionResult.Name(patch_entry.result) if patch_entry.result else "NONE",
}
(patches_dir / f"patch_{patch_num:03d}_metadata.json").write_text(json.dumps(patch_metadata, indent=2))
# Write vulnerability metadata
vuln_metadata = {
"task_id": task_id,
"project_name": project_name,
"num_crashes": len(submission.crashes),
"num_patches": patch_num, # Only count non-empty patches
"num_bundles": len(submission.bundles),
"patch_idx": submission.patch_idx,
"stopped": submission.stop,
}
(vuln_dir / "metadata.json").write_text(json.dumps(vuln_metadata, indent=2))
logger.info(
f"Extracted vulnerability {vuln_num} for {project_name}/{task_id}: "
f"{len(submission.crashes)} crashes, {patch_num} patches"
)
except Exception as e:
logger.error(f"Failed to process submission {i}: {e}")
continue
# Print summary
total_vulns = sum(vuln_counter.values())
logger.info(f"Extraction complete: {total_vulns} vulnerabilities across {len(vuln_counter)} tasks")
logger.info(f"PoV files: {stats['povs_copied']} copied, {stats['povs_failed']} failed")
for task_id, count in vuln_counter.items():
logger.info(f" {task_id}: {count} vulnerabilities")
def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
@@ -227,9 +446,15 @@ def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
logger.error(f"Task {task_id} not found in registry")
continue
if command.task_id and task_id != command.task_id:
logger.info(f"Skipping submission {i} for task {task_id} (not {command.task_id})")
continue
if task_id not in result:
result[task_id] = TaskResult(
task_id=task_id, project_name=task.project_name, mode=str(task.task_type)
task_id=task_id,
project_name=task.project_name,
mode=str(task.task_type),
)
c = next((c for c in submission.crashes if c.result == SubmissionResult.PASSED), None)
if c:
@@ -240,9 +465,8 @@ def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
result[task_id].n_patches += 1
assert c is not None
result[task_id].patched_vulnerabilities.append(c.competition_pov_id)
else:
if c:
result[task_id].non_patched_vulnerabilities.append(c.competition_pov_id)
elif c:
result[task_id].non_patched_vulnerabilities.append(c.competition_pov_id)
b = next((b for b in submission.bundles), None)
if b:
@@ -297,13 +521,15 @@ def handle_subcommand(redis: Redis, command: BaseModel | None) -> None:
print()
logger.info("Done")
elif isinstance(command, ExtractPovsSettings):
extract_povs(redis, command)
elif isinstance(command, ListSettings):
print("Available queues:")
print("\n".join([f"- {name}" for name in get_queue_names()]))
def main() -> None:
settings = Settings()
settings = Settings() # type: ignore[call-arg]
setup_package_logger("util-cli", __name__, settings.log_level)
redis = Redis.from_url(settings.redis_url, decode_responses=False)
+9 -6
View File
@@ -1,12 +1,13 @@
import shutil
import errno
import logging
import os
from typing import Any, Callable
from pathlib import Path
from os import PathLike
import time
import shutil
import threading
import time
from collections.abc import Callable
from os import PathLike
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@@ -24,7 +25,9 @@ def copyanything(src: PathLike, dst: PathLike, **kwargs: Any) -> None:
"""
src, dst = Path(src), Path(dst)
try:
shutil.copytree(src, dst, dirs_exist_ok=True, **kwargs)
shutil.copytree(src, dst, dirs_exist_ok=True, ignore_dangling_symlinks=True, **kwargs)
except shutil.Error:
logger.exception(f"Some errors occurred while copying {src} to {dst}, continuing anyway...")
except OSError as exc: # python >2.5
if exc.errno in (errno.ENOTDIR, errno.EINVAL):
shutil.copy(src, dst)
+171 -34
View File
@@ -1,18 +1,23 @@
from pathlib import Path
import pytest
from unittest.mock import MagicMock, patch
from dirty_equals import IsStr
import subprocess
import os
import base64
import os
import platform
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from dirty_equals import IsStr
from buttercup.common.challenge_task import (
ChallengeTask,
ChallengeTaskError,
ReproduceResult,
CommandResult,
ReproduceResult,
)
from buttercup.common.task_meta import TaskMeta
import tempfile
# ruff: noqa: E501, W291
@pytest.fixture
@@ -105,8 +110,10 @@ def get_mock_popen(returncode: int, stdout: list[bytes], stderr: list[bytes]):
mock_process.poll.side_effect = [None, None, returncode]
mock_process.wait.return_value = returncode
# Make Popen return our mock process
# Make Popen return our mock process and work as context manager
mock_popen.return_value = mock_process
mock_popen.return_value.__enter__ = MagicMock(return_value=mock_process)
mock_popen.return_value.__exit__ = MagicMock(return_value=False)
yield mock_popen
@@ -373,7 +380,7 @@ def libjpeg_oss_fuzz_task_dir(tmp_path: Path) -> Path:
metadata={"task_id": "task-id-libjpeg-turbo", "round_id": "testing", "team_id": "tob"},
).save(tmp_path)
yield tmp_path
return tmp_path
@pytest.fixture
@@ -457,6 +464,121 @@ def test_real_reproduce_pov(libjpeg_oss_fuzz_task_rw: ChallengeTask, libjpeg_cra
assert result.did_crash(), "Reproduce POV failed"
@pytest.mark.integration
@pytest.mark.parametrize(
"oss_fuzz_commit,description",
[
("eb47f56bd15626ab4ae8727bc435148dd2f9857c^", "before_bug"),
("eb47f56bd15626ab4ae8727bc435148dd2f9857c", "bug_introduced"),
("HEAD", "current_head"),
],
)
def test_helper_py_patching_across_commits(
tmp_path: Path, libjpeg_crash_testcase: Path, oss_fuzz_commit: str, description: str
):
"""Test helper.py patching across different OSS-Fuzz commits.
This test validates that helper.py patching works correctly across different
OSS-Fuzz commits:
- Before the bug: eb47f56^ (should work without needing reproduce_impl patch)
- Bug introduced: eb47f56bd15626ab4ae8727bc435148dd2f9857c (needs patching)
- Current HEAD: Latest OSS-Fuzz (needs patching)
The patching should ensure reproduce_pov runs successfully on all commits.
"""
# Detect current architecture
current_arch = platform.machine()
# Normalize architecture names
if current_arch in ("arm64", "aarch64"):
current_arch = "aarch64"
elif current_arch in ("x86_64", "amd64"):
current_arch = "x86_64"
# Only test ARM64 on ARM64 platforms, x86_64 on x86_64 platforms
# For this test we use the current architecture
# Create task directory structure
task_dir = tmp_path / f"libjpeg-turbo-{description}"
task_dir.mkdir(parents=True)
oss_fuzz_dir = task_dir / "fuzz-tooling"
oss_fuzz_dir.mkdir(parents=True)
source_dir = task_dir / "src"
source_dir.mkdir(parents=True)
# Clone OSS-Fuzz at specific commit
subprocess.run(["git", "-C", str(oss_fuzz_dir), "clone", "https://github.com/google/oss-fuzz.git"], check=True)
subprocess.run(
["git", "-C", str(oss_fuzz_dir / "oss-fuzz"), "checkout", oss_fuzz_commit],
check=True,
)
# Clone libjpeg-turbo source (same commit as existing test)
libjpeg_url = "https://github.com/libjpeg-turbo/libjpeg-turbo"
subprocess.run(["git", "-C", str(source_dir), "clone", libjpeg_url], check=True)
subprocess.run(
["git", "-C", str(source_dir / "libjpeg-turbo"), "checkout", "6d91e950c871103a11bac2f10c63bf998796c719"],
check=True,
)
# Create task metadata
TaskMeta(
project_name="libjpeg-turbo",
focus="libjpeg-turbo",
task_id=f"task-id-libjpeg-turbo-{description}",
metadata={"task_id": f"task-id-libjpeg-turbo-{description}", "round_id": "testing", "team_id": "tob"},
).save(task_dir)
# Create challenge task
task = ChallengeTask(
read_only_task_dir=task_dir,
)
# Get RW copy and test
with task.get_rw_copy(None) as local_task:
# Build image and fuzzers
result = local_task.build_image(pull_latest_base_image=False, architecture=current_arch)
assert result.success is True, f"Build image failed: {result.error}"
result = local_task.build_fuzzers(engine="libfuzzer", sanitizer="address", architecture=current_arch)
assert result.success is True, f"Build fuzzers failed: {result.error}"
# Reproduce the POV - this should work with patching (the fuzzer should run successfully)
# Note: We're testing that reproduce_pov runs without errors, not necessarily that
# this specific crash test case triggers a crash (compatibility may vary by version)
result = local_task.reproduce_pov(
fuzzer_name="libjpeg_turbo_fuzzer",
crash_path=libjpeg_crash_testcase,
)
# The key is that reproduce_pov should RUN successfully (did_run returns True)
# This confirms the patching fixed the reproduce_impl issue
assert result.did_run(), (
f"Reproduce POV did not run for commit {oss_fuzz_commit} ({description}). "
f"Patching may not have worked correctly. Output: {result.command_result.output[:500] if result.command_result.output else 'None'}"
)
# Verify that helper.py was patched correctly
helper_path = local_task.get_oss_fuzz_path() / "infra" / "helper.py"
assert helper_path.exists(), "helper.py not found"
helper_content = helper_path.read_text()
# Check for the common fix: architecture=architecture instead of err_result
# The fix should change: return run_function(run_args, err_result)
# To: return run_function(run_args, architecture=architecture)
assert "architecture=architecture" in helper_content, (
f"helper.py patching failed: 'architecture=architecture' not found in helper.py for commit {oss_fuzz_commit}"
)
# On ARM64, verify ARM64-specific patches were applied
if current_arch == "aarch64":
# Check for ARM64 manifest tags
assert ":manifest-arm64v8" in helper_content, (
f"helper.py patching failed: ARM64 manifest tags not found for commit {oss_fuzz_commit}"
)
def test_copy_task(challenge_task_readonly: ChallengeTask, mock_subprocess):
"""Test copying a challenge task to a temporary directory."""
with patch.object(ChallengeTask, "_check_python_path"):
@@ -717,7 +839,10 @@ def mock_node_local(monkeypatch, tmp_path: Path):
@patch("buttercup.common.node_local._get_root_path")
@patch("buttercup.common.node_local.remote_archive_to_dir")
def test_challenge_task_with_node_local_storage_existing(
mock_remote_archive_to_dir, mock_get_root_path, mock_node_local_storage, task_dir
mock_remote_archive_to_dir,
mock_get_root_path,
mock_node_local_storage,
task_dir,
):
"""Test ChallengeTask behavior when using node_local and path exists."""
mock_get_root_path.return_value = Path(mock_node_local_storage)
@@ -770,7 +895,10 @@ def test_challenge_task_with_node_local_storage_existing(
@patch("buttercup.common.node_local._get_root_path")
@patch("buttercup.common.node_local.remote_archive_to_dir")
def test_challenge_task_with_node_local_storage_download(
mock_remote_archive_to_dir, mock_get_root_path, mock_node_local_storage, task_dir
mock_remote_archive_to_dir,
mock_get_root_path,
mock_node_local_storage,
task_dir,
):
"""Test ChallengeTask behavior when using node_local and path doesn't exist."""
mock_get_root_path.return_value = Path(mock_node_local_storage)
@@ -866,7 +994,7 @@ def mock_node_local_storage(tmp_path: Path):
metadata={"task_id": "task-id-challenge-task", "round_id": "testing", "team_id": "tob"},
).save(local_task_path)
yield node_data_dir
return node_data_dir
@pytest.mark.integration
@@ -891,7 +1019,7 @@ def test_reproduce_result_stacktrace():
# Test case 1: Short string (under 1MB limit)
short_output = b"INFO: Seed: 12345\nRunning normally\n==ERROR: AddressSanitizer: heap-buffer-overflow"
result1 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=short_output, error=None)
command_result=CommandResult(success=False, returncode=1, output=short_output, error=None),
)
stacktrace1 = result1.stacktrace()
assert stacktrace1 is not None
@@ -906,7 +1034,7 @@ def test_reproduce_result_stacktrace():
)
result2 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=large_content, error=None)
command_result=CommandResult(success=False, returncode=1, output=large_content, error=None),
)
stacktrace2 = result2.stacktrace()
assert stacktrace2 is not None
@@ -938,7 +1066,7 @@ def test_reproduce_result_stacktrace():
exact_size_content = prefix + b"B" * (MAX_OUTPUT_LEN - len(prefix) - len(suffix)) + suffix
result4 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=exact_size_content, error=None)
command_result=CommandResult(success=False, returncode=1, output=exact_size_content, error=None),
)
stacktrace4 = result4.stacktrace()
assert stacktrace4 is not None
@@ -954,7 +1082,7 @@ def test_reproduce_result_stacktrace():
)
result5 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=over_limit_content, error=None)
command_result=CommandResult(success=False, returncode=1, output=over_limit_content, error=None),
)
stacktrace5 = result5.stacktrace()
assert stacktrace5 is not None
@@ -969,7 +1097,7 @@ def test_reproduce_result_stacktrace():
)
result6 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=very_large_content, error=None)
command_result=CommandResult(success=False, returncode=1, output=very_large_content, error=None),
)
stacktrace6 = result6.stacktrace()
assert stacktrace6 is not None
@@ -989,15 +1117,18 @@ def test_reproduce_result_methods():
# Test case 1: Successful run, no crash
result1 = ReproduceResult(
command_result=CommandResult(
success=True, returncode=0, output=b"INFO: Seed: 12345\nRunning normally", error=None
)
success=True,
returncode=0,
output=b"INFO: Seed: 12345\nRunning normally",
error=None,
),
)
assert result1.did_run() is True
assert result1.did_crash() is False
# Test case 2: Failed run, no crash (fuzzer didn't start)
result2 = ReproduceResult(
command_result=CommandResult(success=False, returncode=1, output=b"Error: Could not start fuzzer", error=None)
command_result=CommandResult(success=False, returncode=1, output=b"Error: Could not start fuzzer", error=None),
)
assert result2.did_run() is False
assert result2.did_crash() is False
@@ -1009,7 +1140,7 @@ def test_reproduce_result_methods():
returncode=1,
output=b"INFO: Seed: 12345\nRunning normally\n==ERROR: AddressSanitizer: heap-buffer-overflow",
error=None,
)
),
)
assert result3.did_run() is True
assert result3.did_crash() is True
@@ -1021,7 +1152,7 @@ def test_reproduce_result_methods():
returncode=1,
output=None,
error=b"INFO: Seed: 12345\nRunning normally\n==ERROR: AddressSanitizer: heap-buffer-overflow",
)
),
)
assert result4.did_run() is True
assert result4.did_crash() is True
@@ -1029,8 +1160,11 @@ def test_reproduce_result_methods():
# Test case 5: Run with None returncode
result5 = ReproduceResult(
command_result=CommandResult(
success=False, returncode=None, output=b"INFO: Seed: 12345\nRunning normally", error=None
)
success=False,
returncode=None,
output=b"INFO: Seed: 12345\nRunning normally",
error=None,
),
)
assert result5.did_run() is True
assert result5.did_crash() is False
@@ -1042,7 +1176,7 @@ def test_reproduce_result_methods():
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\n==ERROR: AddressSanitizer: heap-buffer-overflow\nTimeout occurred",
error=None,
)
),
)
assert result6.did_run() is True
assert result6.did_crash() is True # Should detect crash due to crash token in stacktrace
@@ -1054,7 +1188,7 @@ def test_reproduce_result_methods():
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\nTimeout occurred\nNo crash detected",
error=None,
)
),
)
assert result7.did_run() is True
assert result7.did_crash() is False # Should not detect crash due to no crash token
@@ -1102,7 +1236,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\n" + output + b"\nTimeout occurred",
error=b"",
)
),
)
assert result8.did_run() is True
assert result8.did_crash() is True # Should detect crash due to crash token in error output
@@ -1114,7 +1248,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=124, # TIMEOUT_ERR_RESULT
output=None,
error=None,
)
),
)
assert result9.did_run() is False
assert result9.did_crash() is False # Should not detect crash due to no output and no crash token
@@ -1126,7 +1260,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nTimeout occurred",
error=None,
)
),
)
assert result10.did_run() is True
assert result10.did_crash() is False # Should not detect crash due to no crash token
@@ -1138,7 +1272,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=201, # FAILURE_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\n==ERROR: AddressSanitizer: heap-buffer-overflow",
error=None,
)
),
)
assert result11.did_run() is True
assert result11.did_crash() is False # Should not detect crash due to FAILURE_ERR_RESULT
@@ -1150,7 +1284,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\n" + output + b"\nTimeout occurred",
error=None,
)
),
)
assert result12.did_run() is True
assert result12.did_crash() is True # Should detect crash due to UBSan error in stacktrace
@@ -1162,7 +1296,7 @@ subprocess command returned a non-zero exit status: 1"""
returncode=124, # TIMEOUT_ERR_RESULT
output=b"INFO: Seed: 12345\nRunning normally\n" + output + b"\n" + output + b"\nTimeout occurred",
error=None,
)
),
)
assert result13.did_run() is True
assert result13.did_crash() is True # Should detect crash due to multiple crash patterns in stacktrace
@@ -1265,7 +1399,10 @@ def test_apply_patch_diff_git_apply_failure(challenge_task: ChallengeTask):
with patch("subprocess.run") as mock_run:
# Simulate git apply failure
mock_run.side_effect = subprocess.CalledProcessError(
returncode=1, cmd=["patch", "-p1"], output="", stderr="patch does not apply"
returncode=1,
cmd=["patch", "-p1"],
output="",
stderr="patch does not apply",
)
with pytest.raises(ChallengeTaskError, match="Error applying diff"):
+333 -3
View File
@@ -1,10 +1,13 @@
import pytest
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from buttercup.common.corpus import InputDir, Corpus
import pytest
from buttercup.common import node_local
from buttercup.common.corpus import Corpus, InputDir, _get_corpus_storage_path
@pytest.fixture
@@ -81,7 +84,7 @@ def test_local_corpus_size_with_mixed_exceptions(temp_dir, mock_node_local):
if self.name == "test_file_2" and call_count[self.name] == 1:
raise FileNotFoundError(f"Simulated transient error for {self.name}")
# test_file_4 will always fail
elif self.name == "test_file_4":
if self.name == "test_file_4":
raise PermissionError(f"Simulated permission error for {self.name}")
# Other files work normally
return original_lstat(self)
@@ -225,3 +228,330 @@ def test_input_dir_copy_corpus_all_files_too_large(temp_dir, mock_node_local):
# Should return empty list
assert copied_files == []
assert input_dir.local_corpus_count() == 0
def test_copy_corpus_only_local(temp_dir):
"""Test that copy_corpus copies only to node-local (not remote)."""
remote_path = os.path.join(temp_dir, "remote")
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
input_dir = InputDir(temp_dir, "test_corpus")
src_dir = os.path.join(temp_dir, "src_corpus")
os.makedirs(src_dir, exist_ok=True)
# Create a test file
file_path = os.path.join(src_dir, "test_file")
with open(file_path, "wb") as f:
f.write(b"test content")
copied_files = input_dir.copy_corpus(src_dir)
# File should exist locally
assert len(copied_files) == 1
assert os.path.exists(copied_files[0])
# Remote file should not exist
remote_file = os.path.join(remote_path, os.path.basename(copied_files[0]))
assert not os.path.exists(remote_file)
def test_copy_file_only_local(temp_dir):
"""Test that copy_file with only_local=True skips remote copy."""
remote_path = os.path.join(temp_dir, "remote")
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
input_dir = InputDir(temp_dir, "test_corpus")
src_dir = os.path.join(temp_dir, "src_corpus")
os.makedirs(src_dir, exist_ok=True)
# Create a test file
file_path = os.path.join(src_dir, "test_file")
with open(file_path, "wb") as f:
f.write(b"test content")
# Copy file with only_local=True
dst = input_dir.copy_file(file_path, only_local=True)
# File should exist locally
assert os.path.exists(dst)
# Remote file should not exist
remote_file = os.path.join(remote_path, os.path.basename(dst))
assert not os.path.exists(remote_file)
def test_copy_file_with_remote(temp_dir):
"""Test that copy_file with only_local=False copies to both local and remote."""
remote_path = os.path.join(temp_dir, "remote")
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
input_dir = InputDir(temp_dir, "test_corpus")
src_dir = os.path.join(temp_dir, "src_corpus")
os.makedirs(src_dir, exist_ok=True)
# Create a test file
file_path = os.path.join(src_dir, "test_file")
with open(file_path, "wb") as f:
f.write(b"test content")
# Copy file with only_local=False (explicit)
dst = input_dir.copy_file(file_path, only_local=False)
# File should exist locally
assert os.path.exists(dst)
# Same file should exist in remote
remote_file = os.path.join(remote_path, os.path.basename(dst))
assert os.path.exists(remote_file)
# ============================================================================
# Tests for tmpfs corpus functionality
# ============================================================================
@pytest.fixture
def tmpfs_temp_dirs():
"""Create separate temp directories for node_data, tmpfs, and remote storage."""
node_data_dir = tempfile.mkdtemp(prefix="node_data_")
tmpfs_dir = tempfile.mkdtemp(prefix="tmpfs_")
yield {"node_data": node_data_dir, "tmpfs": tmpfs_dir}
shutil.rmtree(node_data_dir, ignore_errors=True)
shutil.rmtree(tmpfs_dir, ignore_errors=True)
class TestTmpfsCorpusConfiguration:
"""Tests for tmpfs corpus configuration functions."""
def test_is_corpus_tmpfs_enabled_when_not_set(self):
"""Test that tmpfs is disabled when env var is not set."""
with patch.object(node_local, "corpus_tmpfs_path", None):
assert node_local.is_corpus_tmpfs_enabled() is False
def test_is_corpus_tmpfs_enabled_when_set(self):
"""Test that tmpfs is enabled when env var is set."""
with patch.object(node_local, "corpus_tmpfs_path", "/tmp/corpus"):
assert node_local.is_corpus_tmpfs_enabled() is True
def test_get_corpus_tmpfs_path_when_not_set(self):
"""Test that get_corpus_tmpfs_path returns None when not configured."""
with patch.object(node_local, "corpus_tmpfs_path", None):
assert node_local.get_corpus_tmpfs_path() is None
def test_get_corpus_tmpfs_path_when_set(self):
"""Test that get_corpus_tmpfs_path returns the configured path."""
with patch.object(node_local, "corpus_tmpfs_path", "/tmp/corpus"):
assert node_local.get_corpus_tmpfs_path() == Path("/tmp/corpus")
class TestCorpusTmpfsStorage:
"""Tests for corpus storage using tmpfs."""
def test_corpus_uses_standard_path_when_tmpfs_disabled(self, tmpfs_temp_dirs):
"""Test that Corpus uses standard node-local path when tmpfs is disabled."""
node_data = tmpfs_temp_dirs["node_data"]
remote_path = Path(tmpfs_temp_dirs["node_data"]) / "remote"
with patch.object(node_local, "corpus_tmpfs_path", None):
with patch.object(node_local, "node_local_path", node_data):
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
corpus = Corpus(node_data, "test_task", "test_harness")
# Corpus should be stored under node_data
assert corpus.path.startswith(node_data)
assert "test_task" in corpus.path
assert "buttercup_corpus_test_harness" in corpus.path
def test_corpus_uses_tmpfs_path_when_enabled(self, tmpfs_temp_dirs):
"""Test that Corpus uses tmpfs path when tmpfs is enabled."""
node_data = tmpfs_temp_dirs["node_data"]
tmpfs = tmpfs_temp_dirs["tmpfs"]
remote_path = Path("/") / "test_task" / "buttercup_corpus_test_harness"
with patch.object(node_local, "corpus_tmpfs_path", tmpfs):
with patch.object(node_local, "node_local_path", node_data):
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
corpus = Corpus(node_data, "test_task", "test_harness")
# Corpus should be stored under tmpfs
assert corpus.path.startswith(tmpfs)
assert "test_task" in corpus.path
assert "buttercup_corpus_test_harness" in corpus.path
def test_corpus_remote_path_unchanged_with_tmpfs(self, tmpfs_temp_dirs):
"""Test that remote path is calculated correctly regardless of tmpfs setting."""
node_data = tmpfs_temp_dirs["node_data"]
tmpfs = tmpfs_temp_dirs["tmpfs"]
# The remote path should be calculated based on node_data structure, not tmpfs
expected_remote = Path("/") / "test_task" / "buttercup_corpus_test_harness"
with patch.object(node_local, "corpus_tmpfs_path", tmpfs):
with patch.object(node_local, "node_local_path", node_data):
local_path, remote_path = _get_corpus_storage_path(node_data, "test_task/buttercup_corpus_test_harness")
# Local should use tmpfs
assert tmpfs in local_path
# Remote should be calculated from node_data structure
assert remote_path == expected_remote
def test_corpus_file_operations_with_tmpfs(self, tmpfs_temp_dirs):
"""Test that corpus file operations work correctly with tmpfs."""
node_data = tmpfs_temp_dirs["node_data"]
tmpfs = tmpfs_temp_dirs["tmpfs"]
remote_path = Path(node_data) / "remote" / "test_task" / "buttercup_corpus_test_harness"
with patch.object(node_local, "corpus_tmpfs_path", tmpfs):
with patch.object(node_local, "node_local_path", node_data):
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
corpus = Corpus(node_data, "test_task", "test_harness")
# Create a source directory with test files
src_dir = os.path.join(tmpfs_temp_dirs["node_data"], "src")
os.makedirs(src_dir, exist_ok=True)
# Create test files
for i in range(3):
file_path = os.path.join(src_dir, f"file_{i}")
with open(file_path, "wb") as f:
f.write(f"content_{i}".encode())
# Copy files to corpus (should be on tmpfs)
copied = corpus.copy_corpus(src_dir)
assert len(copied) == 3
assert corpus.local_corpus_count() == 3
# Verify files are on tmpfs
for copied_file in copied:
assert tmpfs in copied_file
assert os.path.exists(copied_file)
class TestGetCorpusStoragePath:
"""Tests for the _get_corpus_storage_path function."""
def test_standard_path_when_tmpfs_disabled(self, tmpfs_temp_dirs):
"""Test standard path calculation when tmpfs is disabled."""
node_data = tmpfs_temp_dirs["node_data"]
with patch.object(node_local, "corpus_tmpfs_path", None):
with patch.object(node_local, "node_local_path", node_data):
local_path, remote_path = _get_corpus_storage_path(node_data, "task/corpus")
assert local_path == os.path.join(node_data, "task/corpus")
assert remote_path == Path("/task/corpus")
def test_tmpfs_path_when_enabled(self, tmpfs_temp_dirs):
"""Test tmpfs path calculation when enabled."""
node_data = tmpfs_temp_dirs["node_data"]
tmpfs = tmpfs_temp_dirs["tmpfs"]
with patch.object(node_local, "corpus_tmpfs_path", tmpfs):
with patch.object(node_local, "node_local_path", node_data):
local_path, remote_path = _get_corpus_storage_path(node_data, "task/corpus")
# Local path should use tmpfs
assert local_path == os.path.join(tmpfs, "task/corpus")
# Remote path should still be calculated from node_data structure
assert remote_path == Path("/task/corpus")
class TestCrossFilesystemOperations:
"""Tests for cross-filesystem file operations."""
def test_hash_corpus_with_shutil_move(self, temp_dir, mock_node_local):
"""Test that hash_corpus works correctly (uses shutil.move internally)."""
input_dir = InputDir(temp_dir, "test_corpus")
# Create files with non-hash names
test_files = ["file_a.txt", "file_b.txt", "file_c.txt"]
for name in test_files:
file_path = os.path.join(input_dir.path, name)
with open(file_path, "wb") as f:
f.write(f"content of {name}".encode())
# Hash the corpus
hashed = InputDir.hash_corpus(input_dir.path)
# Should have hashed all 3 files
assert len(hashed) == 3
# All files should now have hash names (64 hex chars)
for file in os.listdir(input_dir.path):
assert InputDir.has_hashed_name(file)
def test_hash_corpus_skips_already_hashed(self, temp_dir, mock_node_local):
"""Test that hash_corpus skips files that are already hashed."""
input_dir = InputDir(temp_dir, "test_corpus")
# Create a file with a hash name (64 hex chars)
hash_name = "a" * 64
hash_file_path = os.path.join(input_dir.path, hash_name)
with open(hash_file_path, "wb") as f:
f.write(b"already hashed content")
# Create a non-hashed file
non_hash_path = os.path.join(input_dir.path, "not_hashed.txt")
with open(non_hash_path, "wb") as f:
f.write(b"not hashed content")
# Hash the corpus
hashed = InputDir.hash_corpus(input_dir.path)
# Should only hash the non-hashed file
assert len(hashed) == 1
assert hash_name not in hashed
# The pre-existing hash file should still exist with same name
assert os.path.exists(hash_file_path)
class TestInputDirOverrides:
"""Tests for InputDir with override parameters."""
def test_input_dir_with_override_local_path(self, temp_dir):
"""Test InputDir with override_local_path parameter."""
custom_local = os.path.join(temp_dir, "custom_local")
remote_path = Path(temp_dir) / "remote"
with patch("buttercup.common.node_local.remote_path", return_value=remote_path):
input_dir = InputDir(
temp_dir,
"test_corpus",
override_local_path=custom_local,
)
assert input_dir.path == custom_local
assert os.path.exists(custom_local)
def test_input_dir_with_override_remote_path(self, temp_dir):
"""Test InputDir with override_remote_path parameter."""
custom_remote = Path("/custom/remote/path")
# Need to still patch the default remote_path call
with patch("buttercup.common.node_local.remote_path", return_value=Path("/default")):
input_dir = InputDir(
temp_dir,
"test_corpus",
override_remote_path=custom_remote,
)
assert input_dir.remote_path == custom_remote
def test_input_dir_with_both_overrides(self, temp_dir):
"""Test InputDir with both override parameters."""
custom_local = os.path.join(temp_dir, "custom_local")
custom_remote = Path("/custom/remote")
with patch("buttercup.common.node_local.remote_path", return_value=Path("/default")):
input_dir = InputDir(
temp_dir,
"test_corpus",
override_local_path=custom_local,
override_remote_path=custom_remote,
)
assert input_dir.path == custom_local
assert input_dir.remote_path == custom_remote
+2 -1
View File
@@ -1,7 +1,8 @@
import pytest
from redis import Redis
from buttercup.common.maps import CoverageMap
from buttercup.common.datastructures.msg_pb2 import FunctionCoverage
from buttercup.common.maps import CoverageMap
@pytest.fixture
+189
View File
@@ -0,0 +1,189 @@
"""Tests for cross-filesystem operations in node_local module.
These tests use real filesystem operations, not mocks, to test the actual
behavior of cross-filesystem file operations.
"""
import errno
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from buttercup.common.node_local import (
_copy_and_delete,
rename_atomically,
)
@pytest.fixture
def temp_dirs():
"""Create temporary directories for testing."""
dir1 = tempfile.mkdtemp(prefix="test_dir1_")
dir2 = tempfile.mkdtemp(prefix="test_dir2_")
yield {"dir1": Path(dir1), "dir2": Path(dir2)}
shutil.rmtree(dir1, ignore_errors=True)
shutil.rmtree(dir2, ignore_errors=True)
class TestCopyAndDelete:
"""Tests for _copy_and_delete function."""
def test_file(self, temp_dirs):
"""Test _copy_and_delete for files."""
src = temp_dirs["dir1"] / "test_file.txt"
dst = temp_dirs["dir2"] / "copied_file.txt"
# Create source file
src.write_bytes(b"file content")
# Copy and delete
result = _copy_and_delete(src, dst)
assert result == dst
assert dst.exists()
assert not src.exists()
assert dst.read_bytes() == b"file content"
def test_directory(self, temp_dirs):
"""Test _copy_and_delete for directories."""
src = temp_dirs["dir1"] / "test_dir"
dst = temp_dirs["dir2"] / "copied_dir"
# Create source directory with files
src.mkdir()
(src / "file1.txt").write_bytes(b"content1")
(src / "file2.txt").write_bytes(b"content2")
# Copy and delete
result = _copy_and_delete(src, dst)
assert result == dst
assert dst.exists()
assert not src.exists()
assert (dst / "file1.txt").read_bytes() == b"content1"
assert (dst / "file2.txt").read_bytes() == b"content2"
def test_file_exists(self, temp_dirs):
"""Test _copy_and_delete when destination already exists."""
src = temp_dirs["dir1"] / "test_file.txt"
dst = temp_dirs["dir2"] / "existing_file.txt"
# Create source and destination files
src.write_bytes(b"source content")
dst.write_bytes(b"existing content")
# Copy and delete should skip and return None
result = _copy_and_delete(src, dst)
assert result is None
# Destination should keep its original content
assert dst.read_bytes() == b"existing content"
# Source should be deleted
assert not src.exists()
def test_directory_exists(self, temp_dirs):
"""Test _copy_and_delete when destination directory already exists."""
src = temp_dirs["dir1"] / "test_dir"
dst = temp_dirs["dir2"] / "existing_dir"
# Create source and destination directories
src.mkdir()
(src / "new_file.txt").write_bytes(b"new content")
dst.mkdir()
(dst / "existing_file.txt").write_bytes(b"existing content")
# Copy and delete should skip and return None
result = _copy_and_delete(src, dst)
assert result is None
# Destination should keep its original content
assert (dst / "existing_file.txt").read_bytes() == b"existing content"
# Source should be deleted
assert not src.exists()
class TestRenameAtomicallyCrossFilesystem:
"""Tests for rename_atomically function with cross-filesystem scenarios."""
def test_cross_filesystem(self, temp_dirs):
"""Test rename_atomically handles cross-filesystem via copy+delete."""
src = temp_dirs["dir1"] / "test_file.txt"
dst = temp_dirs["dir2"] / "renamed_file.txt"
# Create source file
src.write_bytes(b"test content")
# Manually patch os.rename at runtime to simulate EXDEV
original_rename = os.rename
def mock_rename(s, d):
raise OSError(errno.EXDEV, "Invalid cross-device link")
os.rename = mock_rename
try:
# rename_atomically should fall back to copy+delete
result = rename_atomically(src, dst)
assert result == dst
assert dst.exists()
assert not src.exists()
assert dst.read_bytes() == b"test content"
finally:
os.rename = original_rename
def test_directory_not_empty(self, temp_dirs):
"""Test rename_atomically when directory exists (errno ENOTEMPTY)."""
src = temp_dirs["dir1"] / "test_file.txt"
dst = temp_dirs["dir2"] / "renamed_file.txt"
# Create source file
src.write_bytes(b"test content")
# Manually patch os.rename at runtime to simulate ENOTEMPTY
original_rename = os.rename
def mock_rename(s, d):
raise OSError(errno.ENOTEMPTY, "Directory not empty")
os.rename = mock_rename
try:
# rename_atomically should return None
result = rename_atomically(src, dst)
assert result is None
finally:
os.rename = original_rename
def test_cross_filesystem_directory(self, temp_dirs):
"""Test rename_atomically handles cross-filesystem for directories."""
src = temp_dirs["dir1"] / "test_dir"
dst = temp_dirs["dir2"] / "renamed_dir"
# Create source directory with files
src.mkdir()
(src / "file1.txt").write_bytes(b"content1")
(src / "subdir").mkdir()
(src / "subdir" / "file2.txt").write_bytes(b"content2")
# Manually patch os.rename at runtime to simulate EXDEV
original_rename = os.rename
def mock_rename(s, d):
raise OSError(errno.EXDEV, "Invalid cross-device link")
os.rename = mock_rename
try:
# rename_atomically should fall back to copy+delete
result = rename_atomically(src, dst)
assert result == dst
assert dst.exists()
assert not src.exists()
assert (dst / "file1.txt").read_bytes() == b"content1"
assert (dst / "subdir" / "file2.txt").read_bytes() == b"content2"
finally:
os.rename = original_rename
+1 -1
View File
@@ -3,7 +3,7 @@ import tempfile
import unittest
from unittest.mock import patch
from buttercup.common.clusterfuzz_utils import get_fuzz_targets, EXTRA_BUILD_DIR
from buttercup.common.clusterfuzz_utils import EXTRA_BUILD_DIR, get_fuzz_targets
class TestGetFuzzTargets(unittest.TestCase):
+2 -1
View File
@@ -1,7 +1,8 @@
import pytest
from redis import Redis
from buttercup.common.maps import RedisMap
from buttercup.common.datastructures.msg_pb2 import WeightedHarness
from buttercup.common.maps import RedisMap
@pytest.fixture
+7 -3
View File
@@ -1,9 +1,13 @@
from unittest.mock import MagicMock, patch
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from buttercup.common.reproduce_multiple import ReproduceMultiple
from buttercup.common.challenge_task import CommandResult, ReproduceResult
from buttercup.common.datastructures.msg_pb2 import BuildOutput
from buttercup.common.challenge_task import ReproduceResult, CommandResult
from buttercup.common.reproduce_multiple import ReproduceMultiple
# ruff: noqa: E501, W291
@pytest.fixture
+60 -19
View File
@@ -1,24 +1,30 @@
from pathlib import Path
import pytest
import os
from unittest.mock import patch, mock_open, MagicMock
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import pytest
from buttercup.common.node_local import (
_get_root_path,
TmpDir,
temp_dir,
rename_atomically,
remote_path as remote_path_func,
remote_archive_path as remote_archive_path_func,
scratch_path,
scratch_dir,
local_scratch_file,
remote_scratch_file,
_get_root_path,
dir_to_remote_archive,
get_corpus_tmpfs_path,
is_corpus_tmpfs_enabled,
local_scratch_file,
lopen,
remote_scratch_file,
rename_atomically,
scratch_dir,
scratch_path,
temp_dir,
)
from buttercup.common.node_local import (
remote_archive_path as remote_archive_path_func,
)
from buttercup.common.node_local import (
remote_path as remote_path_func,
)
# Use this root path for all tests
TEST_ROOT_PATH = Path("/test/node/data/dir")
@@ -217,13 +223,15 @@ class TestNodeLocal:
mock_scratch_context.__enter__.return_value = mock_scratch_file
with patch(
"buttercup.common.node_local.local_scratch_file", return_value=mock_scratch_context
"buttercup.common.node_local.local_scratch_file",
return_value=mock_scratch_context,
) as mock_local_scratch:
# Mock copyfileobj to avoid actual copying
with patch("shutil.copyfileobj") as mock_copy:
# Mock rename_atomically
with patch(
"buttercup.common.node_local.rename_atomically", return_value=local_path
"buttercup.common.node_local.rename_atomically",
return_value=local_path,
) as mock_rename:
# Mock path operations
with patch.object(Path, "mkdir"):
@@ -265,7 +273,8 @@ class TestNodeLocal:
# Mock the archive path
with patch(
"buttercup.common.node_local.remote_archive_path", return_value=remote_archive_path_val
"buttercup.common.node_local.remote_archive_path",
return_value=remote_archive_path_val,
) as mock_rpath:
# Mock file operations
with patch("builtins.open", mock_open(read_data=b"test data")) as mocked_open:
@@ -276,7 +285,8 @@ class TestNodeLocal:
mock_scratch_context.__enter__.return_value = mock_scratch_file
with patch(
"buttercup.common.node_local.local_scratch_file", return_value=mock_scratch_context
"buttercup.common.node_local.local_scratch_file",
return_value=mock_scratch_context,
) as mock_local_scratch:
# Mock copyfileobj to avoid actual copying
with patch("shutil.copyfileobj") as mock_copy:
@@ -294,11 +304,13 @@ class TestNodeLocal:
mock_tmp_dir_context.__enter__.return_value = mock_tmp_dir
with patch(
"buttercup.common.node_local.scratch_dir", return_value=mock_tmp_dir_context
"buttercup.common.node_local.scratch_dir",
return_value=mock_tmp_dir_context,
) as mock_scratch_dir:
# Mock rename_atomically
with patch(
"buttercup.common.node_local.rename_atomically", return_value=local_path
"buttercup.common.node_local.rename_atomically",
return_value=local_path,
) as mock_rename:
# Mock path.exists
with patch.object(Path, "exists", return_value=False):
@@ -491,3 +503,32 @@ class TestNodeLocal:
# Check the return value
assert result == mock_file
class TestTmpfsCorpusConfiguration:
"""Tests for tmpfs corpus configuration functions."""
@patch("buttercup.common.node_local.corpus_tmpfs_path", None)
def test_is_corpus_tmpfs_enabled_false(self):
"""Test is_corpus_tmpfs_enabled when not configured."""
assert is_corpus_tmpfs_enabled() is False
@patch("buttercup.common.node_local.corpus_tmpfs_path", "/tmp/corpus")
def test_is_corpus_tmpfs_enabled_true(self):
"""Test is_corpus_tmpfs_enabled when configured."""
assert is_corpus_tmpfs_enabled() is True
@patch("buttercup.common.node_local.corpus_tmpfs_path", None)
def test_get_corpus_tmpfs_path_not_set(self):
"""Test get_corpus_tmpfs_path when not configured."""
assert get_corpus_tmpfs_path() is None
@patch("buttercup.common.node_local.corpus_tmpfs_path", "/tmp/corpus")
def test_get_corpus_tmpfs_path_set(self):
"""Test get_corpus_tmpfs_path when configured."""
result = get_corpus_tmpfs_path()
assert result == Path("/tmp/corpus")
# Cross-filesystem operation tests are in test_cross_filesystem.py
# to avoid interference with the autouse mock_env_settings fixture.
+11 -9
View File
@@ -1,17 +1,19 @@
import pytest
import time
from redis import Redis
import pytest
from google.protobuf.struct_pb2 import Struct
from redis import Redis
from buttercup.common.datastructures.msg_pb2 import BuildOutput, BuildRequest
from buttercup.common.queues import (
ReliableQueue,
RQItem,
BUILD_OUTPUT_TASK_TIMEOUT_MS,
BUILD_TASK_TIMEOUT_MS,
GroupNames,
QueueFactory,
QueueNames,
GroupNames,
BUILD_TASK_TIMEOUT_MS,
BUILD_OUTPUT_TASK_TIMEOUT_MS,
ReliableQueue,
RQItem,
)
from buttercup.common.datastructures.msg_pb2 import BuildRequest, BuildOutput
GROUP_NAME = "test_group"
QUEUE_NAME = "test_queue"
@@ -210,7 +212,7 @@ def test_queue_factory(redis_client):
sanitizer="test_sanitizer",
task_dir="test_task_dir",
task_id="test_task_id",
)
),
)
item = queue.pop()
+4 -2
View File
@@ -1,7 +1,9 @@
import pytest
import json
import pytest
from redis import Redis
from buttercup.common.sarif_store import SARIFStore, SARIFBroadcastDetail
from buttercup.common.sarif_store import SARIFBroadcastDetail, SARIFStore
@pytest.fixture
+4 -2
View File
@@ -1,8 +1,10 @@
from unittest.mock import MagicMock
import pytest
from redis import Redis
from buttercup.common.sets import RedisSet, PoVReproduceStatus
from buttercup.common.datastructures.msg_pb2 import POVReproduceRequest, POVReproduceResponse
from unittest.mock import MagicMock
from buttercup.common.sets import PoVReproduceStatus, RedisSet
@pytest.fixture
File diff suppressed because one or more lines are too long
+19 -16
View File
@@ -1,11 +1,11 @@
import pytest
from unittest.mock import Mock
from buttercup.common.task_registry import TaskRegistry, CANCELLED_TASKS_SET, SUCCEEDED_TASKS_SET, ERRORED_TASKS_SET
from buttercup.common.datastructures.msg_pb2 import Task, SourceDetail
import time
from typing import Set
from unittest.mock import Mock, patch
import pytest
from redis import Redis
from unittest.mock import patch
from buttercup.common.datastructures.msg_pb2 import SourceDetail, Task
from buttercup.common.task_registry import CANCELLED_TASKS_SET, ERRORED_TASKS_SET, SUCCEEDED_TASKS_SET, TaskRegistry
@pytest.fixture
@@ -166,7 +166,10 @@ def test_iter_tasks_with_different_types(task_registry, redis_client):
# Create and add two different tasks
full_task = Task(task_id="full123", task_type=Task.TaskType.TASK_TYPE_FULL, message_id="msg_full", cancelled=False)
delta_task = Task(
task_id="delta456", task_type=Task.TaskType.TASK_TYPE_DELTA, message_id="msg_delta", cancelled=True
task_id="delta456",
task_type=Task.TaskType.TASK_TYPE_DELTA,
message_id="msg_delta",
cancelled=True,
)
# Setup Redis mock
@@ -319,7 +322,7 @@ def test_is_expired(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "expired-task":
return expired_task.SerializeToString()
elif key == "live-task":
if key == "live-task":
return live_task.SerializeToString()
return None
@@ -432,7 +435,7 @@ def test_get_cancelled_task_ids(task_registry, redis_client):
def test_should_stop_processing_with_cancelled_ids(task_registry, redis_client):
"""Test that should_stop_processing handles cancelled_ids correctly."""
# Create a set of cancelled IDs
cancelled_ids: Set[str] = {"cancelled-task-1", "cancelled-task-2"}
cancelled_ids: set[str] = {"cancelled-task-1", "cancelled-task-2"}
# Create tasks to test with
current_time = int(time.time())
@@ -444,9 +447,9 @@ def test_should_stop_processing_with_cancelled_ids(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "cancelled-task-1":
return cancelled_task1.SerializeToString()
elif key == "cancelled-task-2":
if key == "cancelled-task-2":
return cancelled_task2.SerializeToString()
elif key == "active-task":
if key == "active-task":
return active_task.SerializeToString()
return None
@@ -479,7 +482,7 @@ def test_should_stop_processing_no_cancelled_ids(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "active-task":
return active_task.SerializeToString()
elif key == "cancelled-task":
if key == "cancelled-task":
return cancelled_task.SerializeToString()
return None
@@ -517,7 +520,7 @@ def test_should_stop_processing_expired_task(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "expired-task":
return expired_task.SerializeToString()
elif key == "active-task":
if key == "active-task":
return active_task.SerializeToString()
return None
@@ -659,9 +662,9 @@ def test_is_expired_with_delta(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "just-expired":
return just_expired_task.SerializeToString()
elif key == "soon-expired":
if key == "soon-expired":
return soon_expired_task.SerializeToString()
elif key == "future-task":
if key == "future-task":
return future_task.SerializeToString()
return None
@@ -732,7 +735,7 @@ def test_is_expired_with_delta_edge_cases(task_registry, redis_client):
def mock_hget(hash_name, key):
if key == "one-second-expired":
return one_second_expired.SerializeToString()
elif key == "one-second-future":
if key == "one-second-future":
return one_second_future.SerializeToString()
return None
+879 -1487
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -36,6 +36,8 @@ services:
# - shared-tmp:/tmp
scantron:
profiles:
- competition-server
image: ghcr.io/tob-challenges/example-crs-architecture/competition-test-api:v1.4-rc1
platform: linux/amd64
privileged: true
+2
View File
@@ -6,6 +6,8 @@
version: "3"
x-common: &common
profiles:
- signoz
networks:
- signoz-net
restart: on-failure
+5
View File
@@ -5,6 +5,7 @@ provider "registry.terraform.io/azure/azapi" {
version = "2.2.0"
constraints = "2.2.0"
hashes = [
"h1:Us5LvK2ju2qo3MQlXVtDDKCt5SMFRDIHUL8ubVdCEUg=",
"h1:yckm1jqUMUGSeS57a3uR0gG/V7scwvjpkRVXnQIUAo4=",
"zh:062be5d8272cac297a88c2057449f449ea6906c4121ba3dfdeb5cecb3ff91178",
"zh:1fd9abec3ffcbf8d0244408334e9bfc8f49ada50978cd73ee0ed5f8560987267",
@@ -25,6 +26,7 @@ provider "registry.terraform.io/hashicorp/azurerm" {
version = "4.17.0"
constraints = "4.17.0"
hashes = [
"h1:VgnUh7PiRa/76P+0NFk8vmrmfLnPT6+tOZ/AP6h4TeQ=",
"h1:gpFgaBSkRTxhavgPAuqQcElHJqmRJ1RpQGr1K0dvVW8=",
"zh:163b81a3bf29c8f161a1c100a48164b1bd1af434cd564b44596cb71a6c33f03d",
"zh:2996b107d3c05a9db14458b32b6f22f8cde0adb96263196d82d3dc302907a257",
@@ -46,6 +48,7 @@ provider "registry.terraform.io/hashicorp/helm" {
constraints = "2.17.0"
hashes = [
"h1:K5FEjxvDnxb1JF1kG1xr8J3pNGxoaR3Z0IBG9Csm/Is=",
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
"zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7",
"zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3",
@@ -66,6 +69,7 @@ provider "registry.terraform.io/hashicorp/random" {
constraints = "3.6.3"
hashes = [
"h1:Fnaec9vA8sZ8BXVlN3Xn9Jz3zghSETIKg7ch8oXhxno=",
"h1:zG9uFP8l9u+yGZZvi5Te7PV62j50azpgwPunq2vTm1E=",
"zh:04ceb65210251339f07cd4611885d242cd4d0c7306e86dda9785396807c00451",
"zh:448f56199f3e99ff75d5c0afacae867ee795e4dfda6cb5f8e3b2a72ec3583dd8",
"zh:4b4c11ccfba7319e901df2dac836b1ae8f12185e37249e8d870ee10bb87a13fe",
@@ -86,6 +90,7 @@ provider "registry.terraform.io/hashicorp/time" {
constraints = "0.12.1"
hashes = [
"h1:6BhxSYBJdBBKyuqatOGkuPKVenfx6UmLdiI13Pb3his=",
"h1:JzYsPugN8Fb7C4NlfLoFu7BBPuRVT2/fCOdCaxshveI=",
"zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2",
"zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea",
"zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511",
+125
View File
@@ -26,6 +26,131 @@ The standard `azure` networking profile is used.
- Access credentials to the competition Tailscale tailnet.
- Minikube installed, if you want to test the full CRS locally (linux/amd64 system recommended).
## Local Minikube Deployment
This section covers deploying the CRS locally using minikube on macOS or Linux.
### Local Prerequisites
- **Docker Desktop** (macOS/Windows), **Colima** (macOS/Linux), or Docker Engine (Linux)
- **docker-buildx**: Required for building images (`brew install docker-buildx` on macOS)
- **minikube**: `brew install minikube` (macOS) or see [minikube install docs](https://minikube.sigs.k8s.io/docs/start/)
- **helm**: `brew install helm` (macOS)
- **kubectl**: `brew install kubectl` (macOS)
### Docker Resource Configuration
The Docker runtime must have enough resources allocated for the full CRS deployment.
**Docker Desktop (macOS/Windows):**
1. Open Docker Desktop > Settings > Resources
2. Allocate at least:
- CPUs: 4-6 (depending on your machine)
- Memory: 8-12 GB (10GB recommended for full deployment)
- Disk: 80+ GB
3. Click "Apply & Restart"
**Colima (macOS/Linux):**
```bash
colima start --cpu 6 --memory 10 --disk 80
```
After starting Colima, ensure the buildx plugin is linked:
```bash
brew install docker-buildx
```
`docker-buildx` is a Docker plugin. For Docker to find the plugin, add "cliPluginsExtraDirs" to ~/.docker/config.json:
```json
"cliPluginsExtraDirs": [
"/opt/homebrew/lib/docker/cli-plugins"
]
```
**Important**: Minikube cannot use more resources than the Docker runtime allocates. If `env.template` specifies `MINIKUBE_CPU=6` and `MINIKUBE_MEMORY_GB=10`, but Docker only has 4 CPUs and 8GB RAM, minikube will fail to start or pods will remain Pending.
### Quick Start
```bash
cd deployment
cp env.template env
# Edit env to configure:
# - MINIKUBE_CPU/MINIKUBE_MEMORY_GB (match your Docker Desktop resources)
# - OPENAI_API_KEY and/or ANTHROPIC_API_KEY
# - Comment out GHCR_AUTH if building locally
make up
```
### Common Errors and Solutions
#### "Exiting due to RSRC_INSUFFICIENT_CORES" or memory errors
**Cause**: Minikube is requesting more resources than Docker Desktop has available.
**Solution**: Either increase Docker Desktop resources or reduce minikube resources in `env`:
```bash
export MINIKUBE_CPU=4
export MINIKUBE_MEMORY_GB=7
```
#### Pods stuck in Pending state
**Cause**: Kubernetes cannot schedule pods due to insufficient CPU or memory.
**Solution**: This is expected with limited resources. Check which pods are pending:
```bash
kubectl get pods -n crs | grep Pending
kubectl describe pod <pod-name> -n crs | grep -A5 Events
```
For local development, core services (redis, task-server, scheduler) should run. Fuzzer bots and multiple replicas may remain Pending.
#### Base64 decode errors with GHCR_AUTH
**Cause**: `GHCR_AUTH` is set to the placeholder value `<your-ghcr-base64-auth>`.
**Solution**: Either set a valid base64-encoded GitHub token or comment out the line:
```bash
# export GHCR_AUTH="<your-ghcr-base64-auth>"
```
For local builds, GHCR authentication is not required.
### Resource Expectations
With 8GB RAM allocated to Docker Desktop:
| Resource Setting | Expected Result |
|-----------------|-----------------|
| MINIKUBE_MEMORY_GB=10 | Minikube fails to start |
| MINIKUBE_MEMORY_GB=7 | Minikube starts, some pods Pending |
| MINIKUBE_MEMORY_GB=6 | More pods Pending, core services run |
### macOS ARM64 (Apple Silicon) Notes
- Minikube uses QEMU emulation for amd64 images on ARM64 Macs
- Some components may run slower due to emulation overhead
- The `gcr.io/oss-fuzz-base/base-runner` image is amd64-only
- For best performance, use a linux/amd64 machine or cloud VM
### Verifying the Deployment
```bash
# Check all pods
kubectl get pods -n crs
# Check core services are running
kubectl get pods -n crs | grep -E "(redis|task-server|scheduler)"
# View logs for a specific service
kubectl logs -n crs -l app=scheduler --tail=50
# Port forward to access the API locally
kubectl port-forward -n crs service/buttercup-competition-api 31323:1323
```
### Azure
#### Login to Azure
+1
View File
@@ -9,6 +9,7 @@
- Microsoft.Compute
- Microsoft.Storage
- Microsoft.Network
- Microsoft.ContainerService
2. Verify and adjust Azure quotas:
- Total Regional vCPUs: ~2000
+2 -2
View File
@@ -4,7 +4,7 @@
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RUN_DIR="run_data_${TIMESTAMP}"
mkdir -p "${RUN_DIR}"
cd "${RUN_DIR}"
cd "${RUN_DIR}" || exit 1
echo "Collecting all data post-run"
# Get cluster info
@@ -21,7 +21,7 @@ echo -e "\nPods in crs namespace:"
kubectl get pods -n crs
# Ask for confirmation
read -p "Is this the correct cluster to collect run data from? (y/N) " confirm
read -r -p "Is this the correct cluster to collect run data from? (y/N) " confirm
if [[ $confirm != [yY] ]]; then
echo "Aborting data collection"
exit 1
+1 -1
View File
@@ -112,7 +112,7 @@ export -f process_pod
running_jobs=0
for pod in $pods; do
# Wait if we've reached the maximum number of parallel jobs
while [ $running_jobs -ge $MAX_PARALLEL ]; do
while [ "$running_jobs" -ge "$MAX_PARALLEL" ]; do
# Wait for any background job to complete
wait -n
running_jobs=$((running_jobs - 1))
+44 -5
View File
@@ -28,6 +28,7 @@ else
fi
if [ "$TAILSCALE_ENABLED" = "true" ]; then
envsubst <k8s/base/tailscale-operator/operator.template >k8s/base/tailscale-operator/operator.yaml
envsubst <k8s/base/tailscale-connections/proxies.template >k8s/base/tailscale-connections/proxies.yaml
fi
if [ "$(echo "$LANGFUSE_ENABLED" | tr '[:upper:]' '[:lower:]')" = "true" ]; then
LANGFUSE_ENABLED="true"
@@ -37,6 +38,7 @@ fi
BUTTERCUP_NAMESPACE=${BUTTERCUP_NAMESPACE:-crs}
DEPLOY_CLUSTER=${DEPLOY_CLUSTER:-true}
DEPLOY_SIGNOZ=${DEPLOY_SIGNOZ:-false}
CLUSTER_TYPE=${CLUSTER_TYPE:-minikube}
if [ "$DEPLOY_CLUSTER" = "true" ] && [ "$CLUSTER_TYPE" = "aks" ]; then
@@ -87,12 +89,32 @@ up() {
;;
*)
echo -e "${BLU}Deploying minikube cluster${NC}"
minikube status | grep -q "kubelet: Running" || minikube start --force --extra-config=kubeadm.skip-phases=preflight --cpus=8 --memory=32g --disk-size=80g --driver=docker --kubernetes-version=stable
# Minikube defaults
MINIKUBE_CPU="${MINIKUBE_CPU:-6}"
MINIKUBE_MEMORY_GB="${MINIKUBE_MEMORY_GB:-10}"
MINIKUBE_DISK_GB="${MINIKUBE_DISK_GB:-80}"
echo -e "${BLU}Using Minikube configuration: CPU=${MINIKUBE_CPU}, Memory=${MINIKUBE_MEMORY_GB}GB, Disk=${MINIKUBE_DISK_GB}GB${NC}"
minikube status | grep -q "kubelet: Running" || minikube start \
--force \
--extra-config=kubeadm.skip-phases=preflight \
--cpus="${MINIKUBE_CPU}" \
--memory="${MINIKUBE_MEMORY_GB}g" \
--disk-size="${MINIKUBE_DISK_GB}g" \
--driver=docker \
--kubernetes-version=stable
echo -e "${GRN}Minikube cluster status:${NC}"
minikube status
# Resize /dev/shm inside minikube for corpus tmpfs storage.
# Minikube docker driver defaults to 64MB, which is too small for fuzzing corpus data
if [ -n "${MINIKUBE_SHM_SIZE_GB:-}" ]; then
echo -e "${BLU}Resizing minikube /dev/shm to ${MINIKUBE_SHM_SIZE_GB}G${NC}"
docker exec minikube mount -o remount,size="${MINIKUBE_SHM_SIZE_GB}G" /dev/shm
fi
echo -e "${BLU}Building local docker images${NC}"
eval $(minikube docker-env)
eval "$(minikube docker-env --shell bash)"
# Authenticate with GitHub Container Registry for Docker builds
if [ -n "$GHCR_AUTH" ]; then
@@ -114,15 +136,25 @@ up() {
fi
if [ -n "$FUZZER_BASE_IMAGE" ]; then
# Check for aarch64 and append :manifest-arm64v8 to base image if needed
if { [ "$(uname -m)" = "aarch64" ] || [ "$(uname -m)" = "arm64" ]; } && [ "$FUZZER_BASE_IMAGE" = "gcr.io/oss-fuzz-base/base-runner" ]; then
FUZZER_BASE_IMAGE="${FUZZER_BASE_IMAGE}:manifest-arm64v8"
fi
FUZZER_BUILD_ARGS="--build-arg BASE_IMAGE=$FUZZER_BASE_IMAGE"
else
FUZZER_BUILD_ARGS=""
fi
# shellcheck disable=SC2086 # Intentionally unquoted for word splitting
docker build $ORCHESTRATOR_BUILD_ARGS -f "$SCRIPT_DIR"/../orchestrator/Dockerfile -t localhost/orchestrator:latest "$SCRIPT_DIR"/..
docker build $FUZZER_BUILD_ARGS -f "$SCRIPT_DIR"/../fuzzer/dockerfiles/runner_image.Dockerfile -t localhost/fuzzer:latest "$SCRIPT_DIR"/..
# shellcheck disable=SC2086 # Intentionally unquoted for word splitting
docker build $FUZZER_BUILD_ARGS -f "$SCRIPT_DIR"/../fuzzer/Dockerfile -t localhost/fuzzer:latest "$SCRIPT_DIR"/..
# shellcheck disable=SC2086 # Intentionally unquoted for word splitting
docker build $SEED_GEN_BUILD_ARGS -f "$SCRIPT_DIR"/../seed-gen/Dockerfile -t localhost/seed-gen:latest "$SCRIPT_DIR"/..
# shellcheck disable=SC2086 # Intentionally unquoted for word splitting
docker build $PATCHER_BUILD_ARGS -f "$SCRIPT_DIR"/../patcher/Dockerfile -t localhost/patcher:latest "$SCRIPT_DIR"/..
# shellcheck disable=SC2086 # Intentionally unquoted for word splitting
docker build $PROGRAM_MODEL_BUILD_ARGS -f "$SCRIPT_DIR"/../program-model/Dockerfile -t localhost/program-model:latest "$SCRIPT_DIR"/..
;;
esac
@@ -141,7 +173,7 @@ up() {
--from-literal=scantron_github_pat="$SCANTRON_GITHUB_PAT" || echo -e "${GRN}ghcr secret already exists${NC}"
echo -e "${BLU}Creating CRS_INSTANCE_ID${NC}"
CRS_INSTANCE_ID=$(echo $RANDOM | md5sum | head -c 20)
CRS_INSTANCE_ID=$(echo "$RANDOM" | md5sum | head -c 20)
kubectl create configmap crs-instance-id \
--namespace "$BUTTERCUP_NAMESPACE" \
--from-literal=crs-instance-id="$CRS_INSTANCE_ID" || echo -e "${GRN}crs-instance-id configmap already exists${NC}"
@@ -202,7 +234,7 @@ fi
if [ "$TAILSCALE_ENABLED" = "true" ]; then
kubectl apply -k k8s/base/tailscale-connections/
echo -e "${BLU}Waiting for ingress hostname DNS registration${NC}"
timeout 5m bash -c "until kubectl get ingress -n "$BUTTERCUP_NAMESPACE" buttercup-task-server -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' | grep -q '.'; do sleep 1; done" || echo -e "${BLU}Error: Ingress hostname failed to be to set within 5 minutes${NC}"
timeout 5m bash -c 'until kubectl get ingress -n '"$BUTTERCUP_NAMESPACE"' buttercup-task-server -o jsonpath='"'"'{.status.loadBalancer.ingress[0].hostname}'"'"' | grep -q "."; do sleep 1; done' || echo -e "${BLU}Error: Ingress hostname failed to be to set within 5 minutes${NC}"
INGRESS_HOSTNAME=$(kubectl get ingress -n "$BUTTERCUP_NAMESPACE" buttercup-task-server -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo -e "${GRN}Your ingress DNS hostname is $INGRESS_HOSTNAME${NC}"
fi
@@ -247,13 +279,20 @@ down-k8s() {
esac
fi
echo -e "${BLU}Deleting Kubernetes resource${NC}"
set -x
kubectl delete -k k8s/base/tailscale-connections/
helm uninstall --wait --namespace "$BUTTERCUP_NAMESPACE" buttercup
# Remove finalizers from clickhouse installation as stated in https://signoz.io/docs/operate/kubernetes/#uninstall-signoz
# Without this, the namespace would not be deleted
kubectl -n "$BUTTERCUP_NAMESPACE" patch clickhouseinstallations.clickhouse.altinity.com/buttercup-clickhouse -p '{"metadata":{"finalizers":[]}}' --type=merge
kubectl delete -k k8s/base/tailscale-coredns/
kubectl delete -k k8s/base/tailscale-dns/
kubectl delete -k k8s/base/tailscale-operator/
kubectl delete secret ghcr --namespace "$BUTTERCUP_NAMESPACE"
kubectl delete namespace "$BUTTERCUP_NAMESPACE"
set +x
echo -e "${GRN}Cleanup complete.${NC}"
echo -e "${BLU}Note: If you plan to redeploy and access SigNoz, clear your browser cookies for http://localhost:33301 to avoid login issues.${NC}"
set -e
}
+25 -2
View File
@@ -1,6 +1,7 @@
# Reference a values file for kubernetes deployment. This should be a template
# file, filled by crs-architecture.sh with the correct variables.
# See values-upstream-minikube.template, values-minikube.template, values-aks.template, values-prod.template
# For a large machine, see values-upstream-minikube-16cpu-128gb.template
export BUTTERCUP_K8S_VALUES_TEMPLATE="k8s/values-upstream-minikube.template"
# Namespace used to install the whole CRS in.
@@ -22,12 +23,14 @@ export CLUSTER_TYPE=minikube # or "aks"
# export TF_VAR_ARM_SUBSCRIPTION_ID="<your-sub-id>"
# export TF_VAR_usr_node_count=50
# export TF_VAR_resource_group_name_prefix="<resource-prefix>"
# export TF_VAR_resource_group_name="<existing-resource-group-name>"
# TailScale variables, necessary only for production/staging deployments
export TAILSCALE_ENABLED=false # or true
# export TS_CLIENT_ID="<your-tailscale-oauth-client-id>"
# export TS_CLIENT_SECRET="<your-tailscale-oauth-client-secret>"
# export TS_OP_TAG="<your-tailscale-operator-tag>"
# export TAILSCALE_DOMAIN="tail123456.ts.net"
# AIxCC - Competition API settings, used to properly connect to the right endpoint and
# with the right authentication. If COMPETITION_API_ENABLED is true, a test API
@@ -55,14 +58,18 @@ export CRS_KEY_TOKEN_HASH='$argon2id$v=19$m=65536,t=3,p=4$Dg1v6NPGTyXPoOPF4ozD5A
# GitHub Container Registry auth, base64 encoded
# echo "USERNAME:$GHCR_PAT" | base64 --wrap=0
# The PAT should have at least package:read permissions
export GHCR_AUTH="<your-ghcr-base64-auth>"
# NOTE: Comment out for local builds - images will be built locally without GHCR access.
# Only needed when pulling pre-built images from GHCR.
# export GHCR_AUTH="<your-ghcr-base64-auth>"
# LiteLLM/LLMs settings
export LITELLM_MASTER_KEY="d5179c62ae1c7366e3ee09775d0993d5" # Generate with: openssl rand -hex 16
export LITELLM_MAX_BUDGET="100"
export AZURE_API_BASE="<disabled>"
export AZURE_API_KEY="<disabled>"
export OPENAI_API_KEY="<your-openai-api-key>"
export ANTHROPIC_API_KEY="<your-anthropic-api-key>"
# export GEMINI_API_KEY="<your-gemini-api-key>"
# LangFuse settings, instructing LLM-applications to log their LLM traces
# export LANGFUSE_ENABLED=true
@@ -71,8 +78,11 @@ export ANTHROPIC_API_KEY="<your-anthropic-api-key>"
# export LANGFUSE_SECRET_KEY="<your-langfuse-secret-key>"
# OpenTelemetry endpoint settings (e.g. SigNoz)
export DEPLOY_SIGNOZ=false
# Your otel endpoint
# export OTEL_ENDPOINT="<your-otel-endpoint>"
# export OTEL_TOKEN="<your-otel-http-token>"
# Your otel token, including basic or bearer
# export OTEL_TOKEN="dXNlcm5hbWU6cGFzc3dvcmQ="
# export OTEL_PROTOCOL=grpc
# Docker Hub credentials for doing logged requests while getting Container
@@ -82,3 +92,16 @@ export ANTHROPIC_API_KEY="<your-anthropic-api-key>"
# Docker build arguments, useful for local deployment
export FUZZER_BASE_IMAGE="gcr.io/oss-fuzz-base/base-runner"
# Minikube cluster size
# NOTE: Docker Desktop typically limits resources. Check Docker Desktop > Settings > Resources.
# For macOS Docker Desktop with 8GB RAM allocated to Docker:
# - Use MINIKUBE_CPU=4 and MINIKUBE_MEMORY_GB=7
# - Some pods may remain Pending due to resource constraints (this is expected)
# For larger systems or dedicated Linux machines with 16GB+ RAM:
# - Use MINIKUBE_CPU=6 and MINIKUBE_MEMORY_GB=10
export MINIKUBE_CPU=6
export MINIKUBE_MEMORY_GB=10
export MINIKUBE_DISK_GB=80
# Increase /dev/shm size if corpus tmpfs is enabled
# export MINIKUBE_SHM_SIZE_GB=60
+5
View File
@@ -1,3 +1,8 @@
charts/dind-daemon-*.tgz
charts/pov-reproducer-*.tgz
charts/registry-cache-*.tgz
charts/scratch-cleaner-*.tgz
charts/ui-*.tgz
charts/build-bot-*.tgz
charts/competition-api-*.tgz
charts/coverage-bot-*.tgz
+21 -3
View File
@@ -2,9 +2,12 @@ dependencies:
- name: redis
repository: https://charts.bitnami.com/bitnami
version: 17.17.1
- name: dind-daemon
repository: file://charts/dind-daemon
version: 0.1.0
- name: litellm-helm
repository: oci://ghcr.io/berriai
version: 0.1.615
version: 0.1.783
- name: task-server
repository: file://charts/task-server
version: 0.0.1
@@ -38,5 +41,20 @@ dependencies:
- name: competition-api
repository: file://charts/competition-api
version: 0.0.1
digest: sha256:8ac9c249917d32604f6d59d65bc74e7a7a7eb46b7c71fbb982bd7b0dd038395a
generated: "2025-03-26T13:45:05.979619483Z"
- name: registry-cache
repository: file://charts/registry-cache
version: 0.1.0
- name: pov-reproducer
repository: file://charts/pov-reproducer
version: 0.1.0
- name: scratch-cleaner
repository: file://charts/scratch-cleaner
version: 0.0.1
- name: ui
repository: file://charts/ui
version: 0.1.0
- name: signoz
repository: https://charts.signoz.io
version: 0.71.0
digest: sha256:d406395eef32321d26f3dee82bd5ab91e80f476dc8f271bac7edbcd04790cc34
generated: "2025-09-29T12:14:43.37270009Z"
+6 -2
View File
@@ -11,7 +11,7 @@ dependencies:
version: "*"
repository: "file://charts/dind-daemon"
- name: litellm-helm
version: "0.1.615"
version: "0.1.783"
repository: "oci://ghcr.io/berriai"
- name: task-server
version: "0.0.1"
@@ -56,5 +56,9 @@ dependencies:
version: "0.0.1"
repository: "file://charts/scratch-cleaner"
- name: ui
version: "0.0.1"
version: "0.1.0"
repository: "file://charts/ui"
- name: signoz
version: "0.71.0"
repository: "https://charts.signoz.io"
condition: signoz.enabled
+5 -1
View File
@@ -8,7 +8,7 @@ This guide explains how to set up and run the Buttercup system on Kubernetes.
- kubectl configured to communicate with your cluster
- Helm v3
- Access to container registries (ghcr.io)
- Access to required API keys (OpenAI, Azure, Anthropic)
- Access to required API keys (OpenAI, Azure, Anthropic, Gemini)
## Environment Configuration
@@ -43,6 +43,8 @@ This is controlled via the `global.environment` setting in the values.yaml or va
apiKey: "your-openai-api-key"
anthropic:
apiKey: "your-anthropic-api-key"
gemini:
apiKey: "your-google-gemini-key"
crs:
api_key_id: 515cc8a0-3019-4c9f-8c1c-72d0b54ae561
@@ -77,6 +79,7 @@ This secret contains API keys for LLM providers:
- **AZURE_API_KEY**: Azure OpenAI API key
- **OPENAI_API_KEY**: OpenAI API key
- **ANTHROPIC_API_KEY**: Anthropic API key
- **GEMINI_API_KEY**: Google Gemini API key
These are populated from the `values-override.yaml` file.
@@ -197,6 +200,7 @@ The system uses LiteLLM to proxy all LLM requests. The configuration includes mo
- Azure OpenAI
- OpenAI
- Anthropic
- Google Gemini
To modify the available models, edit the `litellm-helm.proxy_config.model_list` section in values.yaml.
@@ -5,7 +5,7 @@ metadata:
name: competition-echo-egress
namespace: tailscale
annotations:
tailscale.com/tailnet-fqdn: "echo.tail7e9b4c.ts.net" #Basic http echo test server
tailscale.com/tailnet-fqdn: "echo.${TAILSCALE_DOMAIN}" #Basic http echo test server
spec:
externalName: placeholder
type: ExternalName
@@ -16,7 +16,8 @@ metadata:
name: competition-api-1-egress
namespace: tailscale
annotations:
tailscale.com/tailnet-fqdn: "api.tail7e9b4c.ts.net" #Competition API FQDN
tailscale.com/tailnet-fqdn: "api.${TAILSCALE_DOMAIN}" #Competition API FQDN
spec:
externalName: placeholder
type: ExternalName
@@ -5,7 +5,7 @@ metadata:
namespace: kube-system
data:
tailscale.server: |
tail7e9b4c.ts.net:53 {
${TAILSCALE_DOMAIN}:53 {
forward . ${TS_DNS_IP}
cache 30
}
@@ -8,15 +8,6 @@ resources:
cpu: 250m
memory: 256Mi
dind:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
replicaCount: 4
timer: 5000
logLevel: "DEBUG"
@@ -13,15 +13,5 @@ resources:
cpu: 4000m
memory: 8Gi
dind:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 4000m
memory: 8Gi
nodeSelector: {}
tolerations: []
affinity: {}
@@ -30,16 +30,17 @@ spec:
{{- include "buttercup.standardVolumeMounts" (dict "usesTasksStorage" true) | nindent 8 }}
{{- include "buttercup.nodeLocalVolumeMount" . | nindent 8 }}
{{- include "buttercup.dockerSocketVolumeMount" . | nindent 8 }}
{{- include "buttercup.corpusTmpfsVolumeMount" . | nindent 8 }}
env:
- name: BUTTERCUP_FUZZER_SAMPLE_SIZE
value: "{{ .Values.global.coverageBot.sampleSize }}"
{{- include "buttercup.commonEnv" . | nindent 8 }}
{{- include "buttercup.env.nodeData" . | nindent 8 }}
{{- include "buttercup.env.dockerSocket" . | nindent 8 }}
{{- include "buttercup.env.telemetry" . | nindent 8 }}
{{- include "buttercup.env.corpusTmpfs" . | nindent 8 }}
volumes:
{{- include "buttercup.volumes.scratch" . | nindent 6 }}
{{- include "buttercup.volumes.tasks" . | nindent 6 }}
{{- include "buttercup.dockerSocketVolume" . | nindent 6 }}
{{- include "buttercup.nodeLocalVolume" . | nindent 6 }}
{{- include "buttercup.corpusTmpfsVolume" . | nindent 6 }}
{{- end }}
@@ -22,7 +22,7 @@ spec:
- "ghcr.io"
containers:
- name: dind
image: "docker:24.0.6-dind"
image: "docker:29.0.0-dind"
securityContext:
privileged: true
resources:
@@ -72,4 +72,4 @@ spec:
secret:
secretName: registry-cache-tls
{{- include "buttercup.dockerSocketVolume" . | nindent 8 }}
{{- end }}
{{- end }}
@@ -30,17 +30,18 @@ spec:
periodSeconds: {{ .Values.healthCheck.periodSeconds | default 300 }}
timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds | default 10 }}
failureThreshold: {{ .Values.healthCheck.failureThreshold | default 2 }}
env:
env:
{{- include "buttercup.commonEnv" . | nindent 8 }}
{{- include "buttercup.env.nodeData" . | nindent 8 }}
{{- include "buttercup.fuzzerBotEnv" . | nindent 8 }}
{{- include "buttercup.env.telemetry" . | nindent 8 }}
{{- include "buttercup.env.corpusTmpfs" . | nindent 8 }}
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumeMounts:
{{- include "buttercup.standardVolumeMounts" (dict "usesTasksStorage" false) | nindent 8 }}
{{- include "buttercup.nodeLocalVolumeMount" . | nindent 8 }}
{{- include "buttercup.corpusTmpfsVolumeMount" . | nindent 8 }}
volumes:
{{- include "buttercup.volumes.scratch" . | nindent 6 }}
{{- include "buttercup.nodeLocalVolume" . | nindent 6 }}
{{- include "buttercup.corpusTmpfsVolume" . | nindent 6 }}
{{- end }}
Binary file not shown.
Binary file not shown.
@@ -21,16 +21,19 @@ spec:
- name: merger-bot
image: "{{ .Values.global.fuzzerImage.repository }}:{{ .Values.global.fuzzerImage.tag }}"
imagePullPolicy: {{ .Values.global.fuzzerImage.pullPolicy }}
command: ["buttercup-corpus-merger", "--crs_scratch_dir", "{{ include "buttercup.nodeLocalCrsScratchPath" . }}", "--redis_url", "{{ include "buttercup.core.redisUrl" . }}", "--timer", "{{ .Values.timer }}", "--timeout", "{{ .Values.timeout }}", "--crash_dir_count_limit", "{{ include "buttercup.core.crashDirCountLimit" . }}", "--max_local_files", "{{ .Values.max_local_files }}"]
env:
command: ["buttercup-corpus-merger", "--crs_scratch_dir", "{{ include "buttercup.nodeLocalCrsScratchPath" . }}", "--redis_url", "{{ include "buttercup.core.redisUrl" . }}", "--timer", "{{ .Values.timer }}", "--timeout", "{{ .Values.timeout }}", "--crash_dir_count_limit", "{{ include "buttercup.core.crashDirCountLimit" . }}", "--max_local_files", "{{ .Values.max_local_files }}", "--runner_path", "/app/fuzzer_runner/runner.sh"]
env:
{{- include "buttercup.env.nodeData" . | nindent 8 }}
{{- include "buttercup.env.telemetry" . | nindent 8 }}
{{- include "buttercup.env.corpusTmpfs" . | nindent 8 }}
resources:
{{- toYaml .Values.resources | nindent 10 }}
volumeMounts:
{{- include "buttercup.standardVolumeMounts" (dict "usesTasksStorage" false) | nindent 8 }}
{{- include "buttercup.nodeLocalVolumeMount" . | nindent 8 }}
{{- include "buttercup.corpusTmpfsVolumeMount" . | nindent 8 }}
volumes:
{{- include "buttercup.volumes.scratch" . | nindent 6 }}
{{- include "buttercup.nodeLocalVolume" . | nindent 6 }}
{{- include "buttercup.corpusTmpfsVolume" . | nindent 6 }}
{{- end }}
@@ -38,6 +38,7 @@ spec:
{{- include "buttercup.patcherEnv" . | nindent 8 }}
{{- include "buttercup.env.dockerSocket" . | nindent 8 }}
{{- include "buttercup.env.langfuse" . | nindent 8 }}
{{- include "buttercup.env.llm" (merge (dict "secretName" "litellm-api-user" "secretKey" "API_KEY") .) | nindent 8 }}
- name: LOG_LEVEL
value: "{{ .Values.logLevel }}"
volumes:
@@ -45,4 +46,5 @@ spec:
{{- include "buttercup.volumes.tasks" . | nindent 6 }}
{{- include "buttercup.dockerSocketVolume" . | nindent 6 }}
{{- include "buttercup.nodeLocalVolume" . | nindent 6 }}
{{- include "buttercup.apiKeySecretVolume" (dict "secretName" "litellm-api-user") | nindent 6 }}
{{- end }}
@@ -8,14 +8,5 @@ resources:
cpu: 250m
memory: 256Mi
dind:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
logLevel: debug
sleepTime: 5
@@ -56,5 +56,5 @@ resources:
cpu: 2000m
memory: 2048Mi
requests:
cpu: 1000m
memory: 1024Mi
cpu: 250m
memory: 512Mi
@@ -39,13 +39,18 @@ spec:
{{- include "buttercup.standardVolumeMounts" (dict "usesTasksStorage" false) | nindent 8 }}
{{- include "buttercup.nodeLocalVolumeMount" . | nindent 8 }}
{{- include "buttercup.dockerSocketVolumeMount" . | nindent 8 }}
{{- include "buttercup.corpusTmpfsVolumeMount" . | nindent 8 }}
env:
{{- include "buttercup.commonEnv" . | nindent 8 }}
{{- include "buttercup.env.dockerSocket" . | nindent 8 }}
{{- include "buttercup.env.langfuse" . | nindent 8 }}
{{- include "buttercup.seedGenEnv" . | nindent 8 }}
{{- include "buttercup.env.llm" (merge (dict "secretName" "litellm-api-user" "secretKey" "API_KEY") .) | nindent 8 }}
{{- include "buttercup.env.corpusTmpfs" . | nindent 8 }}
volumes:
{{- include "buttercup.volumes.scratch" . | nindent 6 }}
{{- include "buttercup.dockerSocketVolume" . | nindent 6 }}
{{- include "buttercup.nodeLocalVolume" . | nindent 6 }}
{{- include "buttercup.apiKeySecretVolume" (dict "secretName" "litellm-api-user") | nindent 6 }}
{{- include "buttercup.corpusTmpfsVolume" . | nindent 6 }}
{{- end }}
@@ -9,15 +9,6 @@ resources:
cpu: 200m
memory: 256Mi
dind:
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
# Health check configuration
healthCheck:
# Maximum time (in seconds) the health file can be stale before failing
Binary file not shown.
@@ -40,8 +40,6 @@ spec:
env:
{{- include "buttercup.commonEnv" . | nindent 8 }}
{{- include "buttercup.env.dockerSocket" . | nindent 8 }}
{{- include "buttercup.env.nodeData" . | nindent 8 }}
{{- include "buttercup.env.telemetry" . | nindent 8 }}
volumes:
{{- include "buttercup.volumes.scratch" . | nindent 6 }}
{{- include "buttercup.volumes.tasks" . | nindent 6 }}
@@ -16,6 +16,8 @@ spec:
app: ui
spec:
{{- include "buttercup.imagePullSecrets" . | nindent 6 }}
initContainers:
{{- include "buttercup.waitForDocker" . | nindent 8 }}
containers:
- name: ui
image: "{{ .Values.global.orchestratorImage.repository }}:{{ .Values.global.orchestratorImage.tag }}"
@@ -27,11 +29,14 @@ spec:
env:
{{- include "buttercup.uiEnv" . | nindent 8 }}
{{- include "buttercup.env.telemetry" . | nindent 8 }}
{{- include "buttercup.env.dockerSocket" . | nindent 8 }}
volumeMounts:
- name: data-volume
mountPath: /data
mountPath: /data/data-volume
- name: run-data-volume
mountPath: /tmp/buttercup-run-data
{{- include "buttercup.volumeMounts.ui_db" . | nindent 10 }}
{{- include "buttercup.dockerSocketVolumeMount" . | nindent 10 }}
command: ["buttercup-ui"]
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -59,4 +64,6 @@ spec:
- name: run-data-volume
emptyDir: {}
{{- end }}
{{- include "buttercup.volumes.ui_db" . | nindent 8 }}
{{- include "buttercup.dockerSocketVolume" . | nindent 8 }}
{{- end }}
@@ -0,0 +1,23 @@
---
kind: Ingress
apiVersion: networking.k8s.io/v1
metadata:
name: {{ .Release.Name }}-ui
annotations:
tailscale.com/hostname: "{{ default (printf "%s-ui" .Release.Name) }}"
tailscale.com/experimental-forward-cluster-traffic-via-ingress: "true"
spec:
ingressClassName: tailscale
tls:
- hosts:
- "{{ default (printf "%s-ui" .Release.Name) }}"
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-ui
port:
number: {{ .Values.service.port }}
+62 -2
View File
@@ -5,6 +5,10 @@ Define constants for directories that are used across multiple services
/crs_scratch
{{- end -}}
{{- define "buttercup.dirs.ui_db_storage" -}}
/ui_db_storage
{{- end -}}
{{/*
Define imagePullSecrets for pod specs - using ghcr-auth to match Terraform
*/}}
@@ -28,12 +32,28 @@ Define the Redis init container template that can be included in multiple deploy
{{- end -}}
{{/*
Define the LiteLLM health check init container template
Define the LiteLLM health check init container template with secret passed via volume
*/}}
{{- define "buttercup.waitForLiteLLM" -}}
- name: wait-for-litellm
image: curlimages/curl:8.6.0
command: ['sh', '-c', 'until curl --silent -f http://{{ .Release.Name }}-litellm:4000/health/readiness; do echo waiting for litellm; sleep 2; done;']
command:
- sh
- -c
- |
for i in $(seq 1 60); do
if curl --silent -f -H "Authorization: Bearer $(cat /etc/secrets/API_KEY)" -X POST http://{{ .Release.Name }}-litellm:4000/key/health; then
exit 0
fi
echo "waiting for litellm and key to be ready..."
sleep 2
done
echo "litellm or key not ready after 120s"
exit 1
volumeMounts:
- name: api-key-secret
mountPath: /etc/secrets
readOnly: true
{{- end -}}
{{/*
@@ -187,3 +207,43 @@ Define a wait-for-docker init container that checks if the Docker socket is avai
volumeMounts:
{{- include "buttercup.dockerSocketVolumeMount" . | nindent 2 }}
{{- end -}}
{{/*
Define api-key-secret volume with configurable secret name
Usage: {{- include "buttercup.apiKeySecretVolume" (dict "secretName" "litellm-api-user") | nindent 8 }}
*/}}
{{- define "buttercup.apiKeySecretVolume" -}}
- name: api-key-secret
secret:
secretName: {{ .secretName }}
{{- end -}}
{{/*
Corpus Tmpfs Volume Mount Helper
Conditionally adds the volume mount for tmpfs corpus storage if enabled globally.
Expects the root context '.' to be passed.
Usage: {{- include "buttercup.corpusTmpfsVolumeMount" . | nindent 10 }}
*/}}
{{- define "buttercup.corpusTmpfsVolumeMount" -}}
{{- if .Values.global.volumes.corpusTmpfs.enabled }}
- name: corpus-tmpfs
mountPath: {{ .Values.global.volumes.corpusTmpfs.mountPath }}
{{- end }}
{{- end -}}
{{/*
Corpus Tmpfs Volume Definition Helper
Conditionally adds the volume definition for tmpfs corpus storage if enabled globally.
Uses hostPath to mount a node-level tmpfs (shared across all pods on the node).
PREREQUISITE: The hostPath must be a tmpfs mount on the node (e.g., /dev/shm or custom tmpfs).
Expects the root context '.' to be passed.
Usage: {{- include "buttercup.corpusTmpfsVolume" . | nindent 8 }}
*/}}
{{- define "buttercup.corpusTmpfsVolume" -}}
{{- if .Values.global.volumes.corpusTmpfs.enabled }}
- name: corpus-tmpfs
hostPath:
path: {{ .Values.global.volumes.corpusTmpfs.hostPath }}
type: {{ .Values.global.volumes.corpusTmpfs.type }}
{{- end }}
{{- end -}}
+12 -1
View File
@@ -14,6 +14,12 @@ Define reusable volume specifications for our persistent volumes
claimName: {{ .Release.Name }}-tasks-storage
{{- end -}}
{{- define "buttercup.volumes.ui_db" -}}
- name: ui-db-storage
persistentVolumeClaim:
claimName: {{ .Release.Name }}-ui-db
{{- end -}}
{{/*
Define reusable volume mount specifications for our persistent volumes
*/}}
@@ -26,4 +32,9 @@ Define reusable volume mount specifications for our persistent volumes
{{- define "buttercup.volumeMounts.tasks" -}}
- name: tasks-storage
mountPath: {{ include "buttercup.dirs.tasks_storage" . }}
{{- end -}}
{{- end -}}
{{- define "buttercup.volumeMounts.ui_db" -}}
- name: ui-db-storage
mountPath: {{ include "buttercup.dirs.ui_db_storage" . }}
{{- end -}}
+23 -4
View File
@@ -61,6 +61,13 @@ Define common variables by categories
{{- end }}
{{- end }}
{{- define "buttercup.env.corpusTmpfs" }}
{{- if .Values.global.volumes.corpusTmpfs.enabled }}
- name: CORPUS_TMPFS_PATH
value: "{{ .Values.global.volumes.corpusTmpfs.mountPath }}"
{{- end }}
{{- end }}
{{- define "buttercup.env.redis" }}
- name: REDIS_URL
value: {{ include "buttercup.core.redisUrl" . }}
@@ -78,19 +85,28 @@ Define Docker Host environment variable for Unix socket
- name: BUTTERCUP_LITELLM_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-litellm-api-secrets
key: BUTTERCUP_LITELLM_KEY
name: {{ .secretName }}
key: {{ .secretKey }}
- name: BUTTERCUP_LITELLM_HOSTNAME
value: {{ include "buttercup.core.litellmEndpoint" . }}
{{- end }}
{{- define "buttercup.env.telemetry" }}
{{- if .Values.global.signoz.deployed }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://{{ .Release.Name }}-signoz-otel-collector:4317"
- name: OTEL_EXPORTER_OTLP_HEADERS
value: "Authorization=Basic dXNlcm5hbWU6cGFzc3dvcmQ="
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: "grpc"
{{- else }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "{{ .Values.global.otel.endpoint }}"
- name: OTEL_EXPORTER_OTLP_HEADERS
value: "Authorization=Basic {{ .Values.global.otel.token }}"
value: "Authorization={{ .Values.global.otel.token }}"
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: "{{ .Values.global.otel.protocol | default "grpc" }}"
{{- end }}
- name: CRS_INSTANCE_ID
valueFrom:
configMapKeyRef:
@@ -135,7 +151,6 @@ Define persistent log directory environment variable
Define common environment for all services
*/}}
{{- define "buttercup.commonEnv" }}
{{- include "buttercup.env.llm" . | nindent 0 }}
{{- include "buttercup.env.telemetry" . | nindent 0 }}
{{- include "buttercup.env.timeouts" . | nindent 0 }}
{{- include "buttercup.env.dirs" . | nindent 0 }}
@@ -328,6 +343,8 @@ Service-specific environment variables that utilize the standardized variables
value: "{{ include "buttercup.core.logMaxLineLength" . }}"
- name: BUTTERCUP_FUZZER_MAX_POV_SIZE
value: {{ int .Values.global.maxPovSize | quote }}
- name: BUTTERCUP_FUZZER_RUNNER_PATH
value: "/app/fuzzer_runner/runner.sh"
{{- end }}
{{- define "buttercup.povReproducerEnv" }}
@@ -368,6 +385,8 @@ Service-specific environment variables that utilize the standardized variables
value: "/tmp/buttercup-run-data"
- name: BUTTERCUP_UI_EXTERNAL_HOST
value: {{ .Values.service.external_host | default (printf "%s-ui.%s.svc.cluster.local" .Release.Name .Release.Namespace) | quote }}
- name: BUTTERCUP_UI_DATABASE_URL
value: "sqlite:///{{ include "buttercup.dirs.ui_db_storage" . }}/buttercup_ui.db"
- name: BUTTERCUP_UI_HOST
value: {{ .Values.service.host | default "0.0.0.0" | quote }}
- name: BUTTERCUP_UI_PORT

Some files were not shown because too many files have changed in this diff Show More