diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 8daa40e..584a080 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -25,8 +25,10 @@ jobs: api-token: ${{ secrets.API_DISCOVERY_TOKEN || 'test-token' }} dry-run: 'true' - - name: Display outputs + - name: Check outputs run: | - echo "Spec path: ${{ steps.discovery.outputs.spec-path }}" echo "Success: ${{ steps.discovery.outputs.success }}" + echo "Endpoints: ${{ steps.discovery.outputs.endpoints-count }}" + echo "Frameworks: ${{ steps.discovery.outputs.frameworks-detected }}" + test "${{ steps.discovery.outputs.success }}" = "true" diff --git a/README.md b/README.md index fc2c8aa..29079dc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,13 @@ -# Code Discovery GitHub Action +# APIsec Surface GitHub Action -Automatically discover API endpoints in your repository and upload them to the APIsec platform. This action uses the Code Discovery CLI to scan your codebase, generate OpenAPI specifications, and create pull requests with the results. +Scans your repository with the Surface CLI ([`apisec-surface`](https://pypi.org/project/apisec-surface/)) and uploads the result to APIsec. Surface replaces Code Discovery / Code Bolt; applications created by Code Discovery are updated in place. -## Features +## Quick start -- 🔍 **Automatic Framework Detection** - Detects Spring Boot, Micronaut, Argos, FastAPI, Flask, Django, Express, ASP.NET Core, Gin and more -- 📝 **OpenAPI Generation** - Generates OpenAPI 3.0 specifications from your code -- 🔄 **State Management** - Tracks application and instance IDs across runs -- 🔀 **Pull Request Creation** - Automatically creates PRs with discovered endpoints -- ⚠️ **Non-Blocking** - Warnings don't fail your workflow - -## Usage - -### Basic Example +1. Add two repository secrets (Settings → Secrets and variables → Actions): + - `API_DISCOVERY_ENDPOINT`: your APIsec API URL, for example `https://api.apisecapps.com` + - `API_DISCOVERY_TOKEN`: an APIsec personal access token with the `app:create` permission +2. Add `.github/workflows/api-discovery.yml`: ```yaml name: API Discovery @@ -24,183 +19,78 @@ on: jobs: discover: runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: apisec-inc/apisec-bolt-code-discovery-github-action@v0.1.7 - with: - api-endpoint: ${{ secrets.API_DISCOVERY_ENDPOINT }} - api-token: ${{ secrets.API_DISCOVERY_TOKEN }} -``` - -### Scheduled Runs - -```yaml -name: API Discovery - -on: - schedule: - - cron: '0 2 * * *' # Daily at 2 AM - -jobs: - discover: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write steps: - uses: actions/checkout@v4 - - uses: apisec-inc/apisec-bolt-code-discovery-github-action@v0.1.7 + - uses: apisec-inc/apisec-bolt-code-discovery-github-action@v1 with: api-endpoint: ${{ secrets.API_DISCOVERY_ENDPOINT }} api-token: ${{ secrets.API_DISCOVERY_TOKEN }} - pr-title: 'chore: update OpenAPI specification [skip ci]' - pr-body: 'Automatically generated OpenAPI specification from code discovery' ``` -### Custom Configuration - -```yaml -- uses: apisec-inc/apisec-bolt-code-discovery-github-action@v0.1.7 - with: - api-endpoint: ${{ secrets.API_DISCOVERY_ENDPOINT }} - api-token: ${{ secrets.API_DISCOVERY_TOKEN }} - repo-path: './backend' - config-path: './backend/.codediscovery.yml' -``` +Existing Code Discovery workflows only need the tag changed to `@v1`. The `contents: write` and `pull-requests: write` permissions are no longer needed. -### Dry Run (Testing) +### Dry run ```yaml -- uses: apisec-inc/apisec-bolt-code-discovery-github-action@v0.1.7 +- uses: apisec-inc/apisec-bolt-code-discovery-github-action@v1 with: api-endpoint: ${{ secrets.API_DISCOVERY_ENDPOINT }} api-token: ${{ secrets.API_DISCOVERY_TOKEN }} - dry-run: true # Skip API upload, still generates spec + dry-run: true ``` ## Inputs | Input | Required | Default | Description | |-------|----------|---------|-------------| -| `api-endpoint` | ✅ Yes | - | External API endpoint URL | -| `api-token` | ✅ Yes | - | Bearer token for API authentication | -| `repo-path` | ❌ No | `.` | Path to repository root to analyze | -| `config-path` | ❌ No | `.codediscovery.yml` | Path to .codediscovery.yml config file | -| `pr-title` | ❌ No | `chore: update OpenAPI specification` | Pull request title | -| `pr-body` | ❌ No | `Automatically generated OpenAPI specification` | Pull request body | -| `dry-run` | ❌ No | `false` | Skip API upload (for testing) | -| `host-url` | ⚠️ Recommended | `''` | OpenAPI server URL (e.g. `https://api.example.com`). Required when no parser can extract a host from your sources; without it, state isn't saved across runs. | +| `api-endpoint` | Yes | | APIsec API URL | +| `api-token` | Yes | | APIsec personal access token | +| `repo-path` | No | `.` | Repository root to analyze | +| `dry-run` | No | `false` | Analyze only; do not upload | +| `config-path` | No | `.codediscovery.yml` | Ignored | +| `host-url` | No | | Ignored; set the instance host in the APIsec console | +| `pr-title` | No | | Ignored; no spec PR is opened | +| `pr-body` | No | | Ignored; no spec PR is opened | ## Outputs | Output | Description | |--------|-------------| -| `spec-path` | Path to generated OpenAPI specification (`apisec-bolt-code-discovery/openapi_spec.yaml`) | -| `application-id` | Application ID from API (if uploaded) | -| `instance-id` | Instance ID from API (if uploaded) | -| `pr-url` | URL of created pull request | -| `success` | Whether discovery succeeded | +| `success` | `true` only when the scan completed | +| `application-id` | APIsec application id after a successful upload | +| `endpoints-count` | Number of API routes found | +| `frameworks-detected` | Comma-separated frameworks | +| `spec-path` | Empty (the spec is published by APIsec) | +| `instance-id` | Empty | +| `pr-url` | Empty | -## Required Permissions +The Action never fails the workflow. Check `success` if a later step should stop. -This action requires the following GitHub permissions: +## How it works -- `contents: write` - To commit generated files -- `pull-requests: write` - To create pull requests +1. Installs Python 3.11 and Surface CLI `1.0.0` +2. Registers or reuses one APIsec application per GitHub repository +3. Uploads the Surface analysis; APIsec publishes the OpenAPI spec -Add these permissions to your workflow: +After the first run, set the instance host URL in the APIsec console if the default `/` is not your API base. -```yaml -permissions: - contents: write - pull-requests: write -``` - -## How It Works - -1. **Install CLI** - Installs Code Discovery CLI (version 0.7.2) -2. **Configure Credentials** - Sets up API credentials from inputs -3. **Run Discovery** - Scans repository and generates OpenAPI spec -4. **Create PR** - Creates a new branch, commits files, and opens a pull request - -### Generated Files - -The action generates files in the `apisec-bolt-code-discovery/` directory: - -- `openapi_spec.yaml` - OpenAPI 3.0 specification -- `state.yaml` - State file tracking application and instance IDs - -Both files are automatically committed to the pull request. - -## State Management - -The action uses state management to track your application across runs: - -- **First Run**: Creates a new application and stores IDs in `state.yaml` -- **Subsequent Runs**: Updates the existing application using stored IDs - -The state file (`apisec-bolt-code-discovery/state.yaml`) is safe to commit and contains no sensitive data. +## Changes from Code Discovery (`v0.1.x`) -## Error Handling +- No pull request is opened and no files are committed to your repository. +- `host-url`, `config-path`, `pr-title` and `pr-body` are accepted but ignored. +- `spec-path`, `instance-id` and `pr-url` outputs are empty. -The action is designed to be non-blocking: +## Supported frameworks -- ⚠️ Discovery warnings don't fail the workflow -- ⚠️ API errors are logged but don't stop execution -- ✅ Workflow continues even if discovery encounters issues - -## Supported Frameworks - -- **Java**: Spring Boot, Micronaut, Argos +- **Java**: Spring Boot, Micronaut, JAX-RS (Jersey, Quarkus, RESTEasy), Servlet - **Python**: FastAPI, Flask, Django -- **Node.js**: Express -- **.NET**: ASP.NET Core -- **Go**: Gin - -More frameworks coming soon! - -## Requirements - -- Python 3.9+ (available on GitHub Actions runners) -- Git (for PR creation) -- GitHub CLI (`gh`) - pre-installed on GitHub Actions runners - -## License - -MIT License - see [LICENSE](LICENSE) file for details. - -## Support - -For issues and questions, please open an issue in the [repository](https://github.com/apisec-inc/apisec-bolt-code-discovery-github-action). - -## Quick Start - -1. **Add workflow file** `.github/workflows/api-discovery.yml`: - ```yaml - name: API Discovery - on: - push: - branches: [main] - jobs: - discover: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: apisec-inc/apisec-bolt-code-discovery-github-action@v0.1.7 - with: - api-endpoint: ${{ secrets.API_DISCOVERY_ENDPOINT }} - api-token: ${{ secrets.API_DISCOVERY_TOKEN }} - ``` +- **JavaScript / TypeScript**: Express, NestJS, Fastify, Koa +- **.NET**: ASP.NET Core, ASP.NET MVC / Web API, Web Forms, gRPC, WCF +- **Ruby**: Rails, Sinatra, Grape, Hanami, Roda, Padrino +- **GraphQL** -2. **Configure secrets** in repository Settings → Secrets: - - `API_DISCOVERY_ENDPOINT` - Your API endpoint URL - - `API_DISCOVERY_TOKEN` - Your Bearer token +Go (Gin) and Argos are not yet supported in Surface. Keep those repositories on `@v0.1.9` until they are. -3. **Push to trigger** - The action will run automatically! +## Rollback +Pin `@v0.1.9` to run Code Discovery again. diff --git a/action.yml b/action.yml index 658bce4..2a22d36 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ name: 'Code Discovery - API Discovery' -description: 'Automatically discover API endpoints and upload to API security platform' -author: 'APIsec Bolt' +description: 'Discover API endpoints with Surface CLI and upload them to APIsec' +author: 'APIsec' branding: icon: 'search' @@ -8,170 +8,142 @@ branding: inputs: api-endpoint: - description: 'External API endpoint URL' + description: 'APIsec applicationsservice base URL (same as Surface --api-url)' required: true api-token: - description: 'Bearer token for API authentication' + description: 'APIsec personal access token' required: true repo-path: description: 'Path to repository root to analyze' required: false default: '.' config-path: - description: 'Path to .codediscovery.yml config file (optional)' + description: 'Ignored. Surface does not use .codediscovery.yml. Kept so existing workflows do not break.' required: false default: '.codediscovery.yml' pr-title: - description: 'Pull request title' + description: 'Ignored. The Action no longer opens a spec PR.' required: false default: 'chore: update OpenAPI specification' pr-body: - description: 'Pull request body' + description: 'Ignored. The Action no longer opens a spec PR.' required: false default: 'Automatically generated OpenAPI specification' dry-run: - description: 'Skip API upload (for testing)' + description: 'Analyse only; do not register or upload' required: false default: 'false' host-url: - description: >- - Explicit OpenAPI server URL for the discovered API - (e.g. https://api.example.com). Required when no parser extracts - a host from your sources; without it the APIsec backend cannot - create an application instance and state is never saved across runs. + description: 'Ignored. Set the instance host in the APIsec console after the app is created.' required: false default: '' outputs: spec-path: - description: 'Path to generated OpenAPI specification' + description: 'Empty. The spec is published by the reasoning engine, not written to the repo.' + value: ${{ steps.discovery.outputs.spec-path }} endpoints-count: - description: 'Number of endpoints discovered' + description: 'Number of API routes found' + value: ${{ steps.discovery.outputs.endpoints-count }} frameworks-detected: description: 'Comma-separated list of detected frameworks' + value: ${{ steps.discovery.outputs.frameworks-detected }} application-id: - description: 'Application ID from API (if uploaded)' + description: 'APIsec application id (if uploaded)' + value: ${{ steps.discovery.outputs.application-id }} instance-id: - description: 'Instance ID from API (if uploaded)' + description: 'Empty. Surface does not return an instance id.' + value: ${{ steps.discovery.outputs.instance-id }} pr-url: - description: 'URL of created pull request' + description: 'Empty. The Action no longer opens a spec PR.' + value: ${{ steps.discovery.outputs.pr-url }} success: - description: 'Whether discovery succeeded' + description: 'true only when the CLI exited 0' + value: ${{ steps.discovery.outputs.success }} runs: using: 'composite' steps: - - name: Install Code Discovery CLI - shell: bash - run: | - python3 -m pip install --upgrade pip - pip install code-discovery==0.7.2 - - - name: Configure API credentials + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Surface CLI shell: bash run: | - mkdir -p ~/.config - cat > ~/.apisec << EOF - [api-discovery] - endpoint = ${{ inputs.api-endpoint }} - token = ${{ inputs.api-token }} - EOF - chmod 600 ~/.apisec - - - name: Run API Discovery + python -m pip install --upgrade pip + pip install "apisec-surface==1.0.0" + apisec-surface telemetry off || true + + - name: Run Surface discovery id: discovery shell: bash continue-on-error: true + env: + API_ENDPOINT: ${{ inputs.api-endpoint }} + API_TOKEN: ${{ inputs.api-token }} + REPO_PATH: ${{ inputs.repo-path }} + DRY_RUN: ${{ inputs.dry-run }} + CONFIG_PATH: ${{ inputs.config-path }} + HOST_URL: ${{ inputs.host-url }} + APISEC_CANONICAL_REPO_ID: github:${{ github.repository_id }} + APISEC_RUN_PROFILE: legacy + APISEC_REGISTRATION_CODE: ci-github-action + APISEC_PAT: ${{ inputs.api-token }} run: | - # Build command arguments - CMD_ARGS="--repo-path ${{ inputs.repo-path }}" - CMD_ARGS="$CMD_ARGS --output apisec-bolt-code-discovery/openapi_spec.yaml" - - if [ "${{ inputs.config-path }}" != ".codediscovery.yml" ]; then - CMD_ARGS="$CMD_ARGS --config ${{ inputs.config-path }}" - fi - - if [ "${{ inputs.dry-run }}" == "true" ]; then - CMD_ARGS="$CMD_ARGS --dry-run" + if [ "$CONFIG_PATH" != ".codediscovery.yml" ]; then + echo "::warning::config-path is ignored; Surface does not use .codediscovery.yml" fi - - if [ -n "${{ inputs.host-url }}" ]; then - CMD_ARGS="$CMD_ARGS --server ${{ inputs.host-url }}" + if [ -n "$HOST_URL" ]; then + echo "::warning::host-url is ignored; set the instance host in the APIsec console after the app is created" fi - # Run discovery (continue on error - don't fail workflow) - code-discovery $CMD_ARGS || echo "⚠️ Discovery completed with warnings" - - # Extract outputs from state file and CLI output - if [ -f apisec-bolt-code-discovery/state.yaml ]; then - APP_ID=$(grep 'applicationId:' apisec-bolt-code-discovery/state.yaml | cut -d'"' -f2 || echo "") - INSTANCE_ID=$(grep 'instanceId:' apisec-bolt-code-discovery/state.yaml | cut -d'"' -f2 || echo "") - echo "application-id=$APP_ID" >> $GITHUB_OUTPUT - echo "instance-id=$INSTANCE_ID" >> $GITHUB_OUTPUT + ARGS=(--log-format json analyze "$REPO_PATH" --no-detect-monorepo --skip-if-no-routes --api-url "$API_ENDPOINT") + if [ "$DRY_RUN" = "true" ]; then + ARGS+=(--dry-run) fi - - echo "spec-path=apisec-bolt-code-discovery/openapi_spec.yaml" >> $GITHUB_OUTPUT - echo "success=true" >> $GITHUB_OUTPUT - - - name: Create Pull Request - if: steps.discovery.outcome == 'success' && inputs.dry-run != 'true' - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - # Configure git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Get base branch (default to main if not available) - BASE_BRANCH="${{ github.ref_name }}" - if [ "$BASE_BRANCH" == "HEAD" ] || [ -z "$BASE_BRANCH" ]; then - BASE_BRANCH="main" - fi - - # Fetch all branches - git fetch origin - - # Create new branch with timestamp - BRANCH_NAME="code-discovery/update-openapi-$(date +%s)" - - # Checkout base branch first, then create new branch - git checkout $BASE_BRANCH 2>/dev/null || git checkout -b $BASE_BRANCH origin/$BASE_BRANCH - git checkout -b $BRANCH_NAME 2>/dev/null || git checkout $BRANCH_NAME - - # Add generated files - git add apisec-bolt-code-discovery/openapi_spec.yaml apisec-bolt-code-discovery/state.yaml 2>/dev/null || true - - # Check if there are changes - if git diff --staged --quiet; then - echo "No changes to commit" - echo "pr-url=" >> $GITHUB_OUTPUT - else - # Commit - git commit -m "${{ inputs.pr-title }}" || exit 0 - - # Push branch - git push origin $BRANCH_NAME --force-with-lease 2>/dev/null || git push origin $BRANCH_NAME - - # Create PR (check if PR already exists first) - EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "") - - if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ] && [ "$EXISTING_PR" != "" ]; then - PR_URL=$(gh pr view $EXISTING_PR --json url --jq '.url' 2>/dev/null || echo "") - echo "Using existing pull request: $PR_URL" - else - PR_URL=$(gh pr create \ - --title "${{ inputs.pr-title }}" \ - --body "${{ inputs.pr-body }}" \ - --base $BASE_BRANCH \ - --head $BRANCH_NAME \ - --repo ${{ github.repository }} 2>/dev/null || echo "") - fi - - echo "pr-url=$PR_URL" >> $GITHUB_OUTPUT - - if [ -n "$PR_URL" ]; then - echo "✓ Created pull request: $PR_URL" - fi + set +e + apisec-surface "${ARGS[@]}" 2>scan.ndjson + RC=$? + set -e + cat scan.ndjson >&2 + if [ "$RC" != "0" ]; then + echo "::warning title=APIsec Surface::Scan or upload failed (exit code $RC); results were not sent to APIsec. See the step log for details." fi + RC="$RC" python - <<'PY' + import json + import os + from pathlib import Path + + routes = "" + frameworks = "" + ndjson = Path("scan.ndjson") + if ndjson.exists(): + for line in ndjson.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if obj.get("event") == "scan_complete": + routes = str(obj.get("routes_found", "")) + frameworks = ",".join(obj.get("frameworks") or []) + + state = Path(os.environ["REPO_PATH"]) / "apisec-code-bolt" / "state.yaml" + app_id = "" + if state.exists(): + for line in state.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.startswith("applicationId:"): + app_id = line.split(":", 1)[1].strip().strip("\"'") + break + + out = os.environ["GITHUB_OUTPUT"] + with open(out, "a", encoding="utf-8") as fh: + fh.write(f"endpoints-count={routes}\n") + fh.write(f"frameworks-detected={frameworks}\n") + fh.write(f"application-id={app_id}\n") + fh.write("spec-path=\ninstance-id=\npr-url=\n") + fh.write(f"success={'true' if os.environ['RC'] == '0' else 'false'}\n") + PY + exit 0