← projects

EU AI Footprint Scanner

AST-based static analysis for AI/ML library use in Python codebases, shipped end-to-end as a paid GitHub App — scanner engine, Cloudflare Worker + Container webhook pipeline, KV entitlement gate, and Lemon Squeezy billing.

status
shipped
started
2026-03
updated
2026-08-04
tags
AI/ML · Tools
repo
github.com/zhenee/argus-ai-footprint-scanner

What

A Python static analyser that walks the Abstract Syntax Tree of a codebase, finds every AI/ML library import and call, and classifies each finding into a three-tier risk model loosely inspired by the EU AI Act. Output is structured JSON listing every finding with file path, line number, library name, and risk tier — designed to feed straight into a compliance review.

It ships in two forms:

  • A CLI for ad-hoc and local runs — the scanner engine on its own, MIT-licensed and open on GitHub.
  • A GitHub App that runs the same analysis on every pull request, posts findings as a GitHub Check Run plus a summary comment, and is sold as a monthly subscription under Argus Intelligence.

Why

Most EU SMEs have no idea what AI/ML code is running inside their products. The EU AI Act’s GPAI obligations landed on 2 August 2026, and the audit asks “what AI are you using?” before anything else. Manual code audits are slow, error-prone, and require specialised knowledge. The existing compliance tools are aimed at large enterprises with dedicated GRC teams — there’s a real gap for a pragmatic, engineering-grade tool that fits in CI.

It’s also the first commercial product I’ve shipped under Argus Intelligence.

Scanner engine

  • Pure-Python stack. AST visitor from the ast stdlib + PyYAML for risk-definition config + pytest for the test harness. No heavy framework overhead; the binary path is cold-start fast.
  • AST-based detection rather than string matching — catches import openai and chained calls like openai.ChatCompletion.create() without false positives from comments or docstrings. Recursive attribute traversal handles dotted imports (google.generativeai) and walks back to the base name for chained calls.
  • Three-tier risk schema:
    • HIGH — generative LLMs and direct AI APIs (openai, anthropic, cohere, gemini, mistral, ollama)
    • LIMITED — foundation-model frameworks and orchestration (langchain, transformers, tensorflow, pytorch)
    • MINIMAL — classical ML and numerical computing (scikit-learn, numpy, pandas)
  • Configurable. Risk definitions live in a YAML file. Customers can add internal libraries to a tier or move libraries between tiers without touching the scanner code.

GitHub App architecture

The App is where the scanner engine becomes a product. Two runtimes talking to each other, both on Cloudflare:

  • Cloudflare Worker (TypeScript) — edge webhook receiver. GitHub POSTs pull-request events here. The Worker verifies the HMAC signature against the shared webhook secret, decides whether the event is one we care about (pull_request opened/synchronize/reopened, installation.created, installation_repositories.added), acks GitHub with a 202 in single-digit milliseconds via ctx.waitUntil, then hands the payload off to the scan container. Nothing GitHub-facing happens after the ack — the Worker is deliberately thin so the 10-second webhook timeout can’t bite.
  • Cloudflare Container (Python + FastAPI) — scan runtime. Receives the forwarded event, mints a 9-minute RS256 JWT from the App ID and private key, exchanges it for an installation access token, fetches the repo tarball at the PR head SHA, extracts to a temp dir, runs the scanner, and posts results back via two GitHub API calls: POST /repos/.../check-runs (the inline Check Run) and POST /repos/.../issues/.../comments (the summary comment). Stateless — the App credentials ride in per-request headers so there’s no global init and no cached secrets across container instances.

The install event handler is the load-bearing UX move: on installation.created, the container enumerates the customer’s open PRs and scans each. Without it, a fresh install on a quiet repo would look broken until the next push.

The PEM private key rides between Worker and Container as base64 in an HTTP header — real newlines in a header value throw TypeError under the Fetch spec, and finding this out at deploy time would have been miserable.

Entitlement gate

Every install has a record in a Cloudflare KV namespace keyed install:<id>, with:

{
  tier: "trial" | "starter" | "copilot" | "business" | "expired",
  expires_at: string,                 // ISO 8601
  lemon_subscription_id: string | null,
  lemon_variant_id: string | null,
  github_username: string,
  repo_count: number,
}

A reverse index user:<github_username><installation_id> lets the Lemon Squeezy webhook find the install record when a subscription event arrives.

Rules baked into the Worker:

  • Trial: 7 days, 3-repo hard cap. Trials are anti-abuse — someone could otherwise install on 50 repos and extract 7 days of free scans across all of them. Only the first 3 repos (by GitHub repo id) get scanned; PRs on repos beyond that get a “trial covers 3 repos” comment with the scan skipped entirely. It’s the only state where I actively refuse to scan.
  • Paid over plan limit: soft warn. Customers still get the full scan and findings, but the PR comment includes an “Using N repos on the Starter plan — Upgrade to Copilot for 5 / Business for 25” footer. Different asymmetry: for trials the incentive is anti-abuse; for paid customers the incentive is upgrade friction at the highest-intent moment, mid-PR-review.
  • Expired: subscribe nudge only. No findings shown; the comment is the “Subscribe →” link. Scan still runs (soft-degrade, not hard-cutoff), so a customer who pays reconnects seamlessly.

Effective tier is computed at read time from the record — a trial whose expires_at has passed reports as expired immediately, no sweeper job required.

Billing

Three tiers on Lemon Squeezy, monthly recurring, EU VAT handled by LS as Merchant of Record. Every checkout link carries checkout[custom][github_username]=<login> in the query string so the subscription event arrives with the metadata already attached — LS doesn’t support visible custom checkout fields, so the argusintelligence.ee pricing card captures the GitHub username inline (with a GitHub API round-trip to validate the account exists and a confirmation dialog) before redirect.

Plan Price Repos
Starter €49 / month 1
Copilot €99 / month up to 5
Business €149 / month up to 25

The LS webhook posts to a separate endpoint on the same Worker (POST /webhook/lemon-squeezy) with its own signing secret. Each subscription event updates the entitlement tier in KV:

LS event Tier after the event
subscription_created, _updated, _resumed, _payment_success starter / copilot / business, resolved by variant ID
subscription_cancelled, _expired, _payment_failed expired

The variant-ID → tier mapping lives as three Worker secrets — no code change to swap a variant.

What broke (deploy-time)

The App was originally deployed straight from the Cloudflare Workers Free plan, which doesn’t support Containers at all — that failed with an unhelpfully vague Unauthorized at image push. Two other blockers worth naming, both surprising:

  • Hatchling refuses to build a wheel with a git+https://… direct-reference dependency unless allow-direct-references = true is set in [tool.hatch.metadata]. Also, python:3.12-slim ships without git, so the pip clone step needed apt-get install git in the Dockerfile.
  • Later, GitHub returned 401 on anonymous public-repo access from Docker builds (an enterprise-account policy quirk on the account owning the scanner repo). Fixed by switching from git+https://… to a tarball URL, then eventually vendoring the scanner source into the container repo — cleaner, no external fetch during builds, no runtime dependency on GitHub’s rate limits.

Honest limitations

  • It’s a technical risk-discovery tool, not a legal compliance certification. The output is engineering input to a compliance review — not a substitute for one. This framing is on every page of the product site, every PR comment the App posts, and matters more than every feature combined.
  • Doesn’t detect dynamic imports like importlib.import_module("openai") — AST-invisible by design, known limitation.
  • Library-centric, not data-flow. It tells you what’s imported, not what’s being done with it. A compliance review still needs human judgement about how the library is used.
  • Three-tier model is engineering shorthand, not a literal Annex I/II mapping of the EU AI Act. Useful as a starting point, not a final classification.
  • Direct-checkout URLs on the LS storefront skip the github_username capture. Anyone who bookmarks a direct checkout URL creates an orphan subscription. Rare in practice; reconciled manually. Fixable later with a redirect rule.
  • No admin dashboard. All operational visibility is via wrangler kv key list and the Lemon Squeezy dashboard. Fine at N ≤ 20 customers; will need a real UI beyond that.

What’s next

  • Per-line Check Run annotations so findings surface inline on the PR diff view, not just as a summary.
  • Diff-against-main so the PR comment shows new findings only, not the full codebase footprint.
  • Publishing the scanner engine to PyPI so the Container drops its vendored copy.
  • GitLab CI support in v1.1.
  • An org-level dashboard for teams scanning across multiple repositories.
  • Continued expansion of risk_definitions.yml as the AI ecosystem moves.