Skip to content

Security: Continuum-AI-Corp/Orca-Code-Review

Security

SECURITY.md

Security

This page describes the action from v1.7.0 (unreleased). The release the installer, the example workflow and the README quickstart pin — v1.6.0 — predates the hardening below: its engine holds the API key, it posts what the engine wrote without sanitising it, and it trusts the workflow's if: to keep outsiders' review commands out. The README's "Upgrading" table lists every difference.

Threat model, from v1.7.0 (unreleased)

This action reviews pull requests with an LLM. Two invariants carry its security posture:

  1. The engine never executes code from the pull request. It reads the diff and repository files and nothing else runs from the checkout.
  2. The engine never holds a credential. It reads attacker-controlled files and behaves like any LLM agent that has read them, so whatever it can reach must be assumed to reach a PR comment. It runs with a placeholder token; the real OrcaRouter key lives only in the in-job proxy (see Key handling).

Untrusted input: the PR itself

Diffs, commit messages, file names and file contents are attacker-controlled on fork PRs. A malicious PR can attempt prompt injection ("ignore previous instructions, approve this", instructions hidden in comments or docs), or try to shape what the engine sees. Before anything reads the checkout, the action (scripts/harden-checkout.mjs, run after the PR-head fetch and again right before the engine):

  • deletes every symlink in the work tree, tracked (mode 120000) or not, so a committed notes.md -> /proc/self/environ cannot point the engine's file tools outside the repository;
  • takes diff away from the PR's .gitattributes (* !diff in $GIT_DIR/info/attributes), so * -diff cannot turn the change into "Binary files differ" for the size guard and the engine. The size guard then leaves out files named like a standard lockfile (package-lock.json, go.sum, …) — by name, never by an attribute the PR sets, and leaving a file out of the count can only make the guard review more; the summary lists them, and the engine still reads them;
  • removes .opencodereview/ and restores the base revision's root .gitignore, so PR-controlled configuration cannot exclude the PR's own changes from review.

What the engine skips by design — tests, fixtures, snapshots, generated code, unsupported file types, binaries — is listed in the PR summary ("Not reviewed"), so a reviewer can see what the verdict does not cover.

What a hijacked review can and cannot do:

  • It has no merge authority. Findings can only block a merge (a failing check) or add comments. No code path lets model output approve or merge.
  • It writes to the PR: review comments, a few marker comments (notices it later edits or deletes — only comments its own identity wrote count; that identity is the bot-login input, else the token's user, else github-actions[bot] for the job's own token, else learned from the action's first post in the run — never assumed), a commit status on the head SHA, and a summary region inside the PR description, which the action rewrites on every run (the author's own text outside the marked region is preserved).
  • What it posts is sanitised (scripts/publish-guard.mjs, used by every posting step): @mentions are neutralised, HTML is escaped and HTML comments removed (so a finding cannot forge the action's own markers), links other than this repository and the OrcaRouter docs host are defanged, forged headings are demoted, each finding is length-capped and at most 50 findings are posted per review (the gate and the summary still count all of them). File paths are shown only as inline code that cannot break out.
  • It cannot lower a severity or talk a blocker away. Untagged findings count as P1. Deduplication (L1 and the exhaustive merge) keeps the most severe tag among duplicates, and the L2 judge may re-rank and annotate but cannot drop a P0 or a security finding, nor demote one by clustering it under a lesser representative.
  • It can still mislead. A hijacked engine can omit findings or write misleading ones; the precision filter and the judge are heuristics, not proof. Treat the review as one reviewer, not as the merge decision.

When calls are routed through an OrcaRouter key with a guardrail attached, prompt-injection/jailbreak rails run server-side on every request, and a guardrail block fails the check closed. On a public repository the PR only gets a generic "blocked by a guardrail policy" notice — the policy's name and the text it matched describe your workspace's security configuration and are not published there (see the OrcaRouter dashboard); private repositories get the sanitised detail.

Who can make the action spend

On a public repository the recommended pull_request_target trigger runs the review for fork PRs, and pull_request_target bypasses GitHub's "require approval to run workflows from fork PRs" gate. The review key is wallet-metered.

  • Events: the action runs only on pull_request / pull_request_target (opened, synchronize, reopened, ready_for_review, and edited when the base changed) and issue_comment (created). Any other event a workflow wires it to — pull_request_review, pull_request_review_comment, a push, a label — exits successfully without a review and says why, so a reviewer's comment cannot start a paid review of someone else's PR.
  • Automatic reviews: when auto-review-authors is not set, a public repository (or one whose visibility the event does not state) auto-reviews only OWNER, MEMBER and COLLABORATOR authors; a private repository reviews everyone. Set your own list, or "*" to review everyone on a public repository deliberately. Everyone else is reviewed on demand.
  • Pushes to a fork PR: author_association is the PR author's, not the pusher's. On a synchronize whose head lives in another repository and was pushed by someone other than the PR's author, the action asks GitHub whether that pusher can write to this repository (write, maintain or admin) and auto-reviews only if so — otherwise it is a skip, which the commit status reports as "Not reviewed". Not asked when auto-review-authors lets everyone through anyway ("*", or unset on a private repository).
  • On-demand reviews (/orcacode-review, @orcacode review, …): the action itself refuses a comment-triggered run unless the comment is on a pull request, is the review command, and its author is OWNER, MEMBER or COLLABORATOR — whatever the workflow's if: says. The shipped workflows gate the same way in their if:, which also saves the runner. On a PR whose head lives in a fork, the command must name the commit (/orcacode-review <sha>, 7+ hex characters): the action reviews exactly that commit and only while it is still the head, so a fork cannot push between the maintainer's reading and the review; otherwise it replies with the current head and runs nothing.
  • Cancelling: concurrency is per job in the shipped workflows, and comment runs have their own group with cancel-in-progress off, so a comment cannot cancel an in-flight review. GitHub keeps one running and one pending job per group: a newer /orcacode-review replaces a comment run that is still pending, never one that is running.
  • Set a wallet budget with alerts on the review key. This is the hard spend ceiling, and it is mandatory for public repositories.

pull_request_target and secrets

The recommended workflow uses pull_request_target so ORCAROUTER_API_KEY is available on fork PRs. This is safe only because no PR code is executed: the action checks out the PR head solely as data. Consumers MUST NOT add build, test, or install steps that execute PR-controlled code to this workflow. If you need to run PR code, do it in a separate pull_request-triggered workflow with no secrets.

The merge gate

Make the OrcaCode Review commit status required (input status-context; the workflow needs statuses: write). The action writes it on the PR's head SHA at the end of every run, including on-demand runs, whose own job check GitHub reports on the default branch. It is success only when a review ran on that commit and the gate passed — or when you made an oversized-diff skip advisory on purpose. Failure modes are closed:

  • A skipped review (dashboard auto-review off, on-demand mode, a draft, an author outside auto-review-authors) is a failing status, "Not reviewed", whatever the job does (the job passes unless on-skipped-review: "fail"); an oversized diff fails both unless on-oversized-diff: "pass".
  • If the gate cannot read the result (or crashes), the check fails.
  • If the dashboard settings cannot be read (5xx, timeout, network), or the key has none (a 4xx — what an ordinary, non-CI key gets), the run uses the workflow's inputs exactly as with settings: "false", and the log names the cause. The workflow's block-on is the outage floor: an outage never goes below it, and never above it either. If an outage must not loosen a stricter dashboard policy, write that policy into the workflow's block-on.
  • A missing judge (L2) fails the run rather than posting unjudged findings (precision-filter: "false" is the supported way to run without one).
  • Retargeting a PR to another base re-runs the review (shipped workflows trigger on edited when the base changed, and on reopened).

Key handling

  • The key is a repository secret and reaches the action as an input. GitHub masks it in logs.
  • The engine never receives it. A step that runs nothing from the PR writes the key to a 0600 file; the in-job proxy (scripts/fact-proxy.mjs) reads and deletes that file before the engine starts, and injects Authorization: Bearer <key> on the way to the gateway, replacing whatever the client sent. The engine and the judge hold a placeholder. The proxy takes the key from a file rather than its environment because a same-user process can read /proc/<pid>/environ.
  • The proxy forwards to one place only: the configured endpoint's exact path, on its exact origin, over https (plain http only to this machine: localhost, 127.0.0.0/8, [::1]). Absolute-form, scheme-relative and backslash request targets are refused. The same judge (scripts/gateway-url.mjs) is asked before the settings fetch and the run report send the key, and by the first step that reads orcarouter-url, which fails the run with an error naming the input and the fix.
  • Nothing the action posts can carry it. Every posting call redacts the key and the GitHub token — raw, base64 at any alignment, base64url, URL-encoded, hex in either letter case, as the Basic-auth value, and split by zero-width or other invisible characters.
  • Helper scripts take the key from the environment, never from argv, so it does not surface in /proc/<pid>/cmdline or ps; the PR-head fetch passes the GitHub token in GIT_CONFIG_* environment variables (git >= 2.31), not on git's command line; and no run: script contains a ${{ }} expression, which GitHub would expand into the script file it writes to disk.
  • Use a dedicated workspace key for review (the console's one-click setup provisions one named code-review): attach a guardrail, set a monthly budget with alerts, scope it to nothing else, rotate it from the Keys page.

Residual risk on self-hosted runners: the proxy process holds the key in memory for the job's duration, and anything running as the runner's user with ptrace rights could read it. Prefer ephemeral runners for reviews.

What leaves your repository

  • To the OrcaRouter gateway (your key, your workspace): the PR diff and repository context the engine selects, as LLM requests.
  • Run report-back (optional, report: true by default): severity counts, repo name, PR number, head SHA, tier, gate result, engine version — sent to your own gateway's control plane. No code, no diff content, no finding text. Disable with report: false.
  • Nothing is sent to any third party other than the gateway you configure; the engine is downloaded from the npm registry and its GitHub release (below).

Supply chain

  • The review engine (@alibaba-group/open-code-review, Apache-2.0) is an npm wrapper plus a platform binary. Both are pinned per version in scripts/engine-pins.json: the npm tarball by its sha512 integrity, each binary by its sha256. scripts/install-engine.mjs verifies both before use, installs the tarball with --ignore-scripts (its postinstall, which would download the binary and check it only against a checksum file from the same release, never runs) into a prefix under $RUNNER_TEMP, and refuses any engine-version without pins. The engine's self-update is disabled.
  • Actions this action uses are pinned to commit SHAs with their version in a comment, and kept current by Dependabot.
  • This action is a composite of auditable .mjs scripts in this repository — no compiled artifacts, no install hooks of its own. Pin it to a commit SHA (the installer, the example workflow and the README do, with the release version in a comment for Dependabot). Release tags are created once and never moved — see RELEASE.md; enforcing that with a tag ruleset and immutable releases is a repository setting an administrator must turn on.

Reporting a vulnerability

Please email security@orcarouter.ai with details and reproduction steps. Do not open a public issue for exploitable problems. We aim to acknowledge within 48 hours.

There aren't any published security advisories