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
9 changes: 7 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ 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 `apiKey` (also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) is the opposite: server-only, used to authenticate block-log reporting through the existing connector `POST /api/logs/log` so "Threats blocked" fills in the dashboard. Never put `apiKey` 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. 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.
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**:

- `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.

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.

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

Expand All @@ -97,7 +102,7 @@ This versioned reference ships inside `@patchstack/connect` and documents each s
- 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` as an env var instead. Precedence: CLI flag β†’ env var β†’ `.patchstackrc.json`.
- 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.

## Verifying the install

Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,23 @@ Environment variables:
```json
{
"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*).

The site UUID identifies the site; it is not a secret β€” the disclosure widget ships the same UUID in client-side HTML, and committing `.patchstackrc.json` is the intended workflow so every developer and CI run reports to the same site. Possession of the UUID lets someone submit dependency manifests for that site (noise, not data access). In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` instead.
**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.

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.

If a credential 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`.

### Sandbox and production manifests

Expand Down
47 changes: 45 additions & 2 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, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
import {
buildInjectionSnippet,
findHtmlFiles,
Expand All @@ -36,6 +36,7 @@ import {
installCommand,
renderGuideChecklist,
} from './guide.js';
import { login } from './login.js';
import { runProtect, runVerify } from './protect/install/index.js';
import { buildInputMap } from './map/index.js';
import { isProvenFlow } from './map/coordinates.js';
Expand Down Expand Up @@ -102,6 +103,11 @@ Usage:
what's missing, with tailored commands), then
print the full setup guide. --full prints the
guide even when setup is complete
patchstack-connect login [options] Recover this site's Patchstack credential when
.patchstackrc.json has been lost. Prints a short
code to approve in the dashboard; approving
rotates the credential, so the old one stops
working
patchstack-connect help Print this message

Options (for scan, setup, status, and uninstall):
Expand All @@ -120,6 +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_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 @@ -205,6 +212,37 @@ async function runInit(args: ParsedArgs): Promise<number> {
return 0;
}

async function runLogin(args: ParsedArgs): Promise<number> {
// CI has no browser and no human; build agents must not print credentials
// into logs. Deploys use PATCHSTACK_PULSE_AUTH from the platform's secrets.
if (process.env.CI !== undefined && process.env.CI !== '' && process.env.CI !== 'false') {
console.error('`login` is interactive and cannot run in CI. Set PATCHSTACK_PULSE_AUTH instead.');
return 1;
}

const config = await resolveConfig({
cwd: process.cwd(),
cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
cliEndpoint: getStringFlag(args.flags, 'endpoint'),
});

const result = await login(config, (userCode, verificationUri) => {
console.log(`\n Your code: ${userCode}`);
console.log(` Approve at: ${verificationUri}\n`);
console.log(' Waiting for approval…');
});

if (result.status === 'approved') {
// The value itself is never printed β€” only that it landed.
console.log('\n βœ“ Credential restored and saved to .patchstackrc.json.\n');
return 0;
}

console.error(`\n ${result.message ?? 'Login failed.'}\n`);

return 1;
}

async function runMap(args: ParsedArgs): Promise<number> {
const cwd = getStringFlag(args.flags, 'dir') ?? process.cwd();
const { map, error } = await buildInputMap(cwd, {
Expand Down Expand Up @@ -367,7 +405,10 @@ async function runScan(
}
if (typeof response.api_key === 'string' && response.api_key.length > 0) {
const target = await persistApiKey(process.cwd(), response.api_key);
console.log(`Saved API key to ${target} (for block-log reporting via /api/logs/log; keep out of the public widget).`);
// 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).`);
}

if (response.stored) {
Expand Down Expand Up @@ -880,6 +921,8 @@ async function main(): Promise<number> {
return runSetup(args);
case 'map':
return runMap(args);
case 'login':
return runLogin(args);
default:
console.error(`Unknown command: ${args.command}\n`);
console.error(HELP);
Expand Down
9 changes: 6 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PatchstackError, type Config, type StoreManifestResponse } from './types.js';
import type { WirePayload } from './normalize.js';
import { pulseFetch } from './pulse-token.js';

export const DEFAULT_ENDPOINT = 'https://api.patchstack.com/monitor/pulse/manifest';
export const DEFAULT_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -95,7 +96,7 @@ export async function postInputMap(

const url = buildInputMapUrl(config.endpoint, config.siteUuid);
try {
const response = await fetch(url, {
const response = await pulseFetch(config, url, {
method: 'POST',
headers: {
Accept: 'application/json',
Expand Down Expand Up @@ -155,7 +156,7 @@ export async function postPackageRemoved(config: Config): Promise<PackageRemoved

const url = buildPackageRemovedUrl(config.endpoint, config.siteUuid);
try {
const response = await fetch(url, {
const response = await pulseFetch(config, url, {
method: 'POST',
headers: {
Accept: 'application/json',
Expand Down Expand Up @@ -227,7 +228,9 @@ export async function postManifest(

let response: Response;
try {
response = await fetch(url, {
// Unauthenticated on the bootstrap POST: there is no credential until this
// request issues one.
response = await pulseFetch(config, url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ interface ConfigFile {
siteUuid?: string;
/** WP-format `{secret}-{oauth.id}` for connector /api/logs/log. Server-only. */
apiKey?: string;
/**
* Credential for the authenticated Pulse endpoints (ADR-0018). Same format as
* `apiKey` and today the same value, but kept as its own field so the Pulse
* and block-log paths can diverge without disturbing each other. Server-only.
*/
pulseAuth?: string;
endpoint?: string;
timeoutMs?: number;
environment?: string;
Expand Down Expand Up @@ -72,10 +78,14 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise<Conf
}

const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
// Falls back to apiKey so sites provisioned before ADR-0018 authenticate
// without re-provisioning: today both hold the same credential.
const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;

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,
endpoint,
timeoutMs,
environment,
Expand Down Expand Up @@ -108,6 +118,15 @@ export async function persistApiKey(cwd: string, apiKey: string): Promise<string
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.
*/
export async function persistPulseAuth(cwd: string, pulseAuth: string): Promise<string> {
const existing = await readConfigFile(cwd);
return writeConfigFile(cwd, { ...existing, pulseAuth });
}

async function readConfigFile(cwd: string): Promise<ConfigFile> {
const target = path.join(cwd, CONFIG_FILENAME);
let raw: string;
Expand Down Expand Up @@ -152,6 +171,7 @@ function readEnv(): ConfigFile {
return {
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? undefined,
apiKey: process.env.PATCHSTACK_API_KEY ?? undefined,
pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? undefined,
endpoint: process.env.PATCHSTACK_ENDPOINT ?? undefined,
timeoutMs,
environment:
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { postManifest } from './client.js';
import { persistApiKey, persistSiteUuid, resolveConfig } from './config.js';
import { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig } from './config.js';
import type { Config, Manifest, StoreManifestResponse } from './types.js';

export { scanLockfile, detectLockfile } from './parsers/index.js';
export { buildWirePayload, compareVersions } from './normalize.js';
export { postManifest, buildClaimUrl, buildEndpointUrl, DEFAULT_ENDPOINT } from './client.js';
export { persistApiKey, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
export { persistApiKey, persistPulseAuth, persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
export {
detectStack,
collectHostingEnvKeys,
Expand Down Expand Up @@ -63,6 +63,7 @@ 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
115 changes: 115 additions & 0 deletions src/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { persistApiKey, persistPulseAuth } from './config.js';
import type { Config } from './types.js';

/**
* Device authorization flow (RFC 8628) for recovering a lost credential.
*
* The device code stays in this process; the short user code is what the human
* carries to the browser. Approving rotates the site's credential, so the old
* one β€” wherever it leaked to β€” stops working.
*/

export interface LoginDeps {
fetchImpl?: typeof fetch;
/** Injected so tests do not wait. */
sleep?: (ms: number) => Promise<void>;
now?: () => number;
}

function baseFrom(manifestEndpoint: string): string {
const url = new URL(manifestEndpoint);
const path = url.pathname.replace(/\/$/, '');
url.pathname = path.endsWith('/manifest') ? path.slice(0, -'/manifest'.length) : '/monitor/pulse';
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}

export interface LoginResult {
status: 'approved' | 'denied' | 'expired' | 'unclaimed' | 'not-found' | 'failed';
message?: string;
userCode?: string;
verificationUri?: string;
}

/**
* Start a flow and poll until the owner approves or the code expires.
* `onPrompt` is called once with the code to show the user.
*/
export async function login(
config: Config,
onPrompt: (userCode: string, verificationUri: string) => void,
deps: LoginDeps = {},
): Promise<LoginResult> {
const fetchImpl = deps.fetchImpl ?? fetch;
const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
const now = deps.now ?? (() => Date.now());

if (config.siteUuid === null) {
return { status: 'failed', message: 'No site UUID configured β€” run `patchstack-connect scan` first.' };
}

const base = baseFrom(config.endpoint);

const started = await fetchImpl(`${base}/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ site_uuid: config.siteUuid }),
});

if (started.status === 409) {
return {
status: 'unclaimed',
message: 'This site has not been claimed yet, so there is no owner to approve the request. Claim it in the dashboard, or delete .patchstackrc.json to provision a new site.',
};
}
if (started.status === 404) {
return { status: 'not-found', message: 'Patchstack does not recognise this site UUID.' };
}
if (!started.ok) {
return { status: 'failed', message: `Could not start the login (HTTP ${started.status}).` };
}

const { device_code: deviceCode, user_code: userCode, expires_in: expiresIn, interval } =
(await started.json()) as {
device_code: string;
user_code: string;
expires_in: number;
interval: number;
};

const verificationUri = `${new URL(base).origin}/activate`;
onPrompt(userCode, verificationUri);

const deadline = now() + expiresIn * 1000;
const intervalMs = Math.max(1, interval) * 1000;

while (now() < deadline) {
await sleep(intervalMs);

const polled = await fetchImpl(`${base}/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ device_code: deviceCode }),
});

if (polled.status === 428) continue; // still waiting on the human
if (!polled.ok) return { status: 'expired', message: 'The login request expired. Run the command again.' };

const { api_key: apiKey } = (await polled.json()) as { api_key?: string };
if (typeof apiKey !== 'string' || apiKey.length === 0) {
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);
await persistApiKey(process.cwd(), apiKey);

return { status: 'approved', userCode, verificationUri };
}

return { status: 'expired', message: 'The login request expired. Run the command again.' };
}
Loading
Loading