I gave an Apify Actor three GitHub tools. It found 16 dependency advisories without touching the code.

작성자

카테고리:

← 피드로
DEV Community · Thirdwatch · 2026-09-01 개발(SW)

Most dependency-security demos end with an impressive list and an awkward handoff. Someone still has to find the repository, copy the vulnerable versions, decide where the result belongs, and prevent tomorrow’s scan from opening the same ticket again.

I wanted the scan itself to finish that loop, but I did not want an Actor with permission to rewrite a manifest or open an unreviewed pull request.

So I built an Apify Actor around one deliberately small GitHub MCP connector. It can call exactly three GitHub tools:

  • get_file_contents to read a dependency manifest;
  • search_issues to find its previous triage issue;
  • issue_write to create or update that issue.

The Actor extracts exact dependency versions, calls a separate OSV Actor, and writes a source-linked review queue. It cannot edit a file, create a branch, merge code, or call any other GitHub tool.

On August 8, 2026, I ran it against a public fixture repository containing three intentionally old npm packages. The run returned 16 advisory rows and created issue #1. I ran the same input again. The second run updated issue #1; the repository still had exactly one issue. After hardening the write guard and untrusted-text handling, I repeated the workflow on published build 1.2.1; it updated that same issue again.

That second run is the result I care about. A useful integration has to survive repetition.

MCP connectors point in the opposite direction from the Apify MCP server

The naming is easy to mix up, so here is the distinction.

The Apify MCP server exposes Actors to external AI clients such as Codex, Claude, and Cursor. An agent calls into Apify.

MCP connectors let an Actor call an external service on the user’s behalf. The Actor calls out to GitHub, Slack, Google Sheets, or another MCP-compatible service.

This article uses the second direction:

The connector fires twice in the workflow: first at the data boundary, when the Actor reads package.json, and again at the delivery boundary, when it searches for and writes the triage issue. Without the connector, I would need to copy repository contents into Actor input and move the result back to GitHub manually, or pass a GitHub token into code I did not want handling it.

Declare the capability ceiling in the Actor input schema

An Actor opts into connectors with resourceType: "mcpConnector". I constrained both the upstream server and the tool names:

{
  "githubConnector": {
    "title": "GitHub MCP connector",
    "description": "Read manifests and create or update a triage issue",
    "type": "string",
    "resourceType": "mcpConnector",
    "mcpServers": [
      {
        "url": "https://api.githubcopilot.com/mcp*",
        "tools": {
          "required": [
            "get_file_contents",
            "search_issues",
            "issue_write"
          ]
        }
      }
    ]
  }
}

Enter fullscreen mode Exit fullscreen mode

The wildcard covers the official endpoint’s trailing slash. It does not accept another hostname.

The schema is a runtime ceiling. Apify’s MCP proxy filters tools/list and rejects calls outside the declared set. Connector-level permissions and the GitHub token’s scope still apply underneath it, so the effective permission is the intersection of all three layers.

The connector credential stays server-side. At runtime the Actor receives a connector ID, the Apify proxy base URL, and its run token. It never receives the GitHub PAT or OAuth token stored in the connector.

Dry run is the default. A write run must also provide confirmWriteTarget equal to the exact owner/repo. That is not a GitHub authorization substitute—the connector still enforces the caller’s real permissions—but it prevents a casually toggled checkbox from posting to an unintended repository. I use only repositories I own or am explicitly authorized to modify.

That changes how I assess a Store Actor. I still treat its code as untrusted, because any allowed tool can be misused. But credential exfiltration and unlimited GitHub access are no longer prerequisites for the workflow.

Connect through the proxy with a standard MCP client

The runtime code is ordinary Streamable HTTP MCP:

import os
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

base_url = os.environ["ACTOR_MCP_CONNECTOR_BASE_URL"].rstrip("/")
run_token = os.environ["APIFY_TOKEN"]

async with httpx.AsyncClient(
    headers={"Authorization": f"Bearer {run_token}"}
) as http_client:
    async with streamable_http_client(
        f"{base_url}/{connector_id}",
        http_client=http_client,
    ) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = {tool.name for tool in (await session.list_tools()).tools}

Enter fullscreen mode Exit fullscreen mode

I check the returned tool set before reading anything:

required = {"get_file_contents", "search_issues", "issue_write"}
if missing := required - tools:
    raise RuntimeError(f"GitHub connector is missing: {sorted(missing)}")

Enter fullscreen mode Exit fullscreen mode

Failing early is better than reading a repository, paying for an OSV run, and only then discovering that the connector cannot write the result.

Read manifests, but query only versions I can defend

The Actor supports exact npm versions in package.json and name==version entries in Python requirement files. It intentionally skips ranges such as ^1.2.3, ~2.0, and requests>=2.

EXACT_SEMVER = re.compile(r"^v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?$")
NPM_PACKAGE = re.compile(
    r"^(?:@[A-Za-z0-9][A-Za-z0-9._-]{0,213}/)?"
    r"[A-Za-z0-9][A-Za-z0-9._-]{0,213}$"
)


def parse_package_json(text: str) -> list[str]:
    data = json.loads(text)
    packages = []

    for section in ("dependencies", "devDependencies", "optionalDependencies"):
        for name, raw_version in (data.get(section) or {}).items():
            version = str(raw_version).strip()
            if NPM_PACKAGE.fullmatch(str(name)) and EXACT_SEMVER.fullmatch(version):
                packages.append(f"npm:{name}@{version.removeprefix('v')}")

    return packages

Enter fullscreen mode Exit fullscreen mode

OSV can answer a version query only when I give it a version. Guessing what a range resolved to would create a cleaner-looking issue and worse evidence. Lockfile support is the next useful extension; silently treating a range as an installed version is not.

The GitHub call itself is small:

response = await session.call_tool(
    "get_file_contents",
    arguments={
        "owner": owner,
        "repo": repo,
        "path": "package.json",
    },
)

Enter fullscreen mode Exit fullscreen mode

One implementation detail was easy to miss: GitHub returned the file as an embedded MCP resource rather than a plain text block. My first decoder looked only for content[].text, so the Actor reported an empty file even though the tool call had succeeded.

The corrected decoder prefers resource text:

for block in result.content or []:
    resource = getattr(block, "resource", None)
    resource_text = getattr(resource, "text", None)
    if resource_text:
        resource_texts.append(str(resource_text))

Enter fullscreen mode Exit fullscreen mode

That bug only appeared against the real connector. MCP standardizes the envelope, but servers can legitimately use different content block types, so a mocked JSON response was not enough.

Keep vulnerability lookup separate from repository access

After parsing, the Actor calls my OSV Vulnerability Scraper as a child Actor:

run = await apify_client.actor("thirdwatch/osv-vulnerability-scraper").call(
    run_input={
        "packages": packages,
        "vulnerabilityIds": [],
        "maxResultsPerPackage": 10,
    },
    timeout_secs=300,
)

Enter fullscreen mode Exit fullscreen mode

Keeping this as a separate Actor gives the OSV lookup its own input/output contract, run ID, retries, and pricing. The GitHub integration owns orchestration and delivery; it does not need to reimplement the vulnerability client.

The result is still a triage signal, not a verdict. A published advisory does not prove that a vulnerable code path is reachable in this repository. No returned advisory does not prove that the package is safe. The generated issue says both things explicitly and links each row to the upstream source.

Search before writing

The write path uses a stable title and an invisible marker:

ISSUE_TITLE = "[Dependency risk] OSV triage"
ISSUE_MARKER = "<" + "!-- thirdwatch-osv-triage --" + ">"

Enter fullscreen mode Exit fullscreen mode

Before calling issue_write, the Actor searches the target repository:

search = await session.call_tool(
    "search_issues",
    arguments={
        "query": (f'repo:{owner}/{repo} is:issue is:open in:title "{ISSUE_TITLE}"'),
        "owner": owner,
        "repo": repo,
        "perPage": 5,
        "fields": ["number", "title", "html_url", "body"],
    },
)

existing_number = find_actor_owned_issue_number(
    decode_tool_result(search),
    marker=ISSUE_MARKER,
)

Enter fullscreen mode Exit fullscreen mode

Then it selects the write method:

arguments = {
    "method": "update" if existing_number else "create",
    "owner": owner,
    "repo": repo,
    "title": ISSUE_TITLE,
    "body": issue_body,
}

if existing_number:
    arguments["issue_number"] = existing_number

await session.call_tool("issue_write", arguments=arguments)

Enter fullscreen mode Exit fullscreen mode

The body check matters. A human can independently create an issue with the same title; the Actor must not overwrite it. Only an issue containing the exact invisible marker is Actor-owned.

The stable issue is a queue, not an immutable audit log. Teams that need history should retain redacted Apify evidence exports or post timestamped comments instead. Sequential scheduled runs update one current issue. Overlapping runs for the same repository are unsupported because GitHub search and issue creation do not form a transaction; I disable schedule overlap rather than claiming concurrency-safe idempotency.

Three production runs, one issue

I used a public, fixture-only repository with this manifest:

{
  "private": true,
  "dependencies": {
    "axios": "0.21.1",
    "lodash": "4.17.20",
    "minimist": "1.2.5"
  }
}

Enter fullscreen mode Exit fullscreen mode

The repository contains no application and is explicitly marked non-deployable. Its old versions exist only to make the evidence reproducible.

Observation Create proof Hardened update proof Actor run yB9EAlGsNQAIzAfUo aqcd37hKC4cJwHu1e Published build 1.1.1 1.2.1 Manifest read package.json package.json Exact versions checked 3 3 Advisory rows returned 16 16 GitHub action Created issue 1 Updated issue 1 Parent runtime 22.5 seconds 22.7 seconds Parent platform usage about $0.00103 Not exposed by the public run record

The OSV child runs were pQQR830pVur3QojBg, i9YTbjaqUPNNuQAdd, and LO66fiaSKJoHi1MlN. After all three parent runs, GitHub still reported one issue—not three. The hardened run’s dataset was MYUPMkpFyGpVlUNLF.

The observed package-to-advisory count can change as OSV publishes or aliases records. That is another reason the issue includes a generation timestamp and child run ID instead of presenting the table as timeless truth.

What the connector replaced

Before this build, the awkward choices were:

  • clone the repository inside the Actor and inject a GitHub credential;
  • upload manifests by hand, then copy the result back to GitHub;
  • or maintain a separate automation service whose only job was credentialed glue.

The connector removes that glue without making the Actor omnipotent. A user selects an authorized connector at run time. The Actor reads only the requested paths and writes only one review artifact. A schedule can run the same contract tomorrow.

The manual step it cannot remove is judgment. A maintainer still has to confirm deployment context, read the upstream advisory, choose a compatible fixed version, and test the change. I consider that a feature: the Actor creates a better decision surface without impersonating the decision-maker.

Failure behavior I would keep in production

A connector workflow can fail at several independent boundaries. I preserve them separately:

  • a missing or unsupported manifest is returned in manifest_errors;
  • a manifest larger than 1 MB is rejected before parsing;
  • no exact versions produces NO_EXACT_VERSIONS, not a clean security result;
  • an OSV child-run failure stops the workflow before GitHub write;
  • missing connector tools fail before repository access;
  • issue labels are optional because issue_write fails when a requested label does not exist;
  • a non-dry run without the exact confirmed owner/repo is rejected;
  • a title collision without the Actor marker is never updated;
  • more than 50 advisory rows are summarized in the issue while the dataset remains the detailed evidence surface.

I also cap manifests and package counts. Repository contents and OSV output are untrusted input. Dependency names and versions pass strict allowlists; table text is escaped and truncated; only valid HTTPS advisory URLs become links. A dependency name, file string, advisory summary, or URL is data to render—not an instruction for the Actor to follow.

The part I would reuse

GitHub is replaceable here. The useful part is the bounded, repeatable handoff:

  1. declare the smallest external tool set;
  2. read only the business input needed for this run;
  3. perform deterministic work in Actors with observable run IDs;
  4. search for the previous delivery artifact;
  5. create or update exactly one human review surface;
  6. keep high-authority actions outside the automation.

The same pattern fits a release-note monitor, a data-quality report, or a scheduled compliance inventory. The connector fires where authenticated context enters and where the durable artifact leaves. Everything between those points stays testable as ordinary Actor code.

The connector gave the Actor a safe route from a real repository to a repeatable result in the stack where the team already works.

Reproduce the workflow

Use a dedicated connector with the narrowest repository and token scope your workflow supports. Review every advisory before changing production code.

Disclosure: The Actors described in this article are built and operated by Thirdwatch.

원문에서 계속 ↗