critical Threat analysis

Claude Code GitHub Action Secret Exposure

Microsoft reported that the Claude Code GitHub Action could expose workflow secrets through a Read-tool path that reached /proc/self/environ; Anthropic shipped v2.1.128 as the fixed release.

#github-actions#ci-cd#ai-assistants#credential-theft#workflow-secrets
On this page 0% read

    Executive Summary

    Microsoft described a secret-exposure case in the Claude Code GitHub Action where untrusted issue or pull-request content could reach the action’s Read tool and expose workflow secrets from the runner environment. Anthropic’s v2.1.128 release is the published fixed boundary, but the public report does not include exact repository or workflow examples, so broad exploitation should remain scoped as uncertain rather than assumed [1][2].

    The practical defender takeaway is narrow: look for repositories that route untrusted issue or PR content into Claude Code jobs, confirm whether those jobs had access to ANTHROPIC_API_KEY or other workflow secrets, and treat any evidence of /proc/self/environ access as an exposure event until proven otherwise [1][2].

    Key Facts

    Threat Type: CI/CD secret exposure through an AI assistant workflow

    Ecosystem: GitHub Actions

    Registry: GitHub repositories

    Affected Assets:

    • Claude Code GitHub Action
    • GitHub Actions runners
    • CI/CD workflows processing untrusted issue or pull-request content

    Malicious Versions:

    • not publicly enumerated in the report

    Known Good Versions:

    • v2.1.128

    Fixed Or Safe Versions:

    • v2.1.128

    Execution Trigger: workflow content routed through the Claude Code GitHub Action Read tool

    Primary Impact: workflow secret exposure from the runner environment

    Campaign Context: June 2026 agentic CI/CD incident with a public mitigation release but limited public exploitation detail.

    Confidence: medium

    Canonical Source: Microsoft security blog report and Anthropic release v2.1.128

    Last Verified: 2026-06-05

    Evidence Assessment

    • confirmed: Microsoft reported the case and tied the exposure to a Read-tool path that could reach /proc/self/environ [1].
    • confirmed: Anthropic’s v2.1.128 release is the published fixed boundary in the supplied evidence set [2].
    • unclear: The Microsoft article did not publish exact repository or workflow examples, so the full exploitation surface remains uncertain [1].
    • unclear: Broader exploitation beyond the described Read-tool exposure path is not established in the public materials [1][2].

    Impact Determination

    ClassificationCriteriaRequired evidenceRequired actionClosure condition
    Confirmed compromiseThe workflow or log export shows anthropics/claude-code, v2.1.128-boundary activity, secret material, or /proc/self/environ access.Workflow file, job log, runner telemetry, and secret inventory.Pause the workflow, preserve logs, and rotate any secret reachable from the job.The exposure path is removed and downstream identity checks are clean.
    Presumed exposedThe repository routes untrusted issue or PR content into Claude Code and had secrets available, but the exact runtime evidence is incomplete.Workflow definition, job permissions, and secret availability.Treat reachable secrets as exposed until you can rule out Read-tool access.Owners verify rotation and no suspicious follow-on use.
    Potentially exposedThe repo references Claude Code, but the specific run state, version, or secret availability is incomplete.Repo search hits and workflow history.Complete run-level scoping before classifying the asset.Each matching workflow is dispositioned.
    Not exposedNo Claude Code action reference, ANTHROPIC_API_KEY, or /proc/self/environ evidence exists in the scoped checkout or logs.Negative checkout and log search results.Keep the clean evidence set with the case record.Search artifacts are preserved.
    UnknownRequired checkout, workflow, or log telemetry is missing.Named data gaps.Keep the repository in the investigation queue.Missing evidence is resolved or the owner accepts the gap.

    Minimum Evidence To Collect

    • Workflow definitions that reference anthropics/claude-code or pin v2.1.128, because they establish whether the action was present in the execution path.
    • Job logs or exported runner telemetry that mention ANTHROPIC_API_KEY or /proc/self/environ, because they show whether the secret-bearing environment was reachable.
    • Repository history for issue or pull-request handling workflows, because the report’s exposure path depends on untrusted content reaching the action [1][2].

    Timeline

    • 2026-05-04T23:01:47Z Anthropic published the v2.1.128 release boundary in the public release feed [2].
    • 2026-06-05 Microsoft published the case writeup describing the Claude Code GitHub Action exposure path [1].
    • 2026-06-05 This site-worker pass records the case as a standalone threat post with a matching local hunt.

    What Happened

    Microsoft’s report describes a workflow where the Claude Code GitHub Action processed untrusted issue or pull-request content and a Read tool path could expose the runner environment, including secrets such as ANTHROPIC_API_KEY [1]. The source material does not provide the exact repository or workflow examples, so defenders should treat the issue as a real exposure mechanism without assuming a wider, unproven mass-compromise campaign [1].

    Anthropic’s v2.1.128 release gives the practical fixed boundary for responders who need a version marker during triage. For incident handling, the main question is not whether every Claude Code workflow was affected, but whether a specific checkout or log export shows the action, the fixed version boundary, and a path to /proc/self/environ [2].

    Technical Analysis

    Initial Access

    The attack surface is not a package registry. It is a GitHub Actions workflow that accepts untrusted issue or pull-request content and hands that content to the Claude Code action, which means the relevant evidence is workflow logic, run history, and the secret set available to that job [1].

    Package or Artifact Tampering

    The public evidence supplied here points to a GitHub release boundary, not a malicious tarball. Use v2.1.128 as the version marker during scoping, and treat older references to the action as requiring additional review rather than immediate benign classification [2].

    Execution Trigger

    The trigger is workflow execution. If the runner invokes Claude Code on attacker-influenced content, then the action’s Read tool may traverse environment data that should not be available to untrusted input [1].

    Payload Behavior

    The publicly described behavior is secret exposure from the runner environment via /proc/self/environ, with ANTHROPIC_API_KEY serving as the clearest named secret class in the task context [1][2].

    Exfiltration / C2

    No public command-and-control endpoint was provided in the supplied sources, so the correct defender stance is to hunt for exposure evidence rather than assume a known remote sink [1][2].

    Propagation

    No propagation mechanism is established in the public evidence. The case should be treated as workflow-local exposure, not as a self-replicating campaign, unless local repository history shows otherwise [1][2].

    Obfuscation or Evasion

    The important evasion angle is contextual: a normal-looking assistant workflow can become risky when it is allowed to inspect environment state while processing untrusted content. That makes repository intent, not just file syntax, central to the review [1].

    Affected Assets and Blast Radius

    Affected Assets:

    • ecosystems: GitHub Actions, CI/CD
    • packages:
    • versions: v2.1.128
    • repositories: anthropics/claude-code
    • ci_cd_systems: GitHub Actions runners, workflows processing untrusted issue or pull-request content
    • container_images:
    • developer_tools: Claude Code GitHub Action

    Credentials At Risk:

    • ANTHROPIC_API_KEY
    • any other workflow secret injected into the runner environment
    • downstream tokens reachable from a compromised GitHub Actions job

    Not Currently Known To Affect:

    • repositories that do not invoke the Claude Code GitHub Action
    • workflows that never expose secret-bearing environment data to the assistant path
    • broader exploitation beyond the public Microsoft-described case

    Indicators of Compromise

    The following indicators can be used to scope exposure across local repositories, workflow logs, and exported telemetry.

    Files

    • .github/workflows/*.yml
    • action.yml

    Versions

    • v2.1.128

    Secrets

    • ANTHROPIC_API_KEY

    Paths

    • /proc/self/environ

    Process Patterns

    • Read tool access to /proc/self/environ
    • workflow handling untrusted issue or pull-request content through Claude Code

    Detection and Hunting

    Hunt Manifest: claude-code-github-action-secret-exposure-hunt-1

    • Title: checkout and exported log scope
    • Question: Does the checkout or exported log scope contain indicators associated with the Claude Code GitHub Action secret-exposure case?
    • Telemetry Family: process
    • Telemetry Context: repository checkout or CI/CD log export
    • Positive Signal: Matched anthropics/claude-code, v2.1.128, ANTHROPIC_API_KEY, or /proc/self/environ in checkout or log telemetry
    • False Positives: Legitimate Claude Code release notes or examples may mention the action name or version without indicating exposure; require workflow context before escalation.
    • Classification on Match: presumed_exposed
    #!/usr/bin/env python3
    """Scan a checkout or exported log set for Claude Code GitHub Action exposure indicators.
    
    The hunt focuses on the exact indicators reported in the case context:
    - anthropics/claude-code
    - v2.1.128
    - ANTHROPIC_API_KEY
    - /proc/self/environ
    
    Exit codes:
      0 = no indicators found
      1 = at least one indicator matched
      2 = usage or read error
    """
    
    import argparse
    import json
    import sys
    from pathlib import Path
    INDICATORS = (
        {
            "id": "action_reference",
            "category": "workflow",
            "patterns": ("anthropics/claude-code", "uses: anthropics/claude-code"),
        },
        {
            "id": "version_boundary",
            "category": "release",
            "patterns": ("v2.1.128", "2.1.128"),
        },
        {
            "id": "workflow_secret",
            "category": "secret",
            "patterns": ("ANTHROPIC_API_KEY",),
        },
        {
            "id": "readable_env_path",
            "category": "runtime",
            "patterns": ("/proc/self/environ",),
        },
    )
    
    SKIP_DIR_NAMES = {".git", ".venv", "__pycache__", "dist", "build", "node_modules", "vendor", "coverage"}
    MAX_TEXT_BYTES = 2 * 1024 * 1024
    
    
    def parse_args() -> argparse.Namespace:
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument(
            "paths",
            nargs="*",
            default=["."],
            help="One or more checkout or log-export paths to scan.",
        )
        parser.add_argument(
            "--json-out",
            type=Path,
            help="Optional path to write structured findings as JSON.",
        )
        return parser.parse_args()
    
    
    def is_probably_text(data: bytes) -> bool:
        if not data:
            return True
        if b"\x00" in data:
            return False
        printable = sum(1 for byte in data[:4096] if byte in b"\t\n\r\x20" or 32 <= byte <= 126)
        return printable / min(len(data), 4096) >= 0.75
    
    
    def read_text(path: Path) -> str | None:
        try:
            data = path.read_bytes()
        except OSError:
            return None
    
        if len(data) > MAX_TEXT_BYTES:
            data = data[:MAX_TEXT_BYTES]
    
        if not is_probably_text(data):
            return None
    
        for encoding in ("utf-8", "utf-8-sig", "latin-1"):
            try:
                return data.decode(encoding, errors="ignore")
            except UnicodeDecodeError:
                continue
        return None
    
    
    def iter_candidate_files(target: Path):
        if target.is_file():
            yield target
            return
    
        if not target.exists():
            return
    
        for child in target.rglob("*"):
            if child.is_dir():
                continue
            if any(part in SKIP_DIR_NAMES for part in child.parts):
                continue
            yield child
    
    
    def scan_text(text: str, file_path: Path):
        findings = []
        for indicator in INDICATORS:
            for pattern in indicator["patterns"]:
                start = 0
                while True:
                    match_index = text.find(pattern, start)
                    if match_index < 0:
                        break
                    line_number = text.count("\n", 0, match_index) + 1
                    line_start = text.rfind("\n", 0, match_index) + 1
                    line_end = text.find("\n", match_index)
                    if line_end < 0:
                        line_end = len(text)
                    findings.append(
                        {
                            "category": indicator["category"],
                            "indicator_id": indicator["id"],
                            "pattern": pattern,
                            "file": str(file_path),
                            "line": line_number,
                            "excerpt": text[line_start:line_end].strip(),
                        }
                    )
                    start = match_index + len(pattern)
        return findings
    
    
    def scan_path(target: Path):
        findings = []
        for candidate in iter_candidate_files(target):
            text = read_text(candidate)
            if text is None:
                continue
            findings.extend(scan_text(text, candidate))
        return findings
    
    
    def main() -> int:
        args = parse_args()
        targets = [Path(path).expanduser().resolve() for path in args.paths]
    
        all_findings = []
        for target in targets:
            if not target.exists():
                print(f"[-] missing path: {target}", file=sys.stderr)
                return 2
            all_findings.extend(scan_path(target))
    
        if args.json_out is not None:
            args.json_out.parent.mkdir(parents=True, exist_ok=True)
            args.json_out.write_text(json.dumps(all_findings, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    
        if all_findings:
            print(f"[!] matched {len(all_findings)} indicator occurrence(s)")
            for finding in all_findings:
                print(
                    f"[MATCH] {finding['file']}:{finding['line']} "
                    f"{finding['indicator_id']} => {finding['pattern']}"
                )
                print(f"        {finding['excerpt']}")
            return 1
    
        print("[+] no Claude Code GitHub Action exposure indicators found")
        return 0
    
    
    if __name__ == "__main__":
        raise SystemExit(main())

    Sources

    1. Microsoft Security Blog: Securing CI/CD in an Agentic World — Claude Code GitHub Action case - Role: PRIMARY_RESEARCH - Impact: Public description of the Read-tool secret-exposure path and the uncertainty around exact repository examples.
    2. Anthropic Claude Code v2.1.128 release - Role: PRIMARY_RESEARCH - Impact: Fixed-release boundary used for scoping and remediation.