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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ test-build/.work/
# A lockfile committed from that directory would put the vulnerable package into the repository's
# dependency graph, where its advisories cannot be told apart from advisories about the shipped package.
examples/protect/package-lock.json
.public-types-check/
11 changes: 7 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. It also reads the `<title>` of the root `index.html` and the `name` in `package.json`, to report what the site is called | `.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 and name, where the project or build environment states them |
| `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 and name (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,15 +21,18 @@ 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. `scan --install-paths` additionally sends where each package sits in the dependency tree; it is off unless you pass it.
Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` additionally reads two declarations the project makes about itself — the `<title>` in the root `index.html` and the `name` in `package.json` — to report what the site is called; no other command reads either. `scan` transmits package names + versions, plus the site's own public address and name where the project states them — never source code, file paths, git history, or any environment variable value other than the published URL of this site. `scan --install-paths` additionally sends where each package sits in the dependency tree; it is off unless you pass it.

## 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.
- **What is sent to Patchstack is the dependency list, plus this site's public address and name** — 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.
- **`scan --install-paths` is the one exception, and it is opt-in.** It adds where each package sits in the dependency tree — repo-relative paths made of `node_modules` segments, plus a workspace directory name when a workspace pins its own copy. They are read from the lockfile's own keys or from the `node_modules` walk, **never from your source tree**: no path to a file you wrote is sent by either form of `scan`.
- Why it exists: the same package is routinely installed twice at different versions, and without the locations an advisory affecting only one of them cannot be matched to the copy your code actually loads. Node resolves an import by walking up from the importing file, so the location is what distinguishes "you are running the vulnerable copy" from "the vulnerable copy is installed but nothing reaches it". Absent them, every installed version has to be treated as if the app used it — warnings about code you never call, and protection rules pinned to routes that run the safe copy.
- Why it is off by default: it widens what leaves the machine, so it is your explicit choice and not a consequence of upgrading the package. (`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.)
- **Only `scan` looks for the address and the name.** They are resolved in the one code path that reports them, so `guide`, `status`, `login`, `uninstall`, `mark-build`, `init`, `protect`, `demo-guide` and `map` neither read the host's URL variables nor open `index.html` or `package.json` for this. `setup` and `demo` do, because both run `scan`.
- **The address is the one your visitors use, and, apart from the tool's own `PATCHSTACK_*` settings, 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. An address that is not how the public reaches a website is dropped: any IP address (in either family, however it is written), any single-label host such as `localhost` or `production`, and the reserved suffixes (`.local`, `.internal`, `.test`, `.invalid`, `.home.arpa`, …). A `url` you set explicitly that fails those checks is refused with an error rather than replaced by a guess. 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.
- **The name is read from your project, never from the host environment.** `name` in `.patchstackrc.json` (or `PATCHSTACK_SITE_NAME`) if you set one; otherwise the `<title>` of the project's root `index.html` (`public/index.html` if there is no root one), read from the file as text — a title your app sets from script is not seen; otherwise the `name` in `package.json`, unless it is a template placeholder such as `vite_react_shadcn_ts`. It is omitted when nothing qualifies, and it only ever fills in a site that has no name yet — a name set in the dashboard is never replaced.
- **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 @@ -226,11 +226,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",
"name": "ToDo Application"
}
```

That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile.
That's the entire payload: the package names and versions from your lockfile, plus what your project says about the site itself — its public address and its name. 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. An address that is not how the public reaches a website is refused: any IP address, any single-label host such as `localhost`, and the reserved suffixes (`.local`, `.internal`, `.test`, `.invalid`). If you set `url` yourself and it fails those checks, `scan` stops and says so rather than sending a different address.

The name is what the dashboard calls the site. It is taken from `name` in `.patchstackrc.json` (or `PATCHSTACK_SITE_NAME`) if you set one; otherwise from the `<title>` of the project's root `index.html`; otherwise from the `name` in `package.json`, unless that is a template placeholder such as `vite_react_shadcn_ts`. It is left out when nothing qualifies. Those two files are read only by `scan` — the commands that never post a manifest do not open them.

You can see exactly what would be sent, without sending it, by running `npx @patchstack/connect scan --dry-run`: the preview it prints is the request body itself. Patchstack only applies either field to a site that does not have one yet: it never re-points a site whose address is real, and never replaces a name set in the dashboard.

### `scan --install-paths` (opt-in)

Expand Down
15 changes: 14 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildWirePayload } from './normalize.js';
import { computeManifestChecksum } from './checksum.js';
import {
postInputMap,
buildManifestBody,
DEFAULT_ENDPOINT,
buildClaimUrl,
fetchSiteStatus,
Expand Down Expand Up @@ -366,6 +367,8 @@ async function runScan(
cwd: process.cwd(),
cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
cliEndpoint: getStringFlag(args.flags, 'endpoint'),
// The one command that reports them, so the one command that resolves them.
detectSiteIdentity: true,
});
const manifest = await scanLockfile(process.cwd());
for (const warning of manifest.warnings ?? []) {
Expand Down Expand Up @@ -404,6 +407,16 @@ async function runScan(
);
}

// Ahead of the --dry-run return, so a preview says what a real run would report. This is the part of
// the payload someone might disagree with, and it is easier to disagree with here than in the dashboard.
const body = buildManifestBody(config, payload);
if (typeof body.url === 'string') {
console.log(`Reporting this app's address as ${body.url}.`);
}
if (typeof body.name === 'string') {
console.log(`Reporting this app's name as "${body.name}".`);
}

if (dryRun) {
console.log('');
if (config.siteUuid === null) {
Expand All @@ -412,7 +425,7 @@ async function runScan(
console.log(`--dry-run: not posting to Patchstack (site UUID ${config.siteUuid}).`);
}
console.log('Payload preview:');
const preview = JSON.stringify(payload, null, 2).split('\n');
const preview = JSON.stringify(body, null, 2).split('\n');
console.log(preview.slice(0, Math.min(preview.length, 30)).join('\n'));
if (preview.length > 30) {
console.log(` ... (${preview.length - 30} more lines)`);
Expand Down
25 changes: 24 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,29 @@ export async function fetchSiteStatus(config: Config): Promise<SiteStatus> {
}
}

/**
* The whole body of a manifest push.
*
* Built here rather than inline at the `fetch` so that `--dry-run` prints the object that would be sent
* instead of a subset of it: a preview that omits fields is a preview of a different request.
*
* `url` and `name` ride along on every push, not only the one that provisions the site. A site created
* by a scan from a developer machine carries a placeholder address until some later push reports a real
* one, and that push is a re-scan. Patchstack applies either field only to a site that still lacks it,
* so a repeated push does not re-point an address or replace a name. Both are omitted when they are not
* strings, which is what a caller that built its own `Config` without them sends.
*/
export function buildManifestBody(config: Config, payload: WirePayload): Record<string, unknown> {
return {
...payload,
environment: config.environment,
...(typeof config.siteUrl === 'string' && config.siteUrl !== '' ? { url: config.siteUrl } : {}),
...(typeof config.siteName === 'string' && config.siteName !== ''
? { name: config.siteName }
: {}),
};
}

export async function postManifest(
config: Config,
payload: WirePayload,
Expand All @@ -284,7 +307,7 @@ export async function postManifest(
Accept: 'application/json',
'User-Agent': '@patchstack/connect',
},
body: JSON.stringify({ ...payload, environment: config.environment }),
body: JSON.stringify(buildManifestBody(config, payload)),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (cause) {
Expand Down
Loading
Loading