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
11 changes: 6 additions & 5 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
<script src="https://cdn.patchstack.com/patchstack-widget.js" data-site-uuid="<SITE_UUID>" defer></script>
```

Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. The credentials are the opposite, and `scan` writes both of them for you — **there is no manual step, and you should never invent or ask the user for these values**:
Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The site UUID is public by design — it ships in client-side HTML and is not a secret. The credential is the opposite, and `scan` writes it for you — **there is no manual step, and you should never invent or ask the user for this value**:

- `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — authenticates block-log reporting through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.
- `pulseAuth` (also `PATCHSTACK_PULSE_AUTH`) — authenticates Pulse ingest: the manifest, the attack-surface map and package removal. Exchanged for a short-lived token rather than sent directly. Falls back to `apiKey` when absent, so older projects keep working.
- `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — one credential for both paths. It authenticates **Pulse ingest** (manifest, attack-surface map, package removal), where it is exchanged for a short-lived token rather than sent directly, and **block-log reporting** through the connector `POST /api/logs/log`, so "Threats blocked" fills in the dashboard.

Both are server-only. Never put either in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` / `PATCHSTACK_PULSE_AUTH` in production; `.patchstackrc.json` is fine for local DX. If a credential is lost, `npx @patchstack/connect login` recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with `PATCHSTACK_TELEMETRY=off`. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.
It is server-only. Never put it in the widget tag, client bundles, or public env vars (`NEXT_PUBLIC_*`, etc.). Prefer `PATCHSTACK_API_KEY` in production; `.patchstackrc.json` is fine for local DX. If it is lost, `npx @patchstack/connect login` recovers it via dashboard approval — do not delete the file and re-provision, which would create a second site. Opt out of reporting with `PATCHSTACK_TELEMETRY=off`. If the project must not carry the widget, persist `"widget": false` in `.patchstackrc.json`; otherwise the next scan re-adds it.

A `pulseAuth` field is still honoured if a project has one, and `PATCHSTACK_PULSE_AUTH` still overrides it, for deployments that authenticate Pulse ingest with a different credential from block-logs. Do not add either yourself: they are unnecessary when the two share one credential, which is the default.

4. **Install and verify runtime protection:**

Expand All @@ -102,7 +103,7 @@ Both are server-only. Never put either in the widget tag, client bundles, or pub
- The CLI never opens the dashboard link and never asks for Patchstack credentials.
- Label hosted workspace scans with `PATCHSTACK_ENVIRONMENT=sandbox` in that process only. Leave production builds unset (the default is `production`) and never commit a sandbox label into files shared with production.
- If a step fails, stop and report it. Don't proceed with placeholders.
- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH` as env vars instead. Precedence: CLI flag → env var → `.patchstackrc.json`. `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment.
- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_API_KEY` as env vars instead. Precedence: CLI flag → env var → `.patchstackrc.json`. `login` is interactive and refuses to run in CI, so CI always takes its credential from the environment.

## Verifying the install

Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,23 @@ Environment variables:
{
"siteUuid": "550e8400-e29b-41d4-a716-446655440000",
"apiKey": "…",
"pulseAuth": "…",
"widget": true
}
```

`"widget"` is optional and defaults to `true`; set it to `false` to stop the connector from managing the disclosure-widget tag (see *The disclosure widget*).

**You do not write `apiKey` or `pulseAuth` yourself.** The first `scan` provisions the site and the connector saves both, so setup needs no manual step. They hold the same value today and exist as separate fields so Pulse ingest and block-log reporting can diverge later.
**You do not write `apiKey` yourself.** The first `scan` provisions the site and the connector saves it, so setup needs no manual step.

The site UUID identifies the site and is **not** a secret — the disclosure widget ships the same UUID in client-side HTML.

`apiKey` and `pulseAuth` **are** secrets. `apiKey` authenticates block-log reporting; `pulseAuth` authenticates Pulse ingest (manifest, attack-surface map, package removal) and is exchanged for a short-lived token rather than sent directly. Keep both out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`). For deploys, prefer `PATCHSTACK_API_KEY` and `PATCHSTACK_PULSE_AUTH` in the platform's secret store over the committed file.
`apiKey` **is** a secret. One credential authenticates both paths: Pulse ingest (manifest, attack-surface map, package removal), where it is exchanged for a short-lived token rather than sent directly, and block-log reporting. Keep it out of the widget tag, client bundles and public env vars (`NEXT_PUBLIC_*`). For deploys, prefer `PATCHSTACK_API_KEY` in the platform's secret store over the committed file.

If a credential is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
If it is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.

In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_PULSE_AUTH`. Precedence is CLI flag → env var → `.patchstackrc.json`.
In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` and `PATCHSTACK_API_KEY`. Precedence is CLI flag → env var → `.patchstackrc.json`.

A `pulseAuth` field is still read if present, and `PATCHSTACK_PULSE_AUTH` still overrides, for deployments that authenticate Pulse ingest with a different credential from block-logs. Neither is written by default, and neither is needed when the two share one.

### Sandbox and production manifests

Expand Down
10 changes: 5 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
resolveDemoScenario,
waitForDemoRule,
} from './demo.js';
import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
findHtmlFiles,
Expand Down Expand Up @@ -126,7 +126,7 @@ Options (for demo and demo-guide):
Environment:
PATCHSTACK_SITE_UUID Site UUID
PATCHSTACK_API_KEY WP-format site API key for block-log reporting (never put in the widget)
PATCHSTACK_PULSE_AUTH Credential for authenticated Pulse ingest (defaults to PATCHSTACK_API_KEY)
PATCHSTACK_PULSE_AUTH Only if Pulse ingest uses a different credential from block-logs
PATCHSTACK_TELEMETRY Set to off to disable block-log reporting
PATCHSTACK_API_BASE API origin for /oauth/token and /api/logs/log (default: https://api.patchstack.com)
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
Expand Down Expand Up @@ -404,10 +404,10 @@ async function runScan(
console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`);
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
// One credential for both paths: Pulse resolution falls back to apiKey, so
// a second copy under pulseAuth bought nothing except an obligation to keep
// the two in step. Never printed — only the path it landed in.
const target = await persistApiKey(process.cwd(), response.api_key);
// Written to both fields so the Pulse and block-log paths can diverge later
// without a re-provision. Never printed — only the path it landed in.
await persistPulseAuth(process.cwd(), response.api_key);
console.log(`Saved API key to ${target} (authenticates Pulse ingest and block-log reporting; keep out of the public widget).`);
}

Expand Down
18 changes: 13 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,25 @@ export async function persistSiteUuid(cwd: string, siteUuid: string): Promise<st
}

/**
* Persist the WP-format api_key issued at provision (for connector log auth).
* Never embed this value in the public disclosure widget.
* Persist the WP-format api_key issued at provision. Authenticates both the
* Pulse endpoints and connector log reporting. Never embed it in the public
* disclosure widget.
*
* Drops any `pulseAuth` written by an earlier version. That field resolves
* ahead of `apiKey`, so leaving a copy behind after the credential changes
* would leave Pulse authenticating with the value the server just replaced.
*/
export async function persistApiKey(cwd: string, apiKey: string): Promise<string> {
const existing = await readConfigFile(cwd);
const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
return writeConfigFile(cwd, { ...existing, apiKey });
}

/**
* Persist the credential used for the authenticated Pulse endpoints.
* Kept separate from `apiKey` so block-log auth is never disturbed.
* Persist a Pulse-specific credential.
*
* Only needed when Pulse ingest and block-log reporting must use *different*
* credentials; they share one today, so `persistApiKey` covers both. Retained
* for callers that separate them.
*/
export async function persistPulseAuth(cwd: string, pulseAuth: string): Promise<string> {
const existing = await readConfigFile(cwd);
Expand Down
3 changes: 1 addition & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { postManifest } from './client.js';
import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig } from './config.js';
import { persistApiKey, persistSiteUuid, resolveConfig } from './config.js';
import type { Config, Manifest, StoreManifestResponse } from './types.js';

export { scanLockfile, detectLockfile } from './parsers/index.js';
Expand Down Expand Up @@ -63,7 +63,6 @@ export async function scanAndReport(
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
await persistApiKey(cwd, response.api_key);
await persistPulseAuth(cwd, response.api_key);
}

return {
Expand Down
11 changes: 5 additions & 6 deletions src/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { persistApiKey, persistPulseAuth } from './config.js';
import { persistApiKey } from './config.js';
import type { Config } from './types.js';

/**
Expand Down Expand Up @@ -104,11 +104,10 @@ export async function login(
return { status: 'failed', message: 'Patchstack approved the request but returned no credential.' };
}

// Approving rotates the site's single OAuth secret, which block-log
// reporting also authenticates with. Both fields must therefore be
// refreshed — writing only pulseAuth would leave apiKey holding a secret
// the server has just invalidated, silently breaking block-logs.
await persistPulseAuth(process.cwd(), apiKey);
// Approving rotates the site's single OAuth secret, which Pulse ingest and
// block-log reporting both authenticate with. persistApiKey also clears any
// pulseAuth an earlier version wrote, so nothing is left holding the value
// the server has just replaced.
await persistApiKey(process.cwd(), apiKey);

return { status: 'approved', userCode, verificationUri };
Expand Down
53 changes: 52 additions & 1 deletion tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js';
import { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from '../src/config.js';
import { readFile } from 'node:fs/promises';
import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from '../src/client.js';
import { PatchstackError } from '../src/types.js';
Expand Down Expand Up @@ -125,3 +125,54 @@ describe('resolveConfig', () => {
});
});
});

/**
* One credential authenticates Pulse ingest and block-log reporting. These pin
* the two things that must stay true for configs written by earlier versions.
*/
describe('credential resolution', () => {
let cwd: string;

beforeEach(async () => {
cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-'));
delete process.env.PATCHSTACK_API_KEY;
delete process.env.PATCHSTACK_PULSE_AUTH;
});

afterEach(() => rm(cwd, { recursive: true, force: true }));

it('uses apiKey for Pulse when no pulseAuth is present', async () => {
await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'secret-987' });

const config = await resolveConfig({ cwd });

expect(config.apiKey).toBe('secret-987');
expect(config.pulseAuth).toBe('secret-987');
});

it('still honours a pulseAuth written by an earlier version', async () => {
await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'a-1', pulseAuth: 'b-2' });

expect((await resolveConfig({ cwd })).pulseAuth).toBe('b-2');
});

it('drops a stale pulseAuth when the credential is replaced', async () => {
// pulseAuth resolves ahead of apiKey, so a leftover copy would keep
// authenticating Pulse with the value the server just replaced.
await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'old-1', pulseAuth: 'old-1' });

await persistApiKey(cwd, 'rotated-2');

const config = await resolveConfig({ cwd });
expect(config.apiKey).toBe('rotated-2');
expect(config.pulseAuth).toBe('rotated-2');
expect(JSON.parse(await readFile(path.join(cwd, '.patchstackrc.json'), 'utf8')).pulseAuth).toBeUndefined();
});

it('lets PATCHSTACK_PULSE_AUTH override the file', async () => {
await writeConfigFile(cwd, { siteUuid: VALID_UUID, apiKey: 'file-1' });
process.env.PATCHSTACK_PULSE_AUTH = 'env-2';

expect((await resolveConfig({ cwd })).pulseAuth).toBe('env-2');
});
});
6 changes: 3 additions & 3 deletions tests/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ describe('login', () => {
'https://api.patchstack.com/monitor/pulse/device?code=WDJB-MJHT',
);

// Both fields: approving rotates the one secret block-logs use too, so
// leaving apiKey behind would break block-log reporting.
// One credential for both paths. Pulse resolution falls back to apiKey,
// so a second copy is not written.
const written = JSON.parse(readFileSync('.patchstackrc.json', 'utf8'));
expect(written.pulseAuth).toBe('new-secret-987');
expect(written.apiKey).toBe('new-secret-987');
expect(written.pulseAuth).toBeUndefined();
} finally {
process.chdir(original);
}
Expand Down
Loading