Kurniadi Ahmad Wijaya
Forward Deployed AI Engineer
Built production-grade AI and software for banking, fintech and enterprise.
Technical Consultant, Software & AI · Metrodata (PT Mitra Integrasi Informatika)
Classic site
Skip to content

Writing

L4Oct 24, 2026EN · ID

Untrusted Code Next to Production Secrets

  • application-security
  • supply-chain-security
  • azure-container-apps
  • keda
  • case-study

A security platform should not run the code it is inspecting. An audit I commissioned showed that mine could. Processing a repository could execute that repository's own code inside the internet-facing API, in an environment that held production secrets, including the key of a GitHub App installed across the organization.

This post covers the same-day stopgap, the move to isolated workers under limited cloud permissions, a cut-over with a recorded outage of about 9 minutes, a scaling bug found during the rollout, and a later tool update that broke the stopgap's guarantee.

The audit found and reproduced it before the platform's wider rollout.

A scanner that ran inside the API

Pris.AI is the AI code security platform I built and operate at Metrodata (MII). A GitHub App connects the organization's repositories. Each pull request runs eight open-source security engines, an AI model triages the findings, and a fix agent can open a pull request with a proposed fix. As of 24 October it had posted scan results on 628 pull requests across 24 repositories.

The first version, in June, ran scans inline, in a thread of the backend API. That was a trade for permissions. My deployment rights were Contributor only, and starting a separate job needed a principal with role assignments that I could not create. The inline executor let the product ship.

It also meant repository content was handled in the same process, with the same environment, as the API that held the platform's secrets.

What the audit reproduced

In late September, before wider rollout, I commissioned a commercial-readiness audit: thirteen specialist reviews plus a red-team review and a synthesis. Its top finding was code execution in the API process.

The path was ordinary. When the fix agent upgraded a Python dependency, it ran uv lock. Without --no-build, uv may build a local project to read its metadata, which runs that project's setup.py. A repository could put any code there, and it would run with the full backend environment. The audit reproduced it.

There were related paths. Some scanners load configuration from the repository being scanned. A Checkov config in the repository could point at an external checks directory, and gitleaks automatically loads an ignore file from the scanned source, which could hide findings.

The same audit found other problems, all fixed in the same push: every Entra user could become admin, the chat agent could suppress findings, and organization-wide GitHub tokens appeared in process arguments.

The stopgap, the same morning

The first fix landed on the morning of 25 September. It was meant to stop the bleeding, not to be the boundary:

  • uv lock and uv export run with --no-build. A project that would need a build is reported as "could not upgrade safely", and the partial write is reverted. bun keeps --ignore-scripts.
  • Every scanner and fix subprocess gets an allowlisted environment. Variables named like keys, tokens and secrets, managed identity endpoints, and Azure and GitHub settings are dropped.
  • Scanners use platform-owned configs. Repository Checkov and gitleaks config files are removed from the clone, because I verified that the command-line flags alone did not stop the repository's config from loading.
  • A kill switch can turn off fix pull requests before anything is read or run.

The acceptance tests used fixture repositories whose setup.py writes a marker file. After the change, no marker was written, and a dump of a real child process's environment held no secret variables.

A flag is only as good as the tool that honors it. The real boundary had to be architectural.

Isolation without new permissions

The afternoon of the same day, the re-architecture was on a branch:

Pris.AI today, from its case study. Repository code is cloned and processed only in the Container Apps Jobs; in production the control plane refuses to run scans inline.
  1. The API puts a message on a Service Bus queue. The message carries only a one-shot claim token.
  2. KEDA sees the queue and starts an isolated Container Apps Job.
  3. The job claims its work from the API with a compare-and-swap and a lease. The claim returns a per-job bearer token and a fresh GitHub token scoped to that one repository and purpose.
  4. Inside the job, repository tools run in a sandbox: a dropped user ID, resource limits, process-group kill, and reads that do not follow symlinks. The clone is bounded, and the repository size is checked first.
  5. Results go back to the API through the job's own bearer. Malformed or unauthorized messages go to a dead-letter queue.

The control plane in production refuses inline scans with a 503, with an emergency override that has to be set on purpose.

The Contributor-only limit shaped the design. I could not grant the workers roles, so they were deployed with no managed identity at all. A validation script checks that the worker definitions are credential-free. Each job holds only what its claim returns, for one repository. The role assignments are written in Bicep behind a flag, waiting for a role grant that was still pending at my last check.

The rest of the hardening ran in parallel: signed 30-second identity assertions bound to the request body from the web tier, fixed-role access control, Entra-only sign-in and revocable server-side sessions. In total there were 14 priority items, delivered in about a day and a half on stacked branches. I wrote the specs, directed AI coding agents for much of the implementation, and reviewed, tested on Azure and merged every change.

The cut-over, and a worker that never started

The rollout ran on 26 and 27 September. The backend was down for roughly 9 minutes while it moved from inline mode to queue mode. It was an internal tool; for a customer product, that would not have been acceptable.

Then nothing scanned. No worker execution had started in production. KEDA's Service Bus scaler reads the queue's message count from the management endpoint, and that needs the Manage claim. The scale rule used the worker's Listen-only key, so every poll got 401.

The obvious fix was to give the worker a Manage key, and that would have been wrong. Instead, a separate per-queue rule with Manage rights is used only by the scaler's own authentication. The container still connects with a Listen-only key, so repository code never sees a key that can manage the queue. A dedicated scaler identity is prepared for when role assignments are possible.

After that fix, a dead-letter drill confirmed that a poisoned message lands in the dead-letter queue and that the alert email arrives.

The guarantee broke anyway

On 1 October, CI picked up a newer uv. Versions 0.12.20 and 0.12.21 ran a local project's setup.py during uv lock --no-build when the project declared dynamic metadata. The stopgap's guarantee no longer held for the fix agent's dependency upgrade.

The fix does not depend on uv anymore. A pre-check refuses projects with dynamic metadata, legacy setup.py or setup.cfg projects, and such workspace members before uv runs, and uv is pinned to 0.12.19 in the image and in CI.

This is the reason the isolation matters more than the flag. The flag is a promise from a third-party tool, and that promise changed in a minor release.

Limits, and what I would do differently

  • Threat-model the first deploy. The inline executor was a reasonable trade for permissions in June. It needed a written threat model then, not an audit three months later.
  • Plan the cut-over to avoid downtime. Running the old and new modes side by side, and moving traffic after the first queued scan succeeded, would likely have avoided the 9 minutes.
  • Prove the whole path before switching. One end-to-end check, "a queued scan starts a job and returns findings", would have caught the scaler bug before production instead of after.
  • Pin the tools that enforce safety. The --no-build guarantee depended on an unpinned tool version. uv is now pinned and the pre-check runs before it, so a new release changes nothing until someone moves the pin on purpose.
  • The audit was internal. It was broad, but it was not an independent penetration test.

The flag reduced the risk within hours. Isolation is what made the remaining risk smaller: the repository's code now runs in a job that holds a token for one repository, not in the process that holds the keys.