Skip to content

Deploy scans

Every deploy is scanned before it goes live, whether it comes from jiayang deploy, an agent, GitHub Actions, the dashboard or the API clients. The scan looks for two things:

  • Keys and tokens left in what you deploy: cloud keys, private keys, API tokens, database URLs with a password in them.
  • Packages your lock files pin that have a published vulnerability, checked against OSV, the open vulnerability database.

What a deploy with findings does is the workspace’s choice: it goes live with a warning (the default), or it’s refused. A few kinds of key are always refused, whatever the workspace chooses.

Deploy Read for keys Read for packages
A Worker or framework build Every module of its code, on our side, and the files it serves The project’s lock files
A static site Each file, on our side, once, when it’s first uploaded The project’s lock files
A container The image’s layers and its config, read by jiayang deploy before the push The project’s lock files, and the ones in the image

For a Worker or a site, we read what you upload ourselves, so a modified client can’t skip the check. We can’t see inside an image, so for a container the CLI reads it where it was built and sends us what it found: never the files, only where each finding is and a preview of it. It reads the files your app added, and leaves out the operating system’s paths (/usr, /lib, /var/lib and the like) and other people’s packages (node_modules, site-packages). The image’s environment and its build steps are read too, which is where a --build-arg token ends up.

A key of a kind that’s always refused stops the deploy there, before the image is pushed. A layer compressed with zstd isn’t read, and reading an image stops after 10 seconds. The report says when either happened.

An image deployed without a scan, from an older CLI or the API called directly, wasn’t read by anyone. Its report says 1 file not read rather than clean, and a workspace that blocks on either kind of finding refuses it.

Each kind of key is found by what it always has, a prefix like AKIA or sk_test_, and then checked for the length and characters the real thing has. A value found only by the name before it (api_key = "...") has to look random enough to be a key, so password: "Password" or "your-key-here" aren’t findings.

These are always refused, whatever the workspace’s policy or the allow file say, as they were before scans:

  • private keys (-----BEGIN ... PRIVATE KEY-----)
  • live Stripe keys, and GitHub, GitLab, Slack bot and user, npm, DigitalOcean, Shopify and Anthropic tokens
  • our own credentials: CLI sessions, bypass tokens, agent and deploy credentials

The rest follow the policy: AWS access key ids and secret keys, Google API keys, OpenAI keys, Stripe test keys, SendGrid, Twilio, Mailgun, Hugging Face, PyPI, Linear and Brevo keys, Azure storage keys, Slack and Discord webhook URLs, webhook signing secrets, Supabase service role keys, database URLs with a password, and values named like a secret.

A Firebase web config holds a Google API key that is public by design. If yours is one, add it to the allow file.

A finding never carries the key. You see the rule that found it, the file and line, the first few characters with the length (AKIAZ7QW… (20 characters)), and a fingerprint: 16 characters of a hash that name that value and no other.

The scan reads these lock files, in the deployed folder or the nearest folder above it, up to the top of the repository:

Ecosystem Lock files
npm package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock (1 and Berry)
Python poetry.lock, uv.lock, Pipfile.lock, requirements*.txt (pinned == lines only)
Rust Cargo.lock
Go go.mod, or go.sum without one

Packages a lock file marks as development-only (npm’s dev, Pipfile’s develop) are left out, since they aren’t deployed. In a container image we also read Python packages installed with pip, from their .dist-info folders. The image’s operating system packages aren’t checked.

Only each package’s ecosystem, name and version go to OSV’s API (api.osv.dev), never your code, your file names or your app’s name. A package the lock file says came from anywhere but the public registry isn’t sent either: a private registry or index, a tarball, a path, git or a workspace of your own. Nor is a Go module your GOPRIVATE, GONOPROXY or GONOSUMDB names. A requirements.txt that installs from an index of its own isn’t read. OSV asks for no key and has no rate limit (its API docs, read 7 October 2026). We keep its answers for 6 hours, and an advisory for a day, so a deploy after the first rarely waits on it.

A vulnerability’s severity is the advisory’s own (GitHub’s CRITICAL, HIGH, MODERATE, LOW), or from its CVSS 3 score when it has none: 9.0 and up is critical, 7.0 high, 4.0 moderate. One with neither, which includes one rated only by a CVSS 4 vector, is unrated. A workspace that blocks on critical vulnerabilities refuses an unrated one too, since nobody can say it isn’t critical: upgrade the package, or accept the advisory by its id in the allow file. The report names the first version that fixes each one, when the advisory says.

If OSV doesn’t answer within 4 seconds, the deploy goes ahead and the scan says the packages weren’t checked. In a workspace that blocks on critical vulnerabilities, nobody can say the deploy has none, so it’s refused with Try again in a minute. A deploy with no lock file never waits on OSV.

A lock file that can’t be parsed is a file not read. The deploy goes live with a warning, or in a workspace that blocks on critical vulnerabilities it’s refused, naming the file.

jiayang deploy prints what the scan found under the app’s address:

Version 4 (3f2a9c1b7d0e) is live.
https://payroll--acme.jiayang-apps.cloud
Scan: 1 possible secret, 1 critical vulnerability (12 packages checked).
src/config.js:3: an AWS access key id, AKIAZ7QW… (20 characters). Fingerprint 3f9a1c2e7b4d0a11
minimist 1.2.5: critical GHSA-xvch-5gv4-984h. Prototype Pollution in minimist. Fixed in 1.2.6.
Details: jiayang scans acme/payroll --version 4

A clean deploy says it in one line, like Scan: nothing found (12 packages checked). With --json, the version carries scan, a summary, and scan_report, the whole report, when it found anything.

In CI (when CI or GITHUB_ACTIONS is set), each finding is said by file, line and kind only, without the preview or the fingerprint. A public repository’s logs are open to anyone, and a fingerprint is a hash of the value, which a weak password can be found from. Run jiayang scans where you work to see them.

A refused deploy makes no version. It says why, then what was found:

error: This workspace doesn't deploy code with secrets in it, and this has 1 that looks like one (src/config.js:3: an AWS access key id). Move it to `jiayang secret set`. If one isn't a secret, add its fingerprint to .jiayang-allow.
Scan: refused. 1 possible secret (12 packages checked).
src/config.js:3: an AWS access key id, AKIAZ7QW… (20 characters). Fingerprint 3f9a1c2e7b4d0a11
Details: jiayang scans acme/payroll --id 7c9e6679-7425-40de-944b-e07fc1f90ae7

Move a key to a secret, which we add to your app’s outbound requests so the code never holds it, and rotate it: every earlier version that carried it still has it. Upgrade a vulnerable package to the fixed version, update the lock file, and deploy again.

jiayang scans reads scans back:

Terminal window
jiayang scans acme/payroll # the latest, refused deploys among them
jiayang scans acme/payroll --version 4 # one version's, whole
jiayang scans acme/payroll --id <id> # a refused deploy's, by the id it printed

An agent deploying through jiayang mcp gets the same findings, and is told to fix them, not to add them to the allow file itself. Its deploy_scans tool reads a scan whole.

In GitHub Actions, the deploy action sets a scan output and adds a warning to the run when the scan found something. A refused deploy fails the step.

The app’s Versions tab has a Scan column. Click a version’s to read its report. Deploys the scan refused are listed under the versions. A static site uploaded from the dashboard sends the lock files and .jiayang-allow at the top of its folder along with it. The API clients send only the ones you pass them as scan: without lock files, no package is checked.

A workspace’s owners and admins choose, under Settings, Deploy scans, for secrets and for critical vulnerabilities apart:

  • Warn, the default: the deploy goes live and says what it found.
  • Block: the deploy is refused. For vulnerabilities, a critical or unrated one refuses it, or packages that couldn’t be checked: OSV didn’t answer in time, or a lock file couldn’t be parsed.

Under block, what the scan couldn’t read is refused too, since nobody can say it holds nothing: files uploaded before scans began that a deploy didn’t get to in time (try again in a minute), more than 1,000 different values that look like keys, and an image deployed without a scan.

From a terminal:

Terminal window
jiayang workspace scan-policy acme # show it
jiayang workspace scan-policy acme --secrets block --vulnerabilities block

A CLI session or an agent can turn blocking on, but not off. Turning it off is done in the dashboard, so a leaked token can’t change the workspace’s policy. Any change is in the audit log as workspace.update.

The policy doesn’t make a deploy credential safe to leak. Whoever can deploy an app sends its allow file with the deploy, and a rule or path line accepts every finding it names, so someone holding a leaked token can deploy past a block that way. Each finding an allow file accepts is in the audit log, as app.scan with how many were accepted, and the report lists every one.

A rollback isn’t a new deploy, so it isn’t scanned again: each version keeps the scan it had when it was deployed.

A .jiayang-allow file at the top of the project accepts findings someone has looked at. One per line, with # for a comment:

# Firebase's web key is public by design.
rule google-api-key
# Test fixtures, never served.
path tests/fixtures/**
3f9a1c2e7b4d0a11
GHSA-xvch-5gv4-984h
  • A bare 16-character fingerprint, as a deploy prints it, accepts that value wherever it is.
  • An advisory id (GHSA-..., CVE-..., PYSEC-..., RUSTSEC-..., GO-...) accepts that vulnerability, by any of its names.
  • rule <name> accepts every finding of a rule.
  • path <pattern> accepts every finding in the files it matches: * within a name, ** across folders. A file can have up to 50 of these, each up to 128 characters with up to 8 stars.

An accepted finding doesn’t warn or refuse, and the report still lists it, marked accepted, so the people reading it see what was waived. A line the file doesn’t understand stops the deploy before anything is built, naming the line. The always-refused kinds can’t be accepted.

A scan that found something, or whose findings the allow file accepted, is in the audit log as app.scan, with the version and how many of each. A refused deploy is app.deploy_blocked. Both are webhook and Slack events, so a security team can hear of them as they happen.

Packages looked up in one deploy 5,000
Lock files sent whole from the dashboard or an API client 8, 8 MiB together
A file read for keys 4 MiB on our side, 2 MiB in an image. The report says how many were bigger.
Different values that look like keys, in one deploy 1,000 checked
Findings kept in one report 100 secrets and 200 vulnerabilities. The counts cover all of them.
The allow file 64 KiB, with up to 50 path lines
Refused deploys kept per app the newest 50

The scan adds about a quarter of a second to a deploy of a 32 MiB bundle, and up to 4 seconds the first time a package list is looked up. Reading a container image’s layers takes about a second per few hundred MB, and stops at 10 seconds.