Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Every command at a glance — what it does, whether it reads your source, what i

| Command | What it does | Reads your source? | Writes to your project | Sends over the network |
|---|---|---|---|---|
| `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree | `.patchstackrc.json` (public: site UUID + settings); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions |
| `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | No | Config, widget tag, production marker, guard files, `package.json` scripts | Package names + versions (via `scan`) |
| `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree | `.patchstackrc.json` (public: site UUID + settings); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions; this site's public address, where the build environment publishes one |
| `setup` | One bounded command: `scan` → manage the widget → install + verify `protect` → wire the install/build scans. Never runs the project build. | No | Config, widget tag, production marker, guard files, `package.json` scripts | Package names + versions and the site's public address (via `scan`) |
| `map` | Local, read-only attack-surface analysis (entry points → inputs → sinks → evidence-backed flows). Never run by another command. | **Yes** — via the app's own TypeScript | Nothing (only the file named by `--out`) | Nothing — **unless `--upload`**: structure only (routes, parameter names, the package behind each sink, file:line). Never source code or env values |
| `protect` | Install the always-on runtime guard; auto-wire known stacks, or scaffold a generic guard + print a wiring plan. `--check` verifies the guard is wired (exit 1 if not); `--demo` seeds a broad sample rule set. Runs automatically **only** via `setup` — never by `scan`, `guide`, `status`, or `mark-build`. | No — writes guard files, does not analyze your code | Guard/framework files (e.g. `middleware.ts`, `src/patchstack/`) | Nothing |
| `demo node-serialize` | Production-backed walkthrough: confirm the vulnerable package is present, scan, wait for live rule `18843`, install + verify the guard, print test requests. Does not install the package or start/restart the app. | No | Same files as `scan` + `protect` | `scan` payload; polls the public Pulse rules endpoint (never the printed test requests) |
Expand All @@ -21,12 +21,13 @@ Every command at a glance — what it does, whether it reads your source, what i
| `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.local.json` on approval | Device-code request + approval poll |
| `uninstall` | Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does **not** touch local files. | No | Nothing local | Removal signal |

Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` transmits nothing but package names + versions — never source code, env var values, file paths, or git history.
Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` transmits package names + versions and your site's own public address — never source code, file paths, git history, or any environment variable that is not the published URL of this site.

## Package and command behavior

- Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata.
- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
- **What is sent to Patchstack is the dependency list plus this site's public address** — the dependencies are read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no file paths, no git history is ever transmitted. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
- **The address is the one your visitors use, and it is the only env var value read.** A site provisioned by a scan from a developer machine has no address, so the dashboard shows a placeholder and Patchstack cannot check that the published page still carries what was scanned. `scan` therefore sends `url` when — and only when — it can know it: `url` in `.patchstackrc.json` or `PATCHSTACK_SITE_URL` if you set one, otherwise the single variable a host publishes to name its own **production** URL (`VERCEL_PROJECT_PRODUCTION_URL` on a Vercel production deployment, Netlify's `URL` in the production context, `RENDER_EXTERNAL_URL`, `RAILWAY_PUBLIC_DOMAIN` in a production environment). Preview and branch deployments are excluded, as are hosts that publish no production signal, and an address that could not be a published site (`localhost`, a private network) is dropped. When nothing qualifies, `url` is omitted from the payload rather than guessed. Patchstack only ever applies it to a site that still has no address; it never re-points a site whose address is already real.
- **One command reads source files:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. No other command reads source (`protect` writes guard files but does not analyze your code).
- **`scan` makes up to two source edits, both in the project's root shell:** the disclosure widget's `<script>` tag, and the production marker. Neither runs on `--dry-run`, both are idempotent, both leave a pre-existing manual install untouched, and `"widget": false` in `.patchstackrc.json` disables both.
- The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID.
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,19 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
{ "name": "axios", "version": "1.6.0" },
{ "name": "lodash", "version": "4.17.15" },
{ "name": "lodash", "version": "4.17.21" }
]
],
"url": "https://your-app.example.com"
}
```

That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. (The `map` command reads source files locally to report your attack surface; it transmits nothing unless you pass `--upload`, which sends that structural description — route paths, parameter names, the dependency behind each sink, and file/line locations, never file contents — to your own site's endpoint so rules can be pinned to your real parameter names.) Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
That's the entire payload: the package names and versions from your lockfile, plus your site's own public
address. No source code, no file paths, no secrets. The address is included so the site in your dashboard
shows where it lives instead of a placeholder, and so Patchstack can check the published page still carries
what was scanned. It is taken from `url` in `.patchstackrc.json` (or `PATCHSTACK_SITE_URL`) if you set one;
otherwise from the one variable a host publishes to name its own production URL — `VERCEL_PROJECT_PRODUCTION_URL`
on a Vercel production deployment, Netlify's `URL` in the production context, `RENDER_EXTERNAL_URL`,
`RAILWAY_PUBLIC_DOMAIN` in a production environment. No other environment variable's value is read, and
`url` is left out entirely when none of those says anything — a build on your own machine sends no address. (The `map` command reads source files locally to report your attack surface; it transmits nothing unless you pass `--upload`, which sends that structural description — route paths, parameter names, the dependency behind each sink, and file/line locations, never file contents — to your own site's endpoint so rules can be pinned to your real parameter names.) Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)

## Supported lockfiles

Expand Down
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,12 @@ async function runScan(
console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…');
}

// Said out loud because it is the one value in the payload a person might disagree with, and a site
// that ends up pointing somewhere else is otherwise a silent wrong answer in their dashboard.
if (config.siteUrl !== null) {
console.log(`Reporting this app's address as ${config.siteUrl}.`);
}

const response = await postManifest(config, payload);

// The server always returns the UUID. If we didn't have one, persist it so
Expand Down
11 changes: 10 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,16 @@ export async function postManifest(
Accept: 'application/json',
'User-Agent': '@patchstack/connect',
},
body: JSON.stringify({ ...payload, environment: config.environment }),
// `url` rides along on every push, not just the provisioning one: a site created from a laptop
// scan holds a placeholder address until a deployed build reports a real one. Patchstack only
// adopts it over that placeholder, and only because this request carried the site's credential.
body: JSON.stringify({
...payload,
environment: config.environment,
...(typeof config.siteUrl === 'string' && config.siteUrl !== ''
? { url: config.siteUrl }
: {}),
}),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (cause) {
Expand Down
14 changes: 14 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile, writeFile, chmod } from 'node:fs/promises';
import path from 'node:path';
import { PatchstackError, type Config, type Environment } from './types.js';
import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from './client.js';
import { detectSiteUrl, normaliseSiteUrl } from './site-url.js';

const CONFIG_FILENAME = '.patchstackrc.json';

Expand Down Expand Up @@ -35,6 +36,12 @@ interface ConfigFile {
timeoutMs?: number;
environment?: string;
widget?: boolean;
/**
* Where this app is published. Committed with the UUID rather than kept secret — it is the address
* the site already serves to everyone. Set it for platforms whose build environment does not say
* which deployment is the production one.
*/
url?: string;
}

export interface ResolveConfigOptions {
Expand Down Expand Up @@ -100,10 +107,16 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf
// without re-provisioning: today both hold the same credential.
const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;

// A person naming their own site outranks any inference from the build environment; detection only
// answers for the platforms that publish a production URL of their own.
const siteUrl =
normaliseSiteUrl(fromEnv.url ?? fromFile.url) ?? detectSiteUrl(process.env)?.url ?? null;

return {
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
siteUrl,
endpoint,
timeoutMs,
environment,
Expand Down Expand Up @@ -373,6 +386,7 @@ function readEnv(): ConfigFile {
const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
return {
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? undefined,
url: process.env.PATCHSTACK_SITE_URL ?? undefined,
apiKey: process.env.PATCHSTACK_API_KEY ?? undefined,
pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? undefined,
endpoint: process.env.PATCHSTACK_ENDPOINT ?? undefined,
Expand Down
125 changes: 125 additions & 0 deletions src/site-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Where the app this manifest describes is published.
*
* Patchstack provisions a site from the first manifest, and a manifest posted from a laptop carries no
* address — so those sites are created with a synthetic `*.placeholder.invalid` host. That address is not
* cosmetic: it is what Patchstack fetches to check the live build still carries what was scanned, and what
* the dashboard shows. Something has to replace it, and the manifest push is the only thing that reports
* from inside the deployment while holding the site's own credential.
*
* Reporting a wrong address is worse than reporting none, because the address is adopted once and then
* belongs to the site. So a URL is only derived from a build environment that says, in its own variables,
* that this build is the production one — never from a per-deployment preview URL, and never from a host
* that could not be a published site (a laptop, a private network). A platform that publishes no such
* signal is left alone: set `url` in `.patchstackrc.json` (or `PATCHSTACK_SITE_URL`) and that wins over
* everything here.
*
* Unlike the hosting fingerprint in `stack.ts`, which reports variable NAMES only, this reads values. It
* reads exactly the ones below, each of which is a public address the deployed site serves to every
* visitor.
*/

/** Hosts that cannot be a published site, whatever a build environment claims. */
function isUnpublishableHost(hostname: string): boolean {
const host = hostname.toLowerCase();

return (
host === 'localhost' ||
host === '0.0.0.0' ||
host === '[::1]' ||
host === '::1' ||
host.endsWith('.local') ||
host.endsWith('.localhost') ||
// The value Patchstack itself uses for "no address known" — storing it as one would be circular.
host.endsWith('.placeholder.invalid') ||
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host)
);
}

/**
* Reduce a reported address to the `scheme://host[:port]` Patchstack stores, or null when it is not one.
*
* Several platforms report a bare hostname, so a missing scheme is filled in rather than rejected. A path
* is dropped: the site's address is its origin, and a build variable that happens to include one is still
* naming the same site.
*/
export function normaliseSiteUrl(value: string | undefined | null): string | null {
const trimmed = (value ?? '').trim();
if (trimmed === '') return null;

const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;

let parsed: URL;
try {
parsed = new URL(withScheme);
} catch {
return null;
}

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
if (parsed.hostname === '' || isUnpublishableHost(parsed.hostname)) return null;

const origin = `${parsed.protocol}//${parsed.host}`;

// Patchstack stores the address in a 191-character column; an origin near that length is not one.
return origin.length <= 191 ? origin : null;
}

/**
* The platforms that publish a production address AND a way to know the current build is the production
* one. Both halves are required: a deploy URL without that signal names a preview, and a preview URL
* adopted as the site's address sends every later check to the wrong place.
*/
const PRODUCTION_URL_SOURCES: ReadonlyArray<{
platform: string;
read: (env: NodeJS.ProcessEnv) => string | undefined;
}> = [
{
// VERCEL_URL is per-deployment and changes every build, so it is deliberately not read here.
platform: 'vercel',
read: (env) => (env.VERCEL_ENV === 'production' ? env.VERCEL_PROJECT_PRODUCTION_URL : undefined),
},
{
// `URL` is generic enough to belong to anything, so it counts only alongside Netlify's own markers.
platform: 'netlify',
read: (env) => (env.NETLIFY === 'true' && env.CONTEXT === 'production' ? env.URL : undefined),
},
{
platform: 'render',
read: (env) =>
env.RENDER === 'true' && env.IS_PULL_REQUEST !== 'true' ? env.RENDER_EXTERNAL_URL : undefined,
},
{
platform: 'railway',
read: (env) =>
env.RAILWAY_ENVIRONMENT_NAME === 'production' ? env.RAILWAY_PUBLIC_DOMAIN : undefined,
},
];

export interface DetectedSiteUrl {
/** The `scheme://host[:port]` to report. */
url: string;
/** Which build environment it came from, for the CLI to say so out loud. */
platform: string;
}

/**
* The production address this build environment reports, or null when it reports none.
*
* Cloudflare Pages is a deliberate omission: `CF_PAGES_URL` is the deployment's own URL and the build
* environment gives no way to tell a production deployment from a branch one, so there is nothing here
* that could be adopted safely. Those projects set `url` explicitly.
*/
export function detectSiteUrl(env: NodeJS.ProcessEnv = process.env): DetectedSiteUrl | null {
for (const source of PRODUCTION_URL_SOURCES) {
const url = normaliseSiteUrl(source.read(env));
if (url !== null) {
return { url, platform: source.platform };
}
}

return null;
}
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export interface Config {
* Falls back to `apiKey` when unset. Prefer `PATCHSTACK_PULSE_AUTH`.
*/
pulseAuth: string | null;
/**
* Where this app is published, reported alongside the manifest so a site provisioned without an
* address can learn one from a push that carries its credential. `null` when nothing reliable is
* known — a laptop build, or a platform that publishes no production URL.
*/
siteUrl: string | null;
endpoint: string;
timeoutMs: number;
/** Environment to report the manifest under. Defaults to 'production'. */
Expand Down
Loading
Loading