@patchstack/connect 0.5.19 → 0.5.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-INSTALL.md +2 -0
- package/README.md +2 -2
- package/dist/cli.js +306 -3
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +7 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +13 -2
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.edge.js +1 -0
- package/dist/protect.edge.js.map +1 -1
- package/dist/protect.js +2 -1
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-DZQOEO3Q.js → refresh-manifest-OMRDCNFD.js} +4 -1
- package/dist/refresh-manifest-OMRDCNFD.js.map +1 -0
- package/package.json +1 -1
- package/dist/refresh-manifest-DZQOEO3Q.js.map +0 -1
package/AGENT-INSTALL.md
CHANGED
|
@@ -277,6 +277,8 @@ Handle it in this order:
|
|
|
277
277
|
|
|
278
278
|
- `apiKey` (in `.patchstackrc.local.json`, which is git-ignored; also `PATCHSTACK_API_KEY`, WP format `{secret}-{oauth.id}`) — one credential for both paths. It authenticates **Pulse ingest** (manifest, attack-surface map, package removal, rule detections), 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.
|
|
279
279
|
|
|
280
|
+
A production build of a registered site that has **no** credential — a hosted builder's publish, which builds from the committed project and so never sees the git-ignored key file — cannot send its manifest. `scan` and `mark-build` then POST `monitor/pulse/build/<your site uuid>` instead, with no credential and exactly three fields: the manifest checksum, `environment: "production"`, and (`mark-build` only) the marker result. **No package names, no address, no name, no environment variable values.** Patchstack accepts it only when the checksum is the build it last scanned for this site, and shows the build as reported rather than deployed until the live page is seen running it. A publish that changed your packages is refused; set `PATCHSTACK_API_KEY` for that build.
|
|
281
|
+
|
|
280
282
|
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; the git-ignored `.patchstackrc.local.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.
|
|
281
283
|
|
|
282
284
|
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.
|
package/README.md
CHANGED
|
@@ -262,7 +262,7 @@ group, and a verification that can leave a server running is worse than an unans
|
|
|
262
262
|
|
|
263
263
|
No other command runs your application. `protect`, `protect --check`, `setup`, `guide`, `scan`,
|
|
264
264
|
`status` and `mark-build` only read and write files, and — for `scan` and `mark-build` — report the
|
|
265
|
-
dependency manifest they read.
|
|
265
|
+
dependency manifest they read, or only its checksum from a production build that has no API key.
|
|
266
266
|
|
|
267
267
|
## Configuration
|
|
268
268
|
|
|
@@ -310,7 +310,7 @@ The site UUID identifies the site and is **not** a secret — the Patchstack Con
|
|
|
310
310
|
|
|
311
311
|
If it is ever lost, `npx @patchstack/connect login` recovers it — approval happens in the dashboard and rotates the credential.
|
|
312
312
|
|
|
313
|
-
The credential's file is never committed, so CI needs `PATCHSTACK_API_KEY` in the environment (and `PATCHSTACK_SITE_UUID` too where `.patchstackrc.json` is also absent). Precedence is CLI flag → env var → `.patchstackrc.local.json` → `.patchstackrc.json`.
|
|
313
|
+
The credential's file is never committed, so CI needs `PATCHSTACK_API_KEY` in the environment (and `PATCHSTACK_SITE_UUID` too where `.patchstackrc.json` is also absent). A production build that has the site UUID but no key — a hosted builder's publish, typically — reports only that the build ran: it POSTs the manifest checksum to `monitor/pulse/build/<uuid>` without a credential and without the package list, and Patchstack accepts it only for the build it last scanned. Set the key there if the publish can change your packages. Precedence is CLI flag → env var → `.patchstackrc.local.json` → `.patchstackrc.json`.
|
|
314
314
|
|
|
315
315
|
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.
|
|
316
316
|
|
package/dist/cli.js
CHANGED
|
@@ -1876,11 +1876,79 @@ async function postManifestWithEnvironmentFallback(config, payload, marker = nul
|
|
|
1876
1876
|
environmentUsed: config.environment
|
|
1877
1877
|
};
|
|
1878
1878
|
} catch (err) {
|
|
1879
|
+
if (err instanceof PatchstackError && err.code === "UNAUTHORIZED" && reportsBuildWithoutCredential(config)) {
|
|
1880
|
+
return {
|
|
1881
|
+
response: await postBuildReport(config, computeManifestChecksum(payload.packages), marker),
|
|
1882
|
+
environmentUsed: "production"
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1879
1885
|
if (config.environment !== "local" || !environmentRejected(err)) throw err;
|
|
1880
1886
|
const fallback = { ...config, environment: "sandbox" };
|
|
1881
1887
|
return { response: await postManifest(fallback, payload, marker), environmentUsed: "sandbox" };
|
|
1882
1888
|
}
|
|
1883
1889
|
}
|
|
1890
|
+
function reportsBuildWithoutCredential(config) {
|
|
1891
|
+
const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
|
|
1892
|
+
return !hasCredential && config.environment === "production" && config.siteUuid !== null && config.siteUuid !== "";
|
|
1893
|
+
}
|
|
1894
|
+
function buildReportUrl(manifestEndpoint, siteUuid) {
|
|
1895
|
+
const url = new URL(manifestEndpoint);
|
|
1896
|
+
const path17 = url.pathname.replace(/\/$/, "");
|
|
1897
|
+
const id = encodeURIComponent(siteUuid);
|
|
1898
|
+
url.pathname = path17.endsWith("/manifest") ? `${path17.slice(0, -"/manifest".length)}/build/${id}` : `/monitor/pulse/build/${id}`;
|
|
1899
|
+
url.search = "";
|
|
1900
|
+
url.hash = "";
|
|
1901
|
+
return url.toString();
|
|
1902
|
+
}
|
|
1903
|
+
async function postBuildReport(config, checksum, marker = null) {
|
|
1904
|
+
const siteUuid = config.siteUuid ?? "";
|
|
1905
|
+
const url = buildReportUrl(config.endpoint, siteUuid);
|
|
1906
|
+
assertConnectableEndpoint(config, url);
|
|
1907
|
+
const needsKey = authFailureMessage(401, config) ?? "Set PATCHSTACK_API_KEY where this build runs.";
|
|
1908
|
+
let response;
|
|
1909
|
+
try {
|
|
1910
|
+
response = await fetch(url, {
|
|
1911
|
+
method: "POST",
|
|
1912
|
+
headers: {
|
|
1913
|
+
"Content-Type": "application/json",
|
|
1914
|
+
Accept: "application/json",
|
|
1915
|
+
"User-Agent": "@patchstack/connect"
|
|
1916
|
+
},
|
|
1917
|
+
body: JSON.stringify({ checksum, environment: "production", ...marker !== null ? { marker } : {} }),
|
|
1918
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1919
|
+
});
|
|
1920
|
+
} catch (cause) {
|
|
1921
|
+
throw new PatchstackError(
|
|
1922
|
+
isTimeoutError(cause) ? `Patchstack request to ${url} timed out after ${config.timeoutMs}ms. Override with PATCHSTACK_TIMEOUT_MS.` : `Could not reach Patchstack at ${url}. Check your network connection.`,
|
|
1923
|
+
isTimeoutError(cause) ? "NETWORK_TIMEOUT" : "NETWORK_ERROR",
|
|
1924
|
+
cause
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
let parsed = null;
|
|
1928
|
+
try {
|
|
1929
|
+
const text = await readBoundedText(response);
|
|
1930
|
+
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
1931
|
+
} catch {
|
|
1932
|
+
parsed = null;
|
|
1933
|
+
}
|
|
1934
|
+
if (response.status === 422) {
|
|
1935
|
+
throw new PatchstackError(
|
|
1936
|
+
safeDisplayString(parsed?.error) ?? `This build is not the one Patchstack last scanned. ${needsKey}`,
|
|
1937
|
+
"UNAUTHORIZED"
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
if (response.status < 200 || response.status >= 300) {
|
|
1941
|
+
throw new PatchstackError(needsKey, "UNAUTHORIZED");
|
|
1942
|
+
}
|
|
1943
|
+
const result = parsed?.result;
|
|
1944
|
+
return {
|
|
1945
|
+
uuid: siteUuid,
|
|
1946
|
+
stored: false,
|
|
1947
|
+
checksum,
|
|
1948
|
+
reason: "build-reported",
|
|
1949
|
+
message: result === "already-reported" ? "Patchstack already knew this build was published" : "Told Patchstack this build was published. There is no API key here, so the package list was not sent again"
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1884
1952
|
async function postManifest(config, payload, marker = null) {
|
|
1885
1953
|
const url = buildEndpointUrl(config.endpoint, config.siteUuid);
|
|
1886
1954
|
const timeoutMs = config.timeoutMs;
|
|
@@ -8391,6 +8459,234 @@ function pathFromTainted(node, ts, rootPath, trailing = []) {
|
|
|
8391
8459
|
return { path: normalizePath([base.path, ...segs].filter(Boolean).join(".")), space, ...request, ...accessor, ...reassigned };
|
|
8392
8460
|
}
|
|
8393
8461
|
|
|
8462
|
+
// src/map/budget.ts
|
|
8463
|
+
var INGEST_LIMITS = {
|
|
8464
|
+
bytes: 2 * 1024 * 1024,
|
|
8465
|
+
nodes: 5e4,
|
|
8466
|
+
depth: 12,
|
|
8467
|
+
endpoints: 2e3,
|
|
8468
|
+
fileLength: 512,
|
|
8469
|
+
inputsPerEndpoint: 500,
|
|
8470
|
+
sinksPerEndpoint: 500,
|
|
8471
|
+
flowsPerEndpoint: 2e3,
|
|
8472
|
+
dependencyInputFlowsPerEndpoint: 100
|
|
8473
|
+
};
|
|
8474
|
+
var RESERVE = { nodes: 8, bytes: 256 };
|
|
8475
|
+
function nodeCount(value) {
|
|
8476
|
+
if (value === null || typeof value !== "object") return 0;
|
|
8477
|
+
let n = 0;
|
|
8478
|
+
for (const item of Object.values(value)) n += 1 + nodeCount(item);
|
|
8479
|
+
return n;
|
|
8480
|
+
}
|
|
8481
|
+
function depthOf(value, depth = 0) {
|
|
8482
|
+
if (value === null || typeof value !== "object") return depth;
|
|
8483
|
+
let deepest = depth;
|
|
8484
|
+
for (const item of Object.values(value)) deepest = Math.max(deepest, depthOf(item, depth + 1));
|
|
8485
|
+
return deepest;
|
|
8486
|
+
}
|
|
8487
|
+
var encoder = new TextEncoder();
|
|
8488
|
+
var byteLength = (value) => encoder.encode(JSON.stringify(value)).length;
|
|
8489
|
+
function ingestProblems(map, limits = INGEST_LIMITS) {
|
|
8490
|
+
const problems = [];
|
|
8491
|
+
const bytes = byteLength(map);
|
|
8492
|
+
const nodes = nodeCount(map);
|
|
8493
|
+
const depth = depthOf(map);
|
|
8494
|
+
if (bytes > limits.bytes - RESERVE.bytes) problems.push(`${bytes} bytes, over the ${limits.bytes - RESERVE.bytes} the upload can carry`);
|
|
8495
|
+
if (nodes > limits.nodes - RESERVE.nodes) problems.push(`${nodes} nodes, over the ${limits.nodes - RESERVE.nodes} the upload can carry`);
|
|
8496
|
+
if (depth > limits.depth) problems.push(`nesting ${depth} levels deep, over ${limits.depth}`);
|
|
8497
|
+
if (map.endpoints.length > limits.endpoints) problems.push(`${map.endpoints.length} endpoints, over ${limits.endpoints}`);
|
|
8498
|
+
map.endpoints.forEach((e, i) => {
|
|
8499
|
+
const at = `endpoint ${i}`;
|
|
8500
|
+
if (typeof e.file !== "string" || e.file.length > limits.fileLength) problems.push(`${at} has a file path over ${limits.fileLength} characters`);
|
|
8501
|
+
if (e.inputs.length > limits.inputsPerEndpoint) problems.push(`${at} has ${e.inputs.length} inputs, over ${limits.inputsPerEndpoint}`);
|
|
8502
|
+
if (e.sinks.length > limits.sinksPerEndpoint) problems.push(`${at} has ${e.sinks.length} sinks, over ${limits.sinksPerEndpoint}`);
|
|
8503
|
+
if (e.flows.length > limits.flowsPerEndpoint) problems.push(`${at} has ${e.flows.length} flows, over ${limits.flowsPerEndpoint}`);
|
|
8504
|
+
if ((e.dependencyInputFlows?.length ?? 0) > limits.dependencyInputFlowsPerEndpoint) problems.push(`${at} has ${e.dependencyInputFlows.length} dependency input links, over ${limits.dependencyInputFlowsPerEndpoint}`);
|
|
8505
|
+
});
|
|
8506
|
+
return problems;
|
|
8507
|
+
}
|
|
8508
|
+
function keepPriority(flow) {
|
|
8509
|
+
if (flow.confidence === "exact-local") return flow.ruleGeneratable ? 3 : 2;
|
|
8510
|
+
if (flow.confidence === "transformed-local") return 1;
|
|
8511
|
+
return 0;
|
|
8512
|
+
}
|
|
8513
|
+
var STAGES = ["unproven", "invocation", "dependency", "transformed", "exact", "generatable", "import"];
|
|
8514
|
+
function budgetDocument(input, limits = INGEST_LIMITS) {
|
|
8515
|
+
let endpointsOmitted = 0;
|
|
8516
|
+
let endpoints = input.endpoints.filter((e) => {
|
|
8517
|
+
const ok = typeof e.file === "string" && e.file.length <= limits.fileLength;
|
|
8518
|
+
if (!ok) endpointsOmitted++;
|
|
8519
|
+
return ok;
|
|
8520
|
+
});
|
|
8521
|
+
if (endpoints.length > limits.endpoints) {
|
|
8522
|
+
endpointsOmitted += endpoints.length - limits.endpoints;
|
|
8523
|
+
endpoints = endpoints.slice(0, limits.endpoints);
|
|
8524
|
+
}
|
|
8525
|
+
endpoints = endpoints.map((e) => capEndpoint(e, limits));
|
|
8526
|
+
let map = { ...input, endpoints };
|
|
8527
|
+
if (endpointsOmitted > 0) map = { ...map, coverage: { ...map.coverage, endpointsOmitted } };
|
|
8528
|
+
const removals = [];
|
|
8529
|
+
map.endpoints.forEach((e, s) => {
|
|
8530
|
+
e.flows.forEach((f, i) => {
|
|
8531
|
+
const p = keepPriority(f);
|
|
8532
|
+
const stage = p === 3 ? "generatable" : p === 2 ? "exact" : p === 1 ? "transformed" : "unproven";
|
|
8533
|
+
removals.push({ stage, section: s, item: i, nodes: 1 + nodeCount(f), bytes: byteLength(f) + 1 });
|
|
8534
|
+
});
|
|
8535
|
+
(e.dependencyInputFlows ?? []).forEach((d, i) => removals.push({ stage: "dependency", section: s, item: i, nodes: 1 + nodeCount(d), bytes: byteLength(d) + 1 }));
|
|
8536
|
+
});
|
|
8537
|
+
(map.apiInvocations ?? []).forEach((v, i) => removals.push({ stage: "invocation", section: -1, item: i, nodes: 1 + nodeCount(v), bytes: byteLength(v) + 1 }));
|
|
8538
|
+
(map.imports ?? []).forEach((v, i) => removals.push({ stage: "import", section: -1, item: i, nodes: 1 + nodeCount(v), bytes: byteLength(v) + 1 }));
|
|
8539
|
+
removals.sort((a, b) => STAGES.indexOf(a.stage) - STAGES.indexOf(b.stage) || b.section - a.section || b.item - a.item);
|
|
8540
|
+
const removed = /* @__PURE__ */ new Set();
|
|
8541
|
+
const key = (r) => `${r.stage === "unproven" || r.stage === "transformed" || r.stage === "exact" || r.stage === "generatable" ? "flow" : r.stage}:${r.section}:${r.item}`;
|
|
8542
|
+
const fitsSize = (doc2) => nodeCount(doc2) <= limits.nodes - RESERVE.nodes && byteLength(doc2) <= limits.bytes - RESERVE.bytes;
|
|
8543
|
+
let doc = withNote(applyRemovals(map, removed));
|
|
8544
|
+
let nodes = nodeCount(doc);
|
|
8545
|
+
let bytes = byteLength(doc);
|
|
8546
|
+
for (const r of removals) {
|
|
8547
|
+
if (nodes <= limits.nodes - RESERVE.nodes && bytes <= limits.bytes - RESERVE.bytes) {
|
|
8548
|
+
doc = withNote(applyRemovals(map, removed));
|
|
8549
|
+
nodes = nodeCount(doc);
|
|
8550
|
+
bytes = byteLength(doc);
|
|
8551
|
+
if (fitsSize(doc)) break;
|
|
8552
|
+
}
|
|
8553
|
+
removed.add(key(r));
|
|
8554
|
+
nodes -= r.nodes;
|
|
8555
|
+
bytes -= r.bytes;
|
|
8556
|
+
}
|
|
8557
|
+
doc = withNote(applyRemovals(map, removed));
|
|
8558
|
+
const problems = ingestProblems(doc, limits);
|
|
8559
|
+
if (problems.length === 0) return doc;
|
|
8560
|
+
return {
|
|
8561
|
+
...doc,
|
|
8562
|
+
coverage: {
|
|
8563
|
+
...doc.coverage,
|
|
8564
|
+
notes: [...doc.coverage.notes, `This map cannot be fitted to the size the Patchstack API accepts (${problems.join("; ")}), even after leaving out everything that may be left out. It must not be uploaded.`]
|
|
8565
|
+
}
|
|
8566
|
+
};
|
|
8567
|
+
}
|
|
8568
|
+
function capEndpoint(e, limits) {
|
|
8569
|
+
let flows = e.flows;
|
|
8570
|
+
let droppedProven = 0;
|
|
8571
|
+
let droppedUnproven = 0;
|
|
8572
|
+
const drop = (keep) => {
|
|
8573
|
+
flows = flows.filter((f) => {
|
|
8574
|
+
if (keep(f)) return true;
|
|
8575
|
+
if (isProvenFlow(f.confidence)) droppedProven++;
|
|
8576
|
+
else droppedUnproven++;
|
|
8577
|
+
return false;
|
|
8578
|
+
});
|
|
8579
|
+
};
|
|
8580
|
+
if (flows.length > limits.flowsPerEndpoint) {
|
|
8581
|
+
const kept = new Set(
|
|
8582
|
+
flows.map((f, i) => ({ f, i, p: keepPriority(f) })).sort((a, b) => b.p - a.p || a.i - b.i).slice(0, limits.flowsPerEndpoint).map(({ i }) => i)
|
|
8583
|
+
);
|
|
8584
|
+
const byIndex = new Map(flows.map((f, i) => [f, i]));
|
|
8585
|
+
drop((f) => kept.has(byIndex.get(f)));
|
|
8586
|
+
}
|
|
8587
|
+
const ranked = [...flows].sort((a, b) => keepPriority(b) - keepPriority(a));
|
|
8588
|
+
const pick = (items, limit, idOf) => {
|
|
8589
|
+
if (items.length <= limit) return { kept: items, omitted: 0 };
|
|
8590
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
8591
|
+
for (const f of ranked) {
|
|
8592
|
+
const id = idOf(f);
|
|
8593
|
+
if (id !== void 0 && wanted.size < limit) wanted.add(id);
|
|
8594
|
+
}
|
|
8595
|
+
const chosen = new Set(items.filter((it) => it.id !== void 0 && wanted.has(it.id)));
|
|
8596
|
+
for (const it of items) if (chosen.size < limit) chosen.add(it);
|
|
8597
|
+
return { kept: items.filter((it) => chosen.has(it)), omitted: items.length - chosen.size };
|
|
8598
|
+
};
|
|
8599
|
+
const inputs = pick(e.inputs, limits.inputsPerEndpoint, (f) => f.inputId);
|
|
8600
|
+
const sinks = pick(e.sinks, limits.sinksPerEndpoint, (f) => f.sink?.id);
|
|
8601
|
+
if (inputs.omitted > 0 || sinks.omitted > 0) {
|
|
8602
|
+
const inputIds = new Set(inputs.kept.map((i) => i.id));
|
|
8603
|
+
const sinkIds = new Set(sinks.kept.map((s) => s.id));
|
|
8604
|
+
drop((f) => (f.inputId === void 0 || inputIds.has(f.inputId)) && (f.sink?.id === void 0 || sinkIds.has(f.sink.id)));
|
|
8605
|
+
}
|
|
8606
|
+
let links = e.dependencyInputFlows;
|
|
8607
|
+
let linksTruncated = e.dependencyInputFlowsTruncated === true;
|
|
8608
|
+
if (links !== void 0 && links.length > limits.dependencyInputFlowsPerEndpoint) {
|
|
8609
|
+
links = links.slice(0, limits.dependencyInputFlowsPerEndpoint);
|
|
8610
|
+
linksTruncated = true;
|
|
8611
|
+
}
|
|
8612
|
+
if (flows === e.flows && inputs.omitted === 0 && sinks.omitted === 0 && links === e.dependencyInputFlows) return e;
|
|
8613
|
+
return {
|
|
8614
|
+
...e,
|
|
8615
|
+
inputs: inputs.kept,
|
|
8616
|
+
sinks: sinks.kept,
|
|
8617
|
+
flows,
|
|
8618
|
+
...links !== void 0 ? { dependencyInputFlows: links } : {},
|
|
8619
|
+
...linksTruncated ? { dependencyInputFlowsTruncated: true } : {},
|
|
8620
|
+
...droppedUnproven > 0 ? { flowsTruncated: true } : {},
|
|
8621
|
+
...droppedProven > 0 ? { provenFlowsOmitted: (e.provenFlowsOmitted ?? 0) + droppedProven } : {},
|
|
8622
|
+
...inputs.omitted > 0 ? { inputsOmitted: inputs.omitted } : {},
|
|
8623
|
+
...sinks.omitted > 0 ? { sinksOmitted: sinks.omitted } : {}
|
|
8624
|
+
};
|
|
8625
|
+
}
|
|
8626
|
+
function applyRemovals(map, removed) {
|
|
8627
|
+
if (removed.size === 0) return map;
|
|
8628
|
+
const endpoints = map.endpoints.map((e, s) => {
|
|
8629
|
+
let proven = 0;
|
|
8630
|
+
let unproven = 0;
|
|
8631
|
+
const flows = e.flows.filter((f, i) => {
|
|
8632
|
+
if (!removed.has(`flow:${s}:${i}`)) return true;
|
|
8633
|
+
if (isProvenFlow(f.confidence)) proven++;
|
|
8634
|
+
else unproven++;
|
|
8635
|
+
return false;
|
|
8636
|
+
});
|
|
8637
|
+
const links = e.dependencyInputFlows?.filter((_, i) => !removed.has(`dependency:${s}:${i}`));
|
|
8638
|
+
const linksDropped = (e.dependencyInputFlows?.length ?? 0) - (links?.length ?? 0);
|
|
8639
|
+
if (proven === 0 && unproven === 0 && linksDropped === 0) return e;
|
|
8640
|
+
return {
|
|
8641
|
+
...e,
|
|
8642
|
+
flows,
|
|
8643
|
+
...links !== void 0 ? { dependencyInputFlows: links } : {},
|
|
8644
|
+
...unproven > 0 ? { flowsTruncated: true } : {},
|
|
8645
|
+
...proven > 0 ? { provenFlowsOmitted: (e.provenFlowsOmitted ?? 0) + proven } : {},
|
|
8646
|
+
...linksDropped > 0 ? { dependencyInputFlowsTruncated: true } : {}
|
|
8647
|
+
};
|
|
8648
|
+
});
|
|
8649
|
+
const invocations = map.apiInvocations?.filter((_, i) => !removed.has(`invocation:-1:${i}`));
|
|
8650
|
+
const imports = map.imports?.filter((_, i) => !removed.has(`import:-1:${i}`));
|
|
8651
|
+
const invocationsOmitted = (map.apiInvocations?.length ?? 0) - (invocations?.length ?? 0);
|
|
8652
|
+
const importsOmitted = (map.imports?.length ?? 0) - (imports?.length ?? 0);
|
|
8653
|
+
return {
|
|
8654
|
+
...map,
|
|
8655
|
+
endpoints,
|
|
8656
|
+
...invocations !== void 0 ? { apiInvocations: invocations } : {},
|
|
8657
|
+
...imports !== void 0 ? { imports } : {},
|
|
8658
|
+
coverage: {
|
|
8659
|
+
...map.coverage,
|
|
8660
|
+
...invocationsOmitted > 0 ? { apiInvocationsOmitted: invocationsOmitted } : {},
|
|
8661
|
+
// A package missing from a trimmed inventory may still be imported, so the inventory stops licensing
|
|
8662
|
+
// any "not imported" conclusion.
|
|
8663
|
+
...importsOmitted > 0 ? { importsComplete: false, importCoverageGaps: { ...map.coverage.importCoverageGaps, omittedForSize: importsOmitted } } : {}
|
|
8664
|
+
}
|
|
8665
|
+
};
|
|
8666
|
+
}
|
|
8667
|
+
function withNote(doc) {
|
|
8668
|
+
const parts = [];
|
|
8669
|
+
const proven = doc.endpoints.reduce((n, e) => n + (e.provenFlowsOmitted ?? 0), 0);
|
|
8670
|
+
const marked = doc.endpoints.filter((e) => e.provenFlowsOmitted || e.inputsOmitted || e.sinksOmitted).length;
|
|
8671
|
+
if (proven > 0) parts.push(`${proven} proven flow(s)`);
|
|
8672
|
+
const inputs = doc.endpoints.reduce((n, e) => n + (e.inputsOmitted ?? 0), 0);
|
|
8673
|
+
const sinks = doc.endpoints.reduce((n, e) => n + (e.sinksOmitted ?? 0), 0);
|
|
8674
|
+
if (inputs > 0) parts.push(`${inputs} input(s)`);
|
|
8675
|
+
if (sinks > 0) parts.push(`${sinks} sink(s)`);
|
|
8676
|
+
if (doc.coverage.apiInvocationsOmitted) parts.push(`${doc.coverage.apiInvocationsOmitted} API invocation(s)`);
|
|
8677
|
+
if (doc.coverage.importCoverageGaps?.omittedForSize) parts.push(`${doc.coverage.importCoverageGaps.omittedForSize} import(s)`);
|
|
8678
|
+
if (doc.coverage.endpointsOmitted) parts.push(`${doc.coverage.endpointsOmitted} endpoint(s)`);
|
|
8679
|
+
const links = doc.endpoints.filter((e) => e.dependencyInputFlowsTruncated).length;
|
|
8680
|
+
if (parts.length === 0) return doc;
|
|
8681
|
+
return {
|
|
8682
|
+
...doc,
|
|
8683
|
+
coverage: {
|
|
8684
|
+
...doc.coverage,
|
|
8685
|
+
notes: [...doc.coverage.notes, `To stay within the size the Patchstack API accepts, this map left out ${parts.join(", ")}${marked > 0 ? ` (${marked} endpoint(s) marked provenFlowsOmitted, inputsOmitted or sinksOmitted)` : ""}${links > 0 ? `; ${links} endpoint(s) have dependencyInputFlowsTruncated` : ""}. What is listed is true; what was left out is unknown, not absent.`]
|
|
8686
|
+
}
|
|
8687
|
+
};
|
|
8688
|
+
}
|
|
8689
|
+
|
|
8394
8690
|
// src/map/module-graph.ts
|
|
8395
8691
|
import { readFileSync as readFileSync14, realpathSync as realpathSync5, statSync as statSync7 } from "fs";
|
|
8396
8692
|
import { dirname as dirname8, join as join16, relative as relative5, resolve as resolvePath } from "path";
|
|
@@ -9221,7 +9517,7 @@ async function extractInputMap(cwd, ts, options = {}) {
|
|
|
9221
9517
|
notes.push(deploymentShapes.length === 0 ? "`deploymentShapes` is EMPTY: no deployment artifact was recognized. That is not evidence the app has no server-side runtime \u2014 a serverless handler this analysis cannot parse produces no endpoint and looks identical to an app that has none, and entry-point recognition has no completeness flag (coverage.importsComplete covers the import inventory only). A definitive answer needs deployment or build attestation, which source analysis cannot supply." : `\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(", ")}). POSITIVE EVIDENCE ONLY, and findings differ in strength: only \`runtime-entry\` (a worker entry, or a provider function directory with source) shows something that serves. \`deployment-config\` means the project deploys to a platform \u2014 a static site on Netlify has a \`netlify.toml\` \u2014 and \`layout\` (a root \`api/\` or \`functions/\` folder) is an ordinary application folder. Neither may on its own be read as a server runtime.`);
|
|
9222
9518
|
const serverSurface = classifyServerSurface(cwd, endpoints.length, deploymentShapes, ts);
|
|
9223
9519
|
notes.push(surfaceNote(serverSurface));
|
|
9224
|
-
return {
|
|
9520
|
+
return budgetDocument({
|
|
9225
9521
|
version: 3,
|
|
9226
9522
|
framework: detectFramework(cwd),
|
|
9227
9523
|
deploymentShapes,
|
|
@@ -9261,7 +9557,7 @@ async function extractInputMap(cwd, ts, options = {}) {
|
|
|
9261
9557
|
roots: ["."],
|
|
9262
9558
|
notes
|
|
9263
9559
|
}
|
|
9264
|
-
};
|
|
9560
|
+
});
|
|
9265
9561
|
}
|
|
9266
9562
|
|
|
9267
9563
|
// src/map/index.ts
|
|
@@ -9562,6 +9858,11 @@ async function runMap(flags) {
|
|
|
9562
9858
|
console.log(json);
|
|
9563
9859
|
}
|
|
9564
9860
|
if (flags.get("upload") === true) {
|
|
9861
|
+
const problems = ingestProblems(map);
|
|
9862
|
+
if (problems.length > 0) {
|
|
9863
|
+
console.error(`patchstack: did not upload the attack surface \u2014 it cannot be fitted to the size Patchstack accepts (${problems.join("; ")}).`);
|
|
9864
|
+
return 0;
|
|
9865
|
+
}
|
|
9565
9866
|
if (map.endpoints.length === 0) {
|
|
9566
9867
|
console.error(
|
|
9567
9868
|
"patchstack: no server entry points were recognized \u2014 uploading the import inventory and coverage notes anyway, so a vulnerability can still be judged imported or not. Nothing here can decide whether a request reaches it."
|
|
@@ -10230,6 +10531,8 @@ async function runScan(args, options = {}) {
|
|
|
10230
10531
|
say(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
|
|
10231
10532
|
} else if (response.reason === "duplicate") {
|
|
10232
10533
|
report.done.unshift(checked, "No changes since the last check");
|
|
10534
|
+
} else if (response.reason === "build-reported") {
|
|
10535
|
+
report.done.unshift(checked, response.message ?? "Reported this production build");
|
|
10233
10536
|
} else {
|
|
10234
10537
|
report.missing.push({
|
|
10235
10538
|
text: "Patchstack did not save this check",
|
|
@@ -10275,7 +10578,7 @@ async function runScan(args, options = {}) {
|
|
|
10275
10578
|
if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) {
|
|
10276
10579
|
reportSourceWidget(effectiveUuid, shellFramework, report);
|
|
10277
10580
|
}
|
|
10278
|
-
const synced = effectiveUuid !== null && effectiveUuid.length > 0 && (response.stored || response.reason === "duplicate");
|
|
10581
|
+
const synced = effectiveUuid !== null && effectiveUuid.length > 0 && (response.stored || response.reason === "duplicate" || response.reason === "build-reported");
|
|
10279
10582
|
const outcome = {
|
|
10280
10583
|
connected,
|
|
10281
10584
|
synced,
|