diff --git a/examples/README.md b/examples/README.md index b116441ea..2ffc05cb5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,10 @@ Examples and templates for using Codex Security: +- [GitHub Actions with Amazon Bedrock](github-actions/README.md): a copyable + workflow for PR-diff and full-repository scans with AWS OIDC authentication, + SARIF uploads, and downloadable reports. + - [Findings CSV template](findings.csv): a header-only template for `codex-security publish scan --to cloud --csv PATH`. Copy it, add one finding per row, and validate the file before publishing: diff --git a/examples/github-actions/README.md b/examples/github-actions/README.md new file mode 100644 index 000000000..ff19f0ce7 --- /dev/null +++ b/examples/github-actions/README.md @@ -0,0 +1,94 @@ +# GitHub Actions with Amazon Bedrock + +The [workflow](codex-security.yml) scans PR changes or, on manual and scheduled +runs, the full repository. It uses short-lived AWS credentials and uploads +completed findings to GitHub Code Scanning as SARIF. It does not install a GitHub +App or post PR comments. + +## Setup + +1. Configure [GitHub OIDC in AWS](https://github.com/aws-actions/configure-aws-credentials#oidc-recommended) + and create a role that can invoke your approved Bedrock model or inference + profile, including streaming invocation when required. Restrict its trust + policy to your repository and the `sts.amazonaws.com` audience. Without a + GitHub environment, the subjects are: + - PRs: `repo:OWNER/REPOSITORY:pull_request`. + - Manual and scheduled runs on `main`: `repo:OWNER/REPOSITORY:ref:refs/heads/main`. + Replace `main` with your default branch; allow other branches only if you + intend to run manual scans there. +2. Add these repository variables under **Settings → Secrets and variables → + Actions → Variables**: + + | Variable | Value | + | ------------------ | ---------------------------------------------- | + | `AWS_ROLE_ARN` | ARN of the role created above | + | `AWS_REGION` | Region where the role can invoke the model | + | `BEDROCK_MODEL_ID` | Approved model or inference-profile identifier | + + Bedrock model access must already be enabled. No long-lived AWS access key or + OpenAI API key is needed. + +3. Confirm [GitHub Code Scanning and SARIF upload](https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/integrate-with-existing-tools/upload-sarif-file) + are available. Private repositories need the appropriate GitHub Code Security + entitlement. For downloadable reports only, remove the SARIF upload step and + the `security-events: write` and `actions: read` permissions. +4. Copy `codex-security.yml` into the target repository's `.github/workflows/` + directory, review it, and merge it into the default branch. This example does + not enable scanning until copied. +5. In **Actions → Codex Security (Amazon Bedrock) → Run workflow**, run a baseline + on the default branch, then open a same-repository test PR. Check coverage, + alert locations, deduplication, and fixed-alert behavior in the destination + repository before relying on the integration. + +## Configuration + +PR scans compare GitHub's default PR merge checkout against the event's immutable +base SHA. Draft, fork, and Dependabot-triggered PRs are skipped; newer runs cancel +older ones. Uncomment the schedule for weekly full scans after setup works. +Scans consume Bedrock inference usage. + +The workflow uses standard mode with high reasoning effort. See +[scan options](../../sdk/typescript/README.md#scan-options-and-output) and +[Bedrock configuration](../../sdk/typescript/README.md#authentication) for details. + +Actions are pinned to commit SHAs, and CLI and runtime versions are pinned in the +workflow. Node.js uses the active LTS line. +Review and test updates before changing pins. The job and AWS session each last +at most one hour; for longer scans, adjust both and the IAM role's session limit. + +## Results + +Findings are report-only by default. Set `FAIL_ON_SEVERITY: "high"` to fail on +high or critical findings, then make the check required in your repository rules +if it should block merging. + +| CLI exit code | Workflow result | +| ------------- | ----------------------------------------------------------------------- | +| `0` | Completed scan; export and upload SARIF | +| `1` | Severity-policy violation; upload SARIF and keep the job failed | +| Other nonzero | Failed or incomplete scan; fail the job without uploading partial SARIF | + +For exit `2`, inspect the logs, result JSON, and coverage report: missing coverage +is not a clean security result. Full and diff scans use separate SARIF categories +so a diff does not replace the full-repository baseline. + +Reports are saved as seven-day artifacts even after a scan or SARIF upload fails +(cancelled jobs may not save them). Only result JSON, report, coverage, findings, +and SARIF files are uploaded—not authentication state or raw agent transcripts. +Reports can contain sensitive source snippets and vulnerability details; review +repository access and retention accordingly. + +## Trust boundary + +Use this example only for trusted contributors on GitHub-hosted Linux runners. +A same-repository PR can change workflow code and receives model-invocation +credentials. Skipping forks does not protect against someone who can push a +branch. For a wider contributor set, require reviewers through a protected +GitHub environment and [update the OIDC subject](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws). + +Do not use `pull_request_target` to scan untrusted PR code with credentials, or +add PR-controlled dependency installs, builds, or commands to this job. The CLI +is installed outside the checkout before checkout; AWS credentials are scoped +to the scan step, and no `GH_TOKEN` or `GITHUB_TOKEN` is passed to the scanner. +Keep any additional scanner configuration maintainer-controlled. Only scan code +you are authorized to submit to the configured inference provider. diff --git a/examples/github-actions/codex-security.yml b/examples/github-actions/codex-security.yml new file mode 100644 index 000000000..9ca20c01c --- /dev/null +++ b/examples/github-actions/codex-security.yml @@ -0,0 +1,142 @@ +name: Codex Security (Amazon Bedrock) + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: + # Uncomment after testing to scan the default branch weekly. + # schedule: + # - cron: "17 3 * * 1" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + scan: + # This job receives AWS credentials. Do not use pull_request_target or + # enable it for untrusted contributors; see the README's trust boundary. + if: >- + github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' && + github.event.pull_request.draft == false) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + permissions: + contents: read + id-token: write + security-events: write + actions: read + env: + CODEX_SECURITY_VERSION: "0.1.27" + BEDROCK_MODEL_ID: ${{ vars.BEDROCK_MODEL_ID }} + # Report-only by default. Set to "high" to fail on high/critical findings. + FAIL_ON_SEVERITY: "" + + steps: + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.21.0" + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14.7" + + # Install outside the checkout, before any repository content is present. + - name: Install Codex Security + working-directory: ${{ runner.temp }} + shell: bash + run: | + npm install --prefix "$RUNNER_TEMP/codex-security-cli" \ + --ignore-scripts --no-audit --no-fund \ + --registry=https://registry.npmjs.org/ \ + "@openai/codex-security@$CODEX_SECURITY_VERSION" + echo "$RUNNER_TEMP/codex-security-cli/node_modules/.bin" >> "$GITHUB_PATH" + + - name: Check out the commit to scan + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + # On pull_request, the default checkout is GitHub's PR merge commit. + + - name: Assume the Bedrock role with OIDC + id: aws + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + aws-region: ${{ vars.AWS_REGION }} + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + role-session-name: codex-security-${{ github.run_id }} + role-duration-seconds: 3600 + output-env-credentials: false + output-credentials: true + + - name: Scan + id: scan + shell: bash + env: + AWS_REGION: ${{ vars.AWS_REGION }} + AWS_ACCESS_KEY_ID: ${{ steps.aws.outputs.aws-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.aws.outputs.aws-secret-access-key }} + AWS_SESSION_TOKEN: ${{ steps.aws.outputs.aws-session-token }} + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + args=( + scan "$GITHUB_WORKSPACE" + --provider amazon-bedrock + --model "$BEDROCK_MODEL_ID" + --mode standard + --effort high + --output-dir "$RUNNER_TEMP/codex-security-scan" + --json + ) + if [[ "$EVENT_NAME" == "pull_request" ]]; then + args+=(--diff "$BASE_SHA") + fi + if [[ -n "$FAIL_ON_SEVERITY" ]]; then + args+=(--fail-on-severity "$FAIL_ON_SEVERITY") + fi + scan_status=0 + codex-security "${args[@]}" > "$RUNNER_TEMP/codex-security-result.json" || scan_status=$? + echo "exit-code=$scan_status" >> "$GITHUB_OUTPUT" + exit "$scan_status" + + # A policy violation (1) is still a completed scan. An incomplete/error + # result (2) must not replace code-scanning alerts with a partial report. + - name: Export completed findings as SARIF + id: sarif + if: ${{ !cancelled() && (steps.scan.outputs.exit-code == '0' || steps.scan.outputs.exit-code == '1') }} + shell: bash + run: | + codex-security export "$RUNNER_TEMP/codex-security-scan" \ + --export-format sarif \ + --source-root "$GITHUB_WORKSPACE" \ + --output "$RUNNER_TEMP/codex-security.sarif" + + - name: Upload SARIF to GitHub Code Scanning + if: ${{ !cancelled() && steps.sarif.outcome == 'success' }} + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + with: + sarif_file: ${{ runner.temp }}/codex-security.sarif + category: codex-security/${{ github.event_name == 'pull_request' && 'diff' || 'full' }} + + - name: Save reports + if: ${{ !cancelled() && steps.scan.outputs.exit-code != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codex-security-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: 7 + if-no-files-found: warn + path: | + ${{ runner.temp }}/codex-security-result.json + ${{ runner.temp }}/codex-security.sarif + ${{ runner.temp }}/codex-security-scan/report.md + ${{ runner.temp }}/codex-security-scan/coverage.json + ${{ runner.temp }}/codex-security-scan/findings.json diff --git a/sdk/typescript/tests-ts/github-actions-example.test.ts b/sdk/typescript/tests-ts/github-actions-example.test.ts new file mode 100644 index 000000000..563faff77 --- /dev/null +++ b/sdk/typescript/tests-ts/github-actions-example.test.ts @@ -0,0 +1,191 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { parse } from "yaml"; + +interface Step { + id?: string; + uses?: string; + if?: string; + run?: string; + env?: Record; + with?: Record; +} + +const workflow = parse( + readFileSync( + new URL( + "../../../examples/github-actions/codex-security.yml", + import.meta.url, + ), + "utf8", + ), +) as { + on: Record; + jobs: { + scan: { + if: string; + env: Record; + steps: Step[]; + }; + }; +}; +const job = workflow.jobs.scan; +const scan = job.steps.find((step) => step.id === "scan")!; +const sarif = job.steps.find((step) => step.id === "sarif")!; +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function runStep(step: Step, overrides: Record = {}) { + const directory = mkdtempSync(join(tmpdir(), "codex actions example ")); + temporaryDirectories.push(directory); + // Git Bash accepts forward-slash drive paths on Windows. + const root = directory.replaceAll("\\", "/"); + const bash = + process.platform === "win32" + ? join( + process.env["ProgramFiles"] ?? "C:/Program Files", + "Git/bin/bash.exe", + ) + : "bash"; + const result = spawnSync( + bash, + [ + "--noprofile", + "--norc", + "-e", + "-o", + "pipefail", + "-c", + `codex-security() { + printf '%s\\0' "$@" > "$RUNNER_TEMP/arguments" + printf '{"mock":true}\\n' + return "$MOCK_EXIT_CODE" +} +${step.run}`, + ], + { + cwd: directory, + encoding: "utf8", + env: { + PATH: process.env["PATH"], + SYSTEMROOT: process.env["SYSTEMROOT"], + RUNNER_TEMP: root, + GITHUB_WORKSPACE: `${root}/repository with spaces`, + GITHUB_OUTPUT: `${root}/outputs`, + BEDROCK_MODEL_ID: "example.model", + EVENT_NAME: "workflow_dispatch", + BASE_SHA: "", + FAIL_ON_SEVERITY: "", + MOCK_EXIT_CODE: "0", + ...overrides, + }, + }, + ); + if (result.error) throw result.error; + const args = readFileSync(join(directory, "arguments"), "utf8") + .split("\0") + .slice(0, -1); + return { ...result, directory, root, args }; +} + +test("scans PR changes at the default merge checkout with literal arguments", () => { + const base = "a".repeat(40); + const result = runStep(scan, { + EVENT_NAME: "pull_request", + BASE_SHA: base, + }); + expect(result.status).toBe(0); + expect(result.args.slice(0, 2)).toEqual([ + "scan", + `${result.root}/repository with spaces`, + ]); + expect(result.args.slice(-2)).toEqual(["--diff", base]); + const checkout = job.steps.find((step) => + step.uses?.startsWith("actions/checkout@"), + )!; + expect(checkout.with?.["fetch-depth"]).toBe(0); + expect(checkout.with?.["persist-credentials"]).toBe(false); + expect(checkout.with?.["ref"]).toBeUndefined(); +}); + +for (const event of ["workflow_dispatch", "schedule"]) { + test(`${event} scans the full repository without a severity gate by default`, () => { + const result = runStep(scan, { EVENT_NAME: event }); + expect(result.status).toBe(0); + expect(result.args).not.toContain("--diff"); + expect(result.args).not.toContain("--fail-on-severity"); + expect(job.env["FAIL_ON_SEVERITY"]).toBe(""); + }); +} + +for (const status of [0, 1, 2, 130]) { + test(`preserves scan exit ${status} and writes its result for later steps`, () => { + const result = runStep(scan, { + MOCK_EXIT_CODE: String(status), + FAIL_ON_SEVERITY: "high", + }); + expect(result.status).toBe(status); + expect(result.args.slice(-2)).toEqual(["--fail-on-severity", "high"]); + expect(readFileSync(join(result.directory, "outputs"), "utf8")).toBe( + `exit-code=${status}\n`, + ); + expect( + JSON.parse( + readFileSync( + join(result.directory, "codex-security-result.json"), + "utf8", + ), + ), + ).toEqual({ mock: true }); + }); +} + +test("exports the completed scan with source-root fingerprints outside the checkout", () => { + const result = runStep(sarif); + expect(result.status).toBe(0); + expect(result.args).toEqual([ + "export", + `${result.root}/codex-security-scan`, + "--export-format", + "sarif", + "--source-root", + `${result.root}/repository with spaces`, + "--output", + `${result.root}/codex-security.sarif`, + ]); + expect(sarif.if).toBe( + "${{ !cancelled() && (steps.scan.outputs.exit-code == '0' || steps.scan.outputs.exit-code == '1') }}", + ); + const upload = job.steps.find((step) => + step.uses?.startsWith("github/codeql-action/upload-sarif@"), + )!; + expect(upload.if).toBe( + "${{ !cancelled() && steps.sarif.outcome == 'success' }}", + ); +}); + +test("keeps credentials scoped and skips untrusted PR workflows", () => { + expect(workflow.on).toHaveProperty("pull_request"); + expect(workflow.on).not.toHaveProperty("pull_request_target"); + expect(job.if).toContain( + "github.event.pull_request.head.repo.full_name == github.repository", + ); + expect(job.if).toContain("github.actor != 'dependabot[bot]'"); + const aws = job.steps.find((step) => step.id === "aws")!; + expect(aws.with?.["output-env-credentials"]).toBe(false); + expect(aws.with?.["output-credentials"]).toBe(true); + expect(scan.env).toHaveProperty("AWS_SESSION_TOKEN"); + for (const step of job.steps) { + expect(step.env ?? {}).not.toHaveProperty("GH_TOKEN"); + expect(step.env ?? {}).not.toHaveProperty("GITHUB_TOKEN"); + if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/); + } +});