@patchstack/connect 0.3.23 → 0.3.25
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 +16 -7
- package/README.md +22 -2
- package/dist/chunk-MJOTFUDE.js +87 -0
- package/dist/chunk-MJOTFUDE.js.map +1 -0
- package/dist/cli.js +2857 -132
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +92 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +941 -221
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +7 -0
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +826 -195
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZIFRZF25.js → refresh-manifest-MUWVIPFR.js} +9 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-ZIFRZF25.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "fs";
|
|
5
5
|
|
|
6
6
|
// src/parsers/index.ts
|
|
7
7
|
import { access } from "fs/promises";
|
|
@@ -927,6 +927,88 @@ function computeManifestChecksum(packages) {
|
|
|
927
927
|
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 12);
|
|
928
928
|
}
|
|
929
929
|
|
|
930
|
+
// src/pulse-token.ts
|
|
931
|
+
var TOKEN_SKEW_MS = 6e4;
|
|
932
|
+
function buildTokenUrl(manifestEndpoint) {
|
|
933
|
+
const url = new URL(manifestEndpoint);
|
|
934
|
+
const path11 = url.pathname.replace(/\/$/, "");
|
|
935
|
+
url.pathname = path11.endsWith("/manifest") ? `${path11.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
|
|
936
|
+
url.search = "";
|
|
937
|
+
url.hash = "";
|
|
938
|
+
return url.toString();
|
|
939
|
+
}
|
|
940
|
+
function parsePulseAuth(credential) {
|
|
941
|
+
const index = credential.lastIndexOf("-");
|
|
942
|
+
if (index <= 0 || index === credential.length - 1) return null;
|
|
943
|
+
const clientId = credential.slice(index + 1);
|
|
944
|
+
if (!/^\d+$/.test(clientId)) return null;
|
|
945
|
+
return { clientId, clientSecret: credential.slice(0, index) };
|
|
946
|
+
}
|
|
947
|
+
var cached = null;
|
|
948
|
+
var inflight = null;
|
|
949
|
+
function clearPulseToken() {
|
|
950
|
+
cached = null;
|
|
951
|
+
}
|
|
952
|
+
async function getPulseToken(config, fetchImpl = fetch) {
|
|
953
|
+
if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
|
|
954
|
+
if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
|
|
955
|
+
return cached.token;
|
|
956
|
+
}
|
|
957
|
+
if (inflight !== null) return inflight;
|
|
958
|
+
const credentials = parsePulseAuth(config.pulseAuth);
|
|
959
|
+
if (credentials === null) return null;
|
|
960
|
+
inflight = (async () => {
|
|
961
|
+
try {
|
|
962
|
+
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
963
|
+
method: "POST",
|
|
964
|
+
headers: {
|
|
965
|
+
"Content-Type": "application/json",
|
|
966
|
+
Accept: "application/json",
|
|
967
|
+
"User-Agent": "@patchstack/connect"
|
|
968
|
+
},
|
|
969
|
+
body: JSON.stringify({
|
|
970
|
+
grant_type: "client_credentials",
|
|
971
|
+
client_id: credentials.clientId,
|
|
972
|
+
client_secret: credentials.clientSecret
|
|
973
|
+
}),
|
|
974
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
975
|
+
});
|
|
976
|
+
if (!response.ok) return null;
|
|
977
|
+
const body = await response.json();
|
|
978
|
+
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
979
|
+
const expiresIn = Number(body.expires_in);
|
|
980
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
981
|
+
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
982
|
+
return body.access_token;
|
|
983
|
+
} catch {
|
|
984
|
+
return null;
|
|
985
|
+
} finally {
|
|
986
|
+
inflight = null;
|
|
987
|
+
}
|
|
988
|
+
})();
|
|
989
|
+
return inflight;
|
|
990
|
+
}
|
|
991
|
+
async function pulseAuthHeader(config, fetchImpl = fetch) {
|
|
992
|
+
const token = await getPulseToken(config, fetchImpl);
|
|
993
|
+
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
|
994
|
+
}
|
|
995
|
+
async function pulseFetch(config, url, init, fetchImpl = fetch) {
|
|
996
|
+
const send = async () => {
|
|
997
|
+
const auth = await pulseAuthHeader(config, fetchImpl);
|
|
998
|
+
const response = await fetchImpl(url, {
|
|
999
|
+
...init,
|
|
1000
|
+
headers: { ...init.headers, ...auth }
|
|
1001
|
+
});
|
|
1002
|
+
return { response, authenticated: auth.Authorization !== void 0 };
|
|
1003
|
+
};
|
|
1004
|
+
const first = await send();
|
|
1005
|
+
if (first.response.status === 401 && first.authenticated) {
|
|
1006
|
+
clearPulseToken();
|
|
1007
|
+
return (await send()).response;
|
|
1008
|
+
}
|
|
1009
|
+
return first.response;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
930
1012
|
// src/client.ts
|
|
931
1013
|
var DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
|
|
932
1014
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -950,6 +1032,86 @@ function buildSettingsUrl(endpoint, siteUuid) {
|
|
|
950
1032
|
const origin = new URL(endpoint).origin;
|
|
951
1033
|
return `${origin}/monitor/widget/settings/${encodeURIComponent(siteUuid)}`;
|
|
952
1034
|
}
|
|
1035
|
+
function buildPackageRemovedUrl(manifestEndpoint, siteUuid) {
|
|
1036
|
+
const url = new URL(manifestEndpoint);
|
|
1037
|
+
const path11 = url.pathname.replace(/\/$/, "");
|
|
1038
|
+
url.pathname = path11.endsWith("/manifest") ? `${path11.slice(0, -"/manifest".length)}/package-removed/${encodeURIComponent(siteUuid)}` : `/monitor/pulse/package-removed/${encodeURIComponent(siteUuid)}`;
|
|
1039
|
+
url.search = "";
|
|
1040
|
+
url.hash = "";
|
|
1041
|
+
return url.toString();
|
|
1042
|
+
}
|
|
1043
|
+
function buildInputMapUrl(manifestEndpoint, siteUuid) {
|
|
1044
|
+
const url = new URL(manifestEndpoint);
|
|
1045
|
+
const path11 = url.pathname.replace(/\/$/, "");
|
|
1046
|
+
url.pathname = path11.endsWith("/manifest") ? `${path11.slice(0, -"/manifest".length)}/input-map/${encodeURIComponent(siteUuid)}` : `/monitor/pulse/input-map/${encodeURIComponent(siteUuid)}`;
|
|
1047
|
+
url.search = "";
|
|
1048
|
+
url.hash = "";
|
|
1049
|
+
return url.toString();
|
|
1050
|
+
}
|
|
1051
|
+
async function postInputMap(config, map) {
|
|
1052
|
+
if (config.siteUuid === null) {
|
|
1053
|
+
return { result: "skipped", message: "No site UUID configured \u2014 run `patchstack-connect scan` first." };
|
|
1054
|
+
}
|
|
1055
|
+
const url = buildInputMapUrl(config.endpoint, config.siteUuid);
|
|
1056
|
+
try {
|
|
1057
|
+
const response = await pulseFetch(config, url, {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: {
|
|
1060
|
+
Accept: "application/json",
|
|
1061
|
+
"Content-Type": "application/json",
|
|
1062
|
+
"User-Agent": "@patchstack/connect"
|
|
1063
|
+
},
|
|
1064
|
+
body: JSON.stringify(map),
|
|
1065
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1066
|
+
});
|
|
1067
|
+
if (response.status === 404) {
|
|
1068
|
+
return { result: "failed", message: "Site not found \u2014 the configured site UUID is unknown to Patchstack." };
|
|
1069
|
+
}
|
|
1070
|
+
if (response.status === 422) {
|
|
1071
|
+
return { result: "failed", message: `Patchstack does not accept this map schema (version ${map.version}). Update @patchstack/connect.` };
|
|
1072
|
+
}
|
|
1073
|
+
if (!response.ok) {
|
|
1074
|
+
return { result: "failed", message: `Patchstack returned ${response.status}.` };
|
|
1075
|
+
}
|
|
1076
|
+
const body = await response.json();
|
|
1077
|
+
const revision = Number(body.revision);
|
|
1078
|
+
if ((body.result === "stored" || body.result === "unchanged") && Number.isInteger(revision) && revision > 0) {
|
|
1079
|
+
return { result: body.result, revision };
|
|
1080
|
+
}
|
|
1081
|
+
return { result: "failed", message: "Patchstack returned an unexpected response." };
|
|
1082
|
+
} catch {
|
|
1083
|
+
return { result: "failed", message: `Could not reach Patchstack at ${url}.` };
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function postPackageRemoved(config) {
|
|
1087
|
+
if (config.siteUuid === null) {
|
|
1088
|
+
return { result: "failed", message: "No site UUID configured." };
|
|
1089
|
+
}
|
|
1090
|
+
const url = buildPackageRemovedUrl(config.endpoint, config.siteUuid);
|
|
1091
|
+
try {
|
|
1092
|
+
const response = await pulseFetch(config, url, {
|
|
1093
|
+
method: "POST",
|
|
1094
|
+
headers: {
|
|
1095
|
+
Accept: "application/json",
|
|
1096
|
+
"User-Agent": "@patchstack/connect"
|
|
1097
|
+
},
|
|
1098
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
1099
|
+
});
|
|
1100
|
+
if (response.status === 404) {
|
|
1101
|
+
return { result: "gone", message: null };
|
|
1102
|
+
}
|
|
1103
|
+
if (!response.ok) {
|
|
1104
|
+
return { result: "failed", message: `Patchstack returned ${response.status}.` };
|
|
1105
|
+
}
|
|
1106
|
+
const body = await response.json();
|
|
1107
|
+
if (body.status === "deleted" || body.status === "flagged") {
|
|
1108
|
+
return { result: body.status, message: body.message ?? null };
|
|
1109
|
+
}
|
|
1110
|
+
return { result: "failed", message: "Patchstack returned an unexpected response." };
|
|
1111
|
+
} catch {
|
|
1112
|
+
return { result: "failed", message: `Could not reach Patchstack at ${url}.` };
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
953
1115
|
async function fetchSiteStatus(config) {
|
|
954
1116
|
if (config.siteUuid === null) return "unknown";
|
|
955
1117
|
const url = new URL(buildSettingsUrl(config.endpoint, config.siteUuid));
|
|
@@ -976,7 +1138,7 @@ async function postManifest(config, payload) {
|
|
|
976
1138
|
const timeoutMs = config.timeoutMs;
|
|
977
1139
|
let response;
|
|
978
1140
|
try {
|
|
979
|
-
response = await
|
|
1141
|
+
response = await pulseFetch(config, url, {
|
|
980
1142
|
method: "POST",
|
|
981
1143
|
headers: {
|
|
982
1144
|
"Content-Type": "application/json",
|
|
@@ -1273,9 +1435,11 @@ async function resolveConfig(options) {
|
|
|
1273
1435
|
);
|
|
1274
1436
|
}
|
|
1275
1437
|
const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
|
|
1438
|
+
const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
|
|
1276
1439
|
return {
|
|
1277
1440
|
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
|
|
1278
1441
|
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
|
|
1442
|
+
pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
|
|
1279
1443
|
endpoint,
|
|
1280
1444
|
timeoutMs,
|
|
1281
1445
|
environment,
|
|
@@ -1296,6 +1460,10 @@ async function persistApiKey(cwd, apiKey) {
|
|
|
1296
1460
|
const existing = await readConfigFile(cwd);
|
|
1297
1461
|
return writeConfigFile(cwd, { ...existing, apiKey });
|
|
1298
1462
|
}
|
|
1463
|
+
async function persistPulseAuth(cwd, pulseAuth) {
|
|
1464
|
+
const existing = await readConfigFile(cwd);
|
|
1465
|
+
return writeConfigFile(cwd, { ...existing, pulseAuth });
|
|
1466
|
+
}
|
|
1299
1467
|
async function readConfigFile(cwd) {
|
|
1300
1468
|
const target = path6.join(cwd, CONFIG_FILENAME);
|
|
1301
1469
|
let raw;
|
|
@@ -1338,6 +1506,7 @@ function readEnv() {
|
|
|
1338
1506
|
return {
|
|
1339
1507
|
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
|
|
1340
1508
|
apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
|
|
1509
|
+
pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? void 0,
|
|
1341
1510
|
endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
|
|
1342
1511
|
timeoutMs,
|
|
1343
1512
|
environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
|
|
@@ -2783,145 +2952,2562 @@ function renderGuideChecklist(state, useColor) {
|
|
|
2783
2952
|
return lines.join("\n");
|
|
2784
2953
|
}
|
|
2785
2954
|
|
|
2786
|
-
// src/
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2955
|
+
// src/login.ts
|
|
2956
|
+
function baseFrom(manifestEndpoint) {
|
|
2957
|
+
const url = new URL(manifestEndpoint);
|
|
2958
|
+
const path11 = url.pathname.replace(/\/$/, "");
|
|
2959
|
+
url.pathname = path11.endsWith("/manifest") ? path11.slice(0, -"/manifest".length) : "/monitor/pulse";
|
|
2960
|
+
url.search = "";
|
|
2961
|
+
url.hash = "";
|
|
2962
|
+
return url.toString().replace(/\/$/, "");
|
|
2963
|
+
}
|
|
2964
|
+
async function login(config, onPrompt, deps = {}) {
|
|
2965
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
2966
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2967
|
+
const now = deps.now ?? (() => Date.now());
|
|
2968
|
+
if (config.siteUuid === null) {
|
|
2969
|
+
return { status: "failed", message: "No site UUID configured \u2014 run `patchstack-connect scan` first." };
|
|
2970
|
+
}
|
|
2971
|
+
const base = baseFrom(config.endpoint);
|
|
2972
|
+
const started = await fetchImpl(`${base}/device/code`, {
|
|
2973
|
+
method: "POST",
|
|
2974
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2975
|
+
body: JSON.stringify({ site_uuid: config.siteUuid })
|
|
2976
|
+
});
|
|
2977
|
+
if (started.status === 409) {
|
|
2978
|
+
return {
|
|
2979
|
+
status: "unclaimed",
|
|
2980
|
+
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."
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
if (started.status === 404) {
|
|
2984
|
+
return { status: "not-found", message: "Patchstack does not recognise this site UUID." };
|
|
2985
|
+
}
|
|
2986
|
+
if (!started.ok) {
|
|
2987
|
+
return { status: "failed", message: `Could not start the login (HTTP ${started.status}).` };
|
|
2988
|
+
}
|
|
2989
|
+
const { device_code: deviceCode, user_code: userCode, expires_in: expiresIn, interval } = await started.json();
|
|
2990
|
+
const verificationUri = `${base}/device?code=${encodeURIComponent(userCode)}`;
|
|
2991
|
+
onPrompt(userCode, verificationUri);
|
|
2992
|
+
const deadline = now() + expiresIn * 1e3;
|
|
2993
|
+
const intervalMs = Math.max(1, interval) * 1e3;
|
|
2994
|
+
while (now() < deadline) {
|
|
2995
|
+
await sleep(intervalMs);
|
|
2996
|
+
const polled = await fetchImpl(`${base}/device/token`, {
|
|
2997
|
+
method: "POST",
|
|
2998
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
2999
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
3000
|
+
});
|
|
3001
|
+
if (polled.status === 428) continue;
|
|
3002
|
+
if (!polled.ok) return { status: "expired", message: "The login request expired. Run the command again." };
|
|
3003
|
+
const { api_key: apiKey } = await polled.json();
|
|
3004
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) {
|
|
3005
|
+
return { status: "failed", message: "Patchstack approved the request but returned no credential." };
|
|
3006
|
+
}
|
|
3007
|
+
await persistPulseAuth(process.cwd(), apiKey);
|
|
3008
|
+
await persistApiKey(process.cwd(), apiKey);
|
|
3009
|
+
return { status: "approved", userCode, verificationUri };
|
|
3010
|
+
}
|
|
3011
|
+
return { status: "expired", message: "The login request expired. Run the command again." };
|
|
2795
3012
|
}
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
3013
|
+
|
|
3014
|
+
// src/map/ts-loader.ts
|
|
3015
|
+
import { createRequire } from "module";
|
|
3016
|
+
import { pathToFileURL } from "url";
|
|
3017
|
+
import { join as join11 } from "path";
|
|
3018
|
+
async function loadTypeScript(cwd) {
|
|
3019
|
+
try {
|
|
3020
|
+
const req = createRequire(pathToFileURL(join11(cwd, "package.json")));
|
|
3021
|
+
const resolved = req.resolve("typescript");
|
|
3022
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
3023
|
+
return mod.default ?? mod;
|
|
3024
|
+
} catch {
|
|
2799
3025
|
}
|
|
2800
|
-
|
|
2801
|
-
|
|
3026
|
+
try {
|
|
3027
|
+
const mod = await import("typescript");
|
|
3028
|
+
return mod.default ?? mod;
|
|
3029
|
+
} catch {
|
|
3030
|
+
return null;
|
|
2802
3031
|
}
|
|
2803
|
-
return `${existing} && ${command}`;
|
|
2804
3032
|
}
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
3033
|
+
|
|
3034
|
+
// src/map/extract.ts
|
|
3035
|
+
import { readFileSync as readFileSync10, realpathSync as realpathSync3 } from "fs";
|
|
3036
|
+
import { createHash as createHash3 } from "crypto";
|
|
3037
|
+
import { relative as relative4 } from "path";
|
|
3038
|
+
|
|
3039
|
+
// src/map/ast.ts
|
|
3040
|
+
function rootIdentifier(node, ts) {
|
|
3041
|
+
let cur = node;
|
|
3042
|
+
while (cur) {
|
|
3043
|
+
if (ts.isIdentifier(cur)) return cur.text;
|
|
3044
|
+
if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNewExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
|
|
3045
|
+
cur = cur.expression;
|
|
3046
|
+
} else return void 0;
|
|
3047
|
+
}
|
|
3048
|
+
return void 0;
|
|
3049
|
+
}
|
|
3050
|
+
function spanOf(node) {
|
|
3051
|
+
const out = { line: lineOf(node) };
|
|
3052
|
+
try {
|
|
3053
|
+
out.start = node.getStart();
|
|
3054
|
+
out.end = node.getEnd();
|
|
3055
|
+
} catch {
|
|
3056
|
+
}
|
|
3057
|
+
return out;
|
|
3058
|
+
}
|
|
3059
|
+
function lineOf(node) {
|
|
3060
|
+
const sf = typeof node?.getSourceFile === "function" ? node.getSourceFile() : void 0;
|
|
3061
|
+
if (!sf) return void 0;
|
|
3062
|
+
try {
|
|
3063
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
3064
|
+
} catch {
|
|
3065
|
+
return void 0;
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
function guessScriptKind(ts, file) {
|
|
3069
|
+
if (file.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
3070
|
+
if (file.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
3071
|
+
if (file.endsWith(".js")) return ts.ScriptKind.JS;
|
|
3072
|
+
return ts.ScriptKind.TS;
|
|
3073
|
+
}
|
|
3074
|
+
function isFnLike(n, ts) {
|
|
3075
|
+
return ts.isArrowFunction(n) || ts.isFunctionExpression(n);
|
|
3076
|
+
}
|
|
3077
|
+
function hasExport(node, ts) {
|
|
3078
|
+
return Boolean(node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
3079
|
+
}
|
|
3080
|
+
function unwindChain(node, ts) {
|
|
3081
|
+
const calls = {};
|
|
3082
|
+
let cur = node;
|
|
3083
|
+
while (cur && ts.isCallExpression(cur)) {
|
|
3084
|
+
const callee = cur.expression;
|
|
3085
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
3086
|
+
calls[callee.name.text] = cur;
|
|
3087
|
+
cur = callee.expression;
|
|
3088
|
+
} else if (ts.isIdentifier(callee)) return { baseName: callee.text, baseCall: cur, calls };
|
|
3089
|
+
else break;
|
|
3090
|
+
}
|
|
3091
|
+
return { calls };
|
|
3092
|
+
}
|
|
3093
|
+
function methodFromObjectArg(baseCall, ts) {
|
|
3094
|
+
const arg = baseCall?.arguments?.[0];
|
|
3095
|
+
if (!arg || !ts.isObjectLiteralExpression(arg)) return void 0;
|
|
3096
|
+
for (const p of arg.properties) {
|
|
3097
|
+
if (ts.isPropertyAssignment(p) && p.name?.text === "method" && ts.isStringLiteralLike(p.initializer)) {
|
|
3098
|
+
return p.initializer.text.toUpperCase();
|
|
2819
3099
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
3100
|
+
}
|
|
3101
|
+
return void 0;
|
|
3102
|
+
}
|
|
3103
|
+
function bindingKey(el, ts) {
|
|
3104
|
+
if (!ts.isBindingElement(el)) return void 0;
|
|
3105
|
+
const prop = el.propertyName ?? el.name;
|
|
3106
|
+
return prop && ts.isIdentifier(prop) ? prop.text : void 0;
|
|
3107
|
+
}
|
|
3108
|
+
function isUninvokedFunctionDeclaration(n, ts) {
|
|
3109
|
+
if (ts.isFunctionDeclaration(n)) return true;
|
|
3110
|
+
if (isFnLike(n, ts)) {
|
|
3111
|
+
const p = n.parent;
|
|
3112
|
+
if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p))) return true;
|
|
3113
|
+
}
|
|
3114
|
+
return false;
|
|
3115
|
+
}
|
|
3116
|
+
function isShadowedByEnclosingBinding(node, name, ts) {
|
|
3117
|
+
for (let cur = node?.parent; cur; cur = cur.parent) {
|
|
3118
|
+
if (ts.isCatchClause(cur) && cur.variableDeclaration && ts.isIdentifier(cur.variableDeclaration.name) && cur.variableDeclaration.name.text === name) return true;
|
|
3119
|
+
const params = cur.parameters;
|
|
3120
|
+
if (!params) continue;
|
|
3121
|
+
for (const p of params) {
|
|
3122
|
+
if (!p?.name) continue;
|
|
3123
|
+
if (ts.isIdentifier(p.name) && p.name.text === name) return true;
|
|
3124
|
+
if (ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name)) {
|
|
3125
|
+
for (const el of p.name.elements) {
|
|
3126
|
+
if (ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === name) return true;
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
2825
3129
|
}
|
|
2826
|
-
|
|
2827
|
-
|
|
3130
|
+
}
|
|
3131
|
+
return false;
|
|
3132
|
+
}
|
|
3133
|
+
function calleeName(call, ts) {
|
|
3134
|
+
const c = call?.expression;
|
|
3135
|
+
if (!c) return void 0;
|
|
3136
|
+
if (ts.isPropertyAccessExpression(c)) return c.name.text;
|
|
3137
|
+
if (ts.isIdentifier(c)) return c.text;
|
|
3138
|
+
return void 0;
|
|
3139
|
+
}
|
|
3140
|
+
function isValueRead(id, ts) {
|
|
3141
|
+
const p = id.parent;
|
|
3142
|
+
if (!p) return true;
|
|
3143
|
+
if (ts.isPropertyAssignment(p) && p.name === id) return false;
|
|
3144
|
+
if (ts.isPropertyAccessExpression(p) && p.name === id) return false;
|
|
3145
|
+
if (ts.isBindingElement(p) && p.propertyName === id) return false;
|
|
3146
|
+
if ((ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) && p.name === id) return false;
|
|
3147
|
+
if (ts.isPropertySignature(p) || ts.isMethodSignature(p)) return false;
|
|
3148
|
+
return true;
|
|
3149
|
+
}
|
|
3150
|
+
function opCallOf(propAccess, ts) {
|
|
3151
|
+
const p = propAccess?.parent;
|
|
3152
|
+
return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess;
|
|
3153
|
+
}
|
|
3154
|
+
function localCalls(node, ts) {
|
|
3155
|
+
const names = [];
|
|
3156
|
+
const visit = (n) => {
|
|
3157
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) names.push(n.expression.text);
|
|
3158
|
+
ts.forEachChild(n, visit);
|
|
3159
|
+
};
|
|
3160
|
+
visit(node);
|
|
3161
|
+
return names;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// src/map/bindings.ts
|
|
3165
|
+
import { builtinModules } from "module";
|
|
3166
|
+
var BUILTINS = new Set(builtinModules);
|
|
3167
|
+
function buildModuleBindings(sf, ts) {
|
|
3168
|
+
const nameToModule = /* @__PURE__ */ new Map();
|
|
3169
|
+
const declared = /* @__PURE__ */ new Set();
|
|
3170
|
+
const exportNames = /* @__PURE__ */ new Map();
|
|
3171
|
+
const members = /* @__PURE__ */ new Set();
|
|
3172
|
+
const imports = /* @__PURE__ */ new Set();
|
|
3173
|
+
const origins = /* @__PURE__ */ new Map();
|
|
3174
|
+
const record = (local, mod, origin = "direct") => {
|
|
3175
|
+
nameToModule.set(local, mod);
|
|
3176
|
+
imports.add(mod);
|
|
3177
|
+
if (!origins.has(local)) origins.set(local, origin);
|
|
3178
|
+
};
|
|
3179
|
+
const declareBound = (nameNode) => {
|
|
3180
|
+
if (ts.isIdentifier(nameNode)) declared.add(nameNode.text);
|
|
3181
|
+
else if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) {
|
|
3182
|
+
for (const el of nameNode.elements) if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) declared.add(el.name.text);
|
|
2828
3183
|
}
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
3184
|
+
};
|
|
3185
|
+
const visit = (node) => {
|
|
3186
|
+
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
3187
|
+
const mod = node.moduleSpecifier.text;
|
|
3188
|
+
imports.add(mod);
|
|
3189
|
+
const clause = node.importClause;
|
|
3190
|
+
if (clause?.name) record(clause.name.text, mod);
|
|
3191
|
+
const nb = clause?.namedBindings;
|
|
3192
|
+
if (nb) {
|
|
3193
|
+
if (ts.isNamespaceImport(nb)) record(nb.name.text, mod);
|
|
3194
|
+
else if (ts.isNamedImports(nb)) for (const el of nb.elements) {
|
|
3195
|
+
record(el.name.text, mod);
|
|
3196
|
+
members.add(el.name.text);
|
|
3197
|
+
if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
2835
3200
|
}
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
3201
|
+
if (ts.isFunctionDeclaration(node) && node.name) declared.add(node.name.text);
|
|
3202
|
+
if (ts.isClassDeclaration(node) && node.name) declared.add(node.name.text);
|
|
3203
|
+
if (ts.isVariableStatement(node)) {
|
|
3204
|
+
for (const decl of node.declarationList.declarations) {
|
|
3205
|
+
declareBound(decl.name);
|
|
3206
|
+
const init = decl.initializer;
|
|
3207
|
+
const reqMod = requireSpecifier(init, ts);
|
|
3208
|
+
if (reqMod) {
|
|
3209
|
+
if (ts.isIdentifier(decl.name)) record(decl.name.text, reqMod);
|
|
3210
|
+
else if (ts.isObjectBindingPattern(decl.name)) {
|
|
3211
|
+
for (const el of decl.name.elements) if (ts.isIdentifier(el.name)) {
|
|
3212
|
+
record(el.name.text, reqMod);
|
|
3213
|
+
members.add(el.name.text);
|
|
3214
|
+
if (el.propertyName && ts.isIdentifier(el.propertyName)) exportNames.set(el.name.text, el.propertyName.text);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
if (init && ts.isIdentifier(decl.name)) {
|
|
3219
|
+
const callee = ts.isCallExpression(init) ? init.expression : ts.isNewExpression(init) ? init.expression : void 0;
|
|
3220
|
+
const root = callee ? rootIdentifier(callee, ts) : void 0;
|
|
3221
|
+
if (root) {
|
|
3222
|
+
pending.push([decl.name.text, root]);
|
|
3223
|
+
if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root), "factory");
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
2847
3227
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
3228
|
+
if ((ts.isFunctionDeclaration(node) || isFnLike(node, ts)) && node.body) {
|
|
3229
|
+
const fnName = ts.isFunctionDeclaration(node) && node.name ? node.name.text : ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name) ? node.parent.name.text : void 0;
|
|
3230
|
+
if (fnName) {
|
|
3231
|
+
const root = returnedRootIdentifier(node.body, ts);
|
|
3232
|
+
if (root) pending.push([fnName, root]);
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
ts.forEachChild(node, visit);
|
|
3236
|
+
};
|
|
3237
|
+
const pending = [];
|
|
3238
|
+
visit(sf);
|
|
3239
|
+
for (let i = 0; i < 4; i++) {
|
|
3240
|
+
let changed = false;
|
|
3241
|
+
for (const [name, root] of pending) {
|
|
3242
|
+
if (!nameToModule.has(name) && nameToModule.has(root)) {
|
|
3243
|
+
record(name, nameToModule.get(root), "factory");
|
|
3244
|
+
changed = true;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
if (!changed) break;
|
|
2863
3248
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
3249
|
+
const locals = new Set([...declared].filter((n) => !nameToModule.has(n)));
|
|
3250
|
+
return {
|
|
3251
|
+
resolve: (name) => nameToModule.get(name),
|
|
3252
|
+
exportNameOf: (name) => exportNames.get(name),
|
|
3253
|
+
originOf: (name) => origins.get(name),
|
|
3254
|
+
isPackageMember: (name) => members.has(name),
|
|
3255
|
+
imports,
|
|
3256
|
+
locals
|
|
2872
3257
|
};
|
|
2873
3258
|
}
|
|
3259
|
+
function returnedRootIdentifier(body, ts) {
|
|
3260
|
+
if (!ts.isBlock(body)) return rootIdentifier(body, ts);
|
|
3261
|
+
let found;
|
|
3262
|
+
const visit = (n) => {
|
|
3263
|
+
if (found) return;
|
|
3264
|
+
if (isUninvokedFunctionDeclaration(n, ts) && n !== body) return;
|
|
3265
|
+
if (ts.isReturnStatement(n) && n.expression) {
|
|
3266
|
+
found = rootIdentifier(n.expression, ts);
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
ts.forEachChild(n, visit);
|
|
3270
|
+
};
|
|
3271
|
+
visit(body);
|
|
3272
|
+
return found;
|
|
3273
|
+
}
|
|
3274
|
+
function requireSpecifier(init, ts) {
|
|
3275
|
+
if (init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === "require") {
|
|
3276
|
+
const a = init.arguments[0];
|
|
3277
|
+
if (a && ts.isStringLiteralLike(a)) return a.text;
|
|
3278
|
+
}
|
|
3279
|
+
return void 0;
|
|
3280
|
+
}
|
|
3281
|
+
function npmPackageOf(spec) {
|
|
3282
|
+
if (!spec) return void 0;
|
|
3283
|
+
if (spec.startsWith(".") || spec.startsWith("/")) return void 0;
|
|
3284
|
+
if (spec.startsWith("node:")) return spec;
|
|
3285
|
+
if (BUILTINS.has(spec)) return `node:${spec}`;
|
|
3286
|
+
const parts = spec.split("/");
|
|
3287
|
+
return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
3288
|
+
}
|
|
2874
3289
|
|
|
2875
|
-
// src/
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
3290
|
+
// src/map/sources.ts
|
|
3291
|
+
import { readFileSync as readFileSync6, readdirSync as readdirSync5, realpathSync, statSync as statSync3 } from "fs";
|
|
3292
|
+
import { join as join12, relative as relative2, isAbsolute } from "path";
|
|
3293
|
+
|
|
3294
|
+
// src/map/routes.ts
|
|
3295
|
+
var ROUTE_REGISTER_NAMES = ["get", "post", "put", "patch", "delete", "options", "all", "head", "use"];
|
|
3296
|
+
var ROUTE_REGISTER = new Set(ROUTE_REGISTER_NAMES);
|
|
3297
|
+
var ROUTE_CALL_RE = new RegExp(`\\.(${[...ROUTE_REGISTER_NAMES, "route"].join("|")})\\s*\\(`);
|
|
3298
|
+
function functionNameFromPath(relFile) {
|
|
3299
|
+
const parts = relFile.split(/[\\/]/).filter(Boolean);
|
|
3300
|
+
const i = parts.lastIndexOf("functions");
|
|
3301
|
+
if (i === -1 || i === parts.length - 1) return void 0;
|
|
3302
|
+
const next = parts[i + 1];
|
|
3303
|
+
if (!next) return void 0;
|
|
3304
|
+
const base = next.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, "");
|
|
3305
|
+
return base === "index" ? void 0 : base;
|
|
3306
|
+
}
|
|
3307
|
+
function routeFromFilePath(relFile) {
|
|
3308
|
+
const parts = relFile.split(/[\\/]/).filter(Boolean);
|
|
3309
|
+
if (parts.length === 0) return {};
|
|
3310
|
+
const base = (parts[parts.length - 1] ?? "").replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/, "");
|
|
3311
|
+
const dirs = parts.slice(0, -1);
|
|
3312
|
+
const at = (name) => dirs.lastIndexOf(name);
|
|
3313
|
+
let segs = null;
|
|
3314
|
+
if (base === "route" && at("app") !== -1) {
|
|
3315
|
+
segs = dirs.slice(at("app") + 1);
|
|
3316
|
+
} else if (base === "+server" && at("routes") !== -1) {
|
|
3317
|
+
segs = dirs.slice(at("routes") + 1);
|
|
3318
|
+
} else if (at("pages") !== -1) {
|
|
3319
|
+
segs = [...dirs.slice(at("pages") + 1), ...base === "index" ? [] : [base]];
|
|
3320
|
+
} else if (at("server") !== -1) {
|
|
3321
|
+
segs = [...dirs.slice(at("server") + 1), ...base === "index" ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, "")]];
|
|
3322
|
+
}
|
|
3323
|
+
if (!segs) return {};
|
|
3324
|
+
segs = segs.filter((s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@") && !s.startsWith("_"));
|
|
3325
|
+
let dynamic = false;
|
|
3326
|
+
const mapped = segs.map((s) => {
|
|
3327
|
+
const m = /^\[+(\.{0,3})(.+?)\]+$/.exec(s);
|
|
3328
|
+
if (m) {
|
|
3329
|
+
dynamic = true;
|
|
3330
|
+
return ":" + m[2];
|
|
3331
|
+
}
|
|
3332
|
+
return s;
|
|
3333
|
+
});
|
|
3334
|
+
const route = "/" + mapped.join("/");
|
|
3335
|
+
return { route: route.length > 1 ? route.replace(/\/+$/, "") : "/", dynamic };
|
|
3336
|
+
}
|
|
3337
|
+
function routeFromChain(expr, ts) {
|
|
3338
|
+
let cur = expr;
|
|
3339
|
+
while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
3340
|
+
const nm = cur.expression.name.text;
|
|
3341
|
+
if (nm === "route") {
|
|
3342
|
+
const a = cur.arguments[0];
|
|
3343
|
+
return a && ts.isStringLiteralLike(a) ? a.text : void 0;
|
|
3344
|
+
}
|
|
3345
|
+
if (!ROUTE_REGISTER.has(nm)) return void 0;
|
|
3346
|
+
cur = cur.expression.expression;
|
|
3347
|
+
}
|
|
3348
|
+
return void 0;
|
|
3349
|
+
}
|
|
3350
|
+
function routeObject(obj, ts) {
|
|
3351
|
+
let url;
|
|
3352
|
+
let handler;
|
|
3353
|
+
const methods = [];
|
|
3354
|
+
for (const p of obj.properties) {
|
|
3355
|
+
const key = p.name?.text;
|
|
3356
|
+
if (ts.isPropertyAssignment(p)) {
|
|
3357
|
+
if ((key === "url" || key === "path") && ts.isStringLiteralLike(p.initializer)) url = p.initializer.text;
|
|
3358
|
+
if (key === "method") {
|
|
3359
|
+
if (ts.isStringLiteralLike(p.initializer)) methods.push(p.initializer.text.toUpperCase());
|
|
3360
|
+
else if (ts.isArrayLiteralExpression(p.initializer)) {
|
|
3361
|
+
for (const el of p.initializer.elements) if (ts.isStringLiteralLike(el)) methods.push(el.text.toUpperCase());
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
if (key === "handler" && isFnLike(p.initializer, ts)) handler = p.initializer;
|
|
3365
|
+
} else if (ts.isMethodDeclaration(p) && key === "handler") handler = p;
|
|
3366
|
+
}
|
|
3367
|
+
return { url, methods, handler };
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
// src/map/sources.ts
|
|
3371
|
+
function hasEntrySignal(text) {
|
|
3372
|
+
return text.includes("createServerFn") || /\bexport\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/.test(text) || ROUTE_CALL_RE.test(text) || text.includes("'use server'") || text.includes('"use server"') || text.includes("Deno.serve") || /\bserve\s*\(/.test(text);
|
|
3373
|
+
}
|
|
3374
|
+
function detectFramework(cwd) {
|
|
3375
|
+
try {
|
|
3376
|
+
const pkg = JSON.parse(readFileSync6(join12(cwd, "package.json"), "utf8"));
|
|
3377
|
+
const d = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
3378
|
+
if (d["@tanstack/react-start"] || d["@tanstack/start"] || d["@tanstack/solid-start"]) return "tanstack-start";
|
|
3379
|
+
if (d["next"]) return "next";
|
|
3380
|
+
if (d["@sveltejs/kit"]) return "sveltekit";
|
|
3381
|
+
if (d["@nestjs/core"]) return "nestjs";
|
|
3382
|
+
if (d["fastify"]) return "fastify";
|
|
3383
|
+
if (d["express"]) return "express";
|
|
3384
|
+
if (d["hono"]) return "hono";
|
|
3385
|
+
} catch {
|
|
3386
|
+
}
|
|
3387
|
+
try {
|
|
3388
|
+
if (statSync3(join12(cwd, "supabase", "functions")).isDirectory()) return "supabase-functions";
|
|
3389
|
+
} catch {
|
|
3390
|
+
}
|
|
3391
|
+
try {
|
|
3392
|
+
if (statSync3(join12(cwd, "functions")).isDirectory()) return "deno-functions";
|
|
3393
|
+
} catch {
|
|
3394
|
+
}
|
|
3395
|
+
return "unknown";
|
|
3396
|
+
}
|
|
3397
|
+
var isSourceFile = (name) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith(".d.ts");
|
|
3398
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([
|
|
3399
|
+
"node_modules",
|
|
3400
|
+
"dist",
|
|
3401
|
+
"build",
|
|
3402
|
+
"out",
|
|
3403
|
+
"coverage",
|
|
3404
|
+
"public",
|
|
3405
|
+
"static",
|
|
3406
|
+
"assets",
|
|
3407
|
+
".git",
|
|
3408
|
+
".next",
|
|
3409
|
+
".nuxt",
|
|
3410
|
+
".svelte-kit",
|
|
3411
|
+
".output",
|
|
3412
|
+
".vercel",
|
|
3413
|
+
".wrangler",
|
|
3414
|
+
".turbo",
|
|
3415
|
+
".cache",
|
|
3416
|
+
"vendor",
|
|
3417
|
+
"tmp",
|
|
3418
|
+
"temp",
|
|
3419
|
+
"__pycache__"
|
|
3420
|
+
]);
|
|
3421
|
+
function collectSources(dir, boundary, opts, out = [], seen = /* @__PURE__ */ new Set(), stats = { discovered: 0, unwalked: 0 }) {
|
|
3422
|
+
let key;
|
|
3423
|
+
try {
|
|
3424
|
+
key = realpathSync(dir);
|
|
3425
|
+
} catch {
|
|
3426
|
+
stats.unwalked++;
|
|
3427
|
+
return out;
|
|
3428
|
+
}
|
|
3429
|
+
if (seen.has(key)) return out;
|
|
3430
|
+
if (!opts.followOutside && !isInside(key, boundary)) {
|
|
3431
|
+
stats.unwalked++;
|
|
3432
|
+
return out;
|
|
3433
|
+
}
|
|
3434
|
+
seen.add(key);
|
|
3435
|
+
let entries;
|
|
3436
|
+
try {
|
|
3437
|
+
entries = readdirSync5(dir, { withFileTypes: true });
|
|
3438
|
+
} catch {
|
|
3439
|
+
stats.unwalked++;
|
|
3440
|
+
return out;
|
|
3441
|
+
}
|
|
3442
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
3443
|
+
for (const e of entries) {
|
|
3444
|
+
if (SKIP_DIRS2.has(e.name) || e.name.startsWith(".") && e.name !== ".") continue;
|
|
3445
|
+
const full = join12(dir, e.name);
|
|
3446
|
+
if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats);
|
|
3447
|
+
else if (e.isSymbolicLink()) {
|
|
3448
|
+
let st, real;
|
|
3449
|
+
try {
|
|
3450
|
+
st = statSync3(full);
|
|
3451
|
+
real = realpathSync(full);
|
|
3452
|
+
} catch {
|
|
3453
|
+
stats.unwalked++;
|
|
3454
|
+
continue;
|
|
3455
|
+
}
|
|
3456
|
+
if (!opts.followOutside && !isInside(real, boundary)) {
|
|
3457
|
+
if (st.isDirectory() || isSourceFile(e.name)) stats.unwalked++;
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
if (st.isDirectory()) collectSources(full, boundary, opts, out, seen, stats);
|
|
3461
|
+
else if (st.isFile() && isSourceFile(e.name)) {
|
|
3462
|
+
out.push(full);
|
|
3463
|
+
stats.discovered++;
|
|
3464
|
+
}
|
|
3465
|
+
} else if (isSourceFile(e.name)) {
|
|
3466
|
+
out.push(full);
|
|
3467
|
+
stats.discovered++;
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
return out;
|
|
3471
|
+
}
|
|
3472
|
+
function isInside(candidate, boundary) {
|
|
3473
|
+
if (candidate === boundary) return true;
|
|
3474
|
+
const rel = relative2(boundary, candidate);
|
|
3475
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
3476
|
+
}
|
|
3477
|
+
var DEPLOYMENT_SHAPES = [
|
|
3478
|
+
// Config first: these are declarations by the project itself, and they survive a build output being
|
|
3479
|
+
// absent (a fresh clone has no `.vercel`/`.wrangler` directory).
|
|
3480
|
+
{ shape: "vercel", evidence: "deployment-config", files: ["vercel.json"] },
|
|
3481
|
+
{ shape: "netlify", evidence: "deployment-config", files: ["netlify.toml"] },
|
|
3482
|
+
// Wrangler names a Workers/Pages deployment. `.jsonc` and `.json` are both current spellings.
|
|
3483
|
+
{ shape: "cloudflare-workers", evidence: "deployment-config", files: ["wrangler.toml", "wrangler.jsonc", "wrangler.json"] },
|
|
3484
|
+
// Pages advanced mode: a single worker entry at the project root takes over routing entirely. Unlike a
|
|
3485
|
+
// wrangler config, this file IS the server — it is a runtime entry, not a deployment declaration.
|
|
3486
|
+
{ shape: "cloudflare-pages-advanced", evidence: "runtime-entry", files: ["_worker.js", "_worker.ts"] },
|
|
3487
|
+
{ shape: "netlify-functions", evidence: "runtime-entry", dirs: ["netlify/functions", "netlify/edge-functions"] },
|
|
3488
|
+
{ shape: "supabase-functions", evidence: "runtime-entry", dirs: ["supabase/functions"], requiresFunctionEntry: true },
|
|
3489
|
+
// Ambiguous by nature and reported as one shape: a root `functions/` directory is Cloudflare Pages
|
|
3490
|
+
// Functions, Firebase functions, or a Deno layout depending on the platform, and nothing inside the
|
|
3491
|
+
// repository always distinguishes them. Naming it honestly is better than guessing a provider.
|
|
3492
|
+
{ shape: "root-functions-directory", evidence: "layout", dirs: ["functions"] },
|
|
3493
|
+
// The bare-root Vercel convention: `api/handler.ts` with no framework router. Next owns `pages/api`
|
|
3494
|
+
// and `app/api` instead, which the endpoint walk already recognizes, so this is reported as its own
|
|
3495
|
+
// shape rather than folded into `vercel`.
|
|
3496
|
+
{ shape: "root-api-directory", evidence: "layout", dirs: ["api"] }
|
|
3497
|
+
];
|
|
3498
|
+
function detectDeploymentShapes(cwd, opts = {}) {
|
|
3499
|
+
let boundary = opts.boundary ?? cwd;
|
|
3500
|
+
try {
|
|
3501
|
+
boundary = realpathSync(boundary);
|
|
3502
|
+
} catch {
|
|
3503
|
+
}
|
|
3504
|
+
const inProject = (path11) => {
|
|
3505
|
+
if (opts.followOutside) return true;
|
|
3506
|
+
try {
|
|
3507
|
+
return isInside(realpathSync(path11), boundary);
|
|
3508
|
+
} catch {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
const found = [];
|
|
3513
|
+
for (const candidate of DEPLOYMENT_SHAPES) {
|
|
3514
|
+
for (const file of candidate.files ?? []) {
|
|
3515
|
+
const full = join12(cwd, file);
|
|
3516
|
+
try {
|
|
3517
|
+
if (statSync3(full).isFile() && inProject(full)) {
|
|
3518
|
+
found.push({ shape: candidate.shape, source: file, evidence: candidate.evidence });
|
|
3519
|
+
break;
|
|
3520
|
+
}
|
|
3521
|
+
} catch {
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
for (const dir of candidate.dirs ?? []) {
|
|
3525
|
+
const full = join12(cwd, dir);
|
|
3526
|
+
try {
|
|
3527
|
+
if (!statSync3(full).isDirectory() || !inProject(full)) continue;
|
|
3528
|
+
} catch {
|
|
3529
|
+
continue;
|
|
3530
|
+
}
|
|
3531
|
+
if (candidate.requiresFunctionEntry ? holdsFunctionEntry(full) : holdsSourceFile(full)) {
|
|
3532
|
+
found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence });
|
|
3533
|
+
break;
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
return found;
|
|
3538
|
+
}
|
|
3539
|
+
function holdsFunctionEntry(dir) {
|
|
3540
|
+
try {
|
|
3541
|
+
for (const entry of readdirSync5(dir, { withFileTypes: true })) {
|
|
3542
|
+
if (!entry.isDirectory() || entry.name.startsWith("_")) continue;
|
|
3543
|
+
try {
|
|
3544
|
+
for (const nested of readdirSync5(join12(dir, entry.name), { withFileTypes: true })) {
|
|
3545
|
+
if (nested.isFile() && /^index\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(nested.name)) return true;
|
|
3546
|
+
}
|
|
3547
|
+
} catch {
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
} catch {
|
|
3551
|
+
}
|
|
3552
|
+
return false;
|
|
3553
|
+
}
|
|
3554
|
+
function holdsSourceFile(dir) {
|
|
3555
|
+
try {
|
|
3556
|
+
for (const entry of readdirSync5(dir, { withFileTypes: true })) {
|
|
3557
|
+
if (entry.isFile() && isSourceFile(entry.name)) return true;
|
|
3558
|
+
if (entry.isDirectory()) {
|
|
3559
|
+
try {
|
|
3560
|
+
for (const nested of readdirSync5(join12(dir, entry.name), { withFileTypes: true })) {
|
|
3561
|
+
if (nested.isFile() && isSourceFile(nested.name)) return true;
|
|
3562
|
+
}
|
|
3563
|
+
} catch {
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
} catch {
|
|
3568
|
+
}
|
|
3569
|
+
return false;
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
// src/map/surface.ts
|
|
3573
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
3574
|
+
import { join as join13 } from "path";
|
|
3575
|
+
var STATIC_GENERATORS = [
|
|
3576
|
+
{ dep: "vite", label: "vite" },
|
|
3577
|
+
{ dep: "react-scripts", label: "create-react-app" },
|
|
3578
|
+
{ dep: "gatsby", label: "gatsby" },
|
|
3579
|
+
{ dep: "parcel", label: "parcel" },
|
|
3580
|
+
{ dep: "@sveltejs/adapter-static", label: "sveltekit-static-adapter" }
|
|
3581
|
+
];
|
|
3582
|
+
var SSR_COMPANIONS = ["vike", "vite-plugin-ssr", "@react-router/node", "@react-router/serve", "vite-plugin-node"];
|
|
3583
|
+
var SERVER_DEPENDENCIES = [
|
|
3584
|
+
"express",
|
|
3585
|
+
"fastify",
|
|
3586
|
+
"hono",
|
|
3587
|
+
"koa",
|
|
3588
|
+
"@nestjs/core",
|
|
3589
|
+
"@hapi/hapi",
|
|
3590
|
+
"h3",
|
|
3591
|
+
"polka",
|
|
3592
|
+
"restify",
|
|
3593
|
+
"@tanstack/react-start",
|
|
3594
|
+
"@tanstack/start",
|
|
3595
|
+
"@tanstack/solid-start",
|
|
3596
|
+
"nuxt",
|
|
3597
|
+
"remix",
|
|
3598
|
+
"@remix-run/node",
|
|
3599
|
+
"@remix-run/server-runtime"
|
|
3600
|
+
];
|
|
3601
|
+
var CONDITIONAL_SERVER_DEPENDENCIES = [
|
|
3602
|
+
{ dep: "@sveltejs/kit", staticWhen: "@sveltejs/adapter-static" }
|
|
3603
|
+
];
|
|
3604
|
+
var SSR_ADAPTERS = ["@astrojs/node", "@astrojs/vercel", "@astrojs/cloudflare", "@astrojs/netlify", "@astrojs/deno"];
|
|
3605
|
+
var SHAPE_SIGNAL = {
|
|
3606
|
+
"runtime-entry": "runtime-entry",
|
|
3607
|
+
"deployment-config": "deployment-config",
|
|
3608
|
+
layout: "ambiguous-layout"
|
|
3609
|
+
};
|
|
3610
|
+
function readManifest(cwd) {
|
|
3611
|
+
try {
|
|
3612
|
+
const parsed = JSON.parse(readFileSync7(join13(cwd, "package.json"), "utf8"));
|
|
3613
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
3614
|
+
} catch {
|
|
3615
|
+
return null;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
function nextExportsStatically(cwd, manifest, ts) {
|
|
3619
|
+
const scripts = Object.values(manifest.scripts ?? {}).join(" ");
|
|
3620
|
+
if (/\bnext\s+export\b/.test(scripts)) return "next export (build script)";
|
|
3621
|
+
if (ts === void 0) return null;
|
|
3622
|
+
for (const file of ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]) {
|
|
3623
|
+
let text;
|
|
3624
|
+
try {
|
|
3625
|
+
text = readFileSync7(join13(cwd, file), "utf8");
|
|
3626
|
+
} catch {
|
|
3627
|
+
continue;
|
|
3628
|
+
}
|
|
3629
|
+
try {
|
|
3630
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
3631
|
+
if (exportedConfigIsStatic(sf, ts)) return `output: 'export' (${file})`;
|
|
3632
|
+
} catch {
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
return null;
|
|
3636
|
+
}
|
|
3637
|
+
function exportedConfigIsStatic(sf, ts) {
|
|
3638
|
+
const objects = [];
|
|
3639
|
+
const followed = /* @__PURE__ */ new Set();
|
|
3640
|
+
const collectFrom = (expr) => {
|
|
3641
|
+
if (!expr) return;
|
|
3642
|
+
if (ts.isObjectLiteralExpression(expr)) {
|
|
3643
|
+
objects.push(expr);
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
if (ts.isCallExpression(expr)) {
|
|
3647
|
+
for (const arg of expr.arguments) collectFrom(arg);
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (ts.isIdentifier(expr)) {
|
|
3651
|
+
const name = expr.text;
|
|
3652
|
+
if (followed.has(name)) return;
|
|
3653
|
+
followed.add(name);
|
|
3654
|
+
const visit = (node) => {
|
|
3655
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) {
|
|
3656
|
+
collectFrom(node.initializer);
|
|
3657
|
+
}
|
|
3658
|
+
ts.forEachChild(node, visit);
|
|
3659
|
+
};
|
|
3660
|
+
visit(sf);
|
|
3661
|
+
}
|
|
3662
|
+
};
|
|
3663
|
+
const findExports = (node) => {
|
|
3664
|
+
if (ts.isExportAssignment(node)) collectFrom(node.expression);
|
|
3665
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === "exports") {
|
|
3666
|
+
collectFrom(node.right);
|
|
3667
|
+
}
|
|
3668
|
+
ts.forEachChild(node, findExports);
|
|
3669
|
+
};
|
|
3670
|
+
findExports(sf);
|
|
3671
|
+
for (const object of objects) {
|
|
3672
|
+
for (const property of object.properties) {
|
|
3673
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
3674
|
+
const key = property.name;
|
|
3675
|
+
const keyName = ts.isIdentifier(key) || ts.isStringLiteralLike(key) ? key.text : void 0;
|
|
3676
|
+
if (keyName !== "output") continue;
|
|
3677
|
+
if (ts.isStringLiteralLike(property.initializer) && property.initializer.text === "export") return true;
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
return false;
|
|
3681
|
+
}
|
|
3682
|
+
function staticSignals(cwd, ts) {
|
|
3683
|
+
const manifest = readManifest(cwd);
|
|
3684
|
+
if (manifest === null) return [];
|
|
3685
|
+
const deps = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
3686
|
+
const signals = [];
|
|
3687
|
+
for (const generator of STATIC_GENERATORS) {
|
|
3688
|
+
if (deps[generator.dep] !== void 0) {
|
|
3689
|
+
signals.push({ signal: "static-generator", source: generator.label });
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
if (deps["astro"] !== void 0 && !SSR_ADAPTERS.some((adapter) => deps[adapter] !== void 0)) {
|
|
3693
|
+
signals.push({ signal: "static-generator", source: "astro (no SSR adapter)" });
|
|
3694
|
+
}
|
|
3695
|
+
if (deps["next"] !== void 0) {
|
|
3696
|
+
const exported = nextExportsStatically(cwd, manifest, ts);
|
|
3697
|
+
if (exported !== null) signals.push({ signal: "static-generator", source: `next: ${exported}` });
|
|
3698
|
+
}
|
|
3699
|
+
return signals;
|
|
3700
|
+
}
|
|
3701
|
+
function serverDependencySignals(cwd, ts) {
|
|
3702
|
+
const manifest = readManifest(cwd);
|
|
3703
|
+
if (manifest === null) return [];
|
|
3704
|
+
const deps = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
3705
|
+
const found = SERVER_DEPENDENCIES.filter((dep) => deps[dep] !== void 0);
|
|
3706
|
+
for (const conditional of CONDITIONAL_SERVER_DEPENDENCIES) {
|
|
3707
|
+
if (deps[conditional.dep] !== void 0 && deps[conditional.staticWhen] === void 0) {
|
|
3708
|
+
found.push(conditional.dep);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
for (const companion of SSR_COMPANIONS) if (deps[companion] !== void 0) found.push(companion);
|
|
3712
|
+
if (deps["next"] !== void 0 && nextExportsStatically(cwd, manifest, ts) === null) found.push("next");
|
|
3713
|
+
if (deps["astro"] !== void 0) {
|
|
3714
|
+
for (const adapter of SSR_ADAPTERS) if (deps[adapter] !== void 0) found.push(adapter);
|
|
3715
|
+
}
|
|
3716
|
+
return found.map((dep) => ({ signal: "server-dependency", source: dep }));
|
|
3717
|
+
}
|
|
3718
|
+
function classifyServerSurface(cwd, endpointCount, deploymentShapes, ts) {
|
|
3719
|
+
const evidence = [];
|
|
3720
|
+
if (endpointCount > 0) {
|
|
3721
|
+
evidence.push({ signal: "endpoint", source: `${endpointCount} recognized entry point(s)` });
|
|
3722
|
+
}
|
|
3723
|
+
for (const shape of deploymentShapes) {
|
|
3724
|
+
evidence.push({
|
|
3725
|
+
signal: SHAPE_SIGNAL[shape.evidence],
|
|
3726
|
+
source: `${shape.shape} (${shape.source})`
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
const servers = serverDependencySignals(cwd, ts);
|
|
3730
|
+
const statics = staticSignals(cwd, ts);
|
|
3731
|
+
const servesRequests = deploymentShapes.some((shape) => shape.evidence === "runtime-entry");
|
|
3732
|
+
if (endpointCount > 0 || servesRequests) {
|
|
3733
|
+
return { state: "server-runtime-detected", evidence };
|
|
3734
|
+
}
|
|
3735
|
+
evidence.push(...servers, ...statics);
|
|
3736
|
+
const blocked = servers.length > 0 || deploymentShapes.length > 0;
|
|
3737
|
+
if (statics.length > 0 && !blocked) {
|
|
3738
|
+
return { state: "static-build-detected", evidence };
|
|
3739
|
+
}
|
|
3740
|
+
return { state: "unknown", evidence };
|
|
3741
|
+
}
|
|
3742
|
+
function surfaceNote(surface) {
|
|
3743
|
+
if (surface.state === "server-runtime-detected") {
|
|
3744
|
+
return "serverSurface: a server runtime was recognized in the analysed source (see its evidence). Request-path protection applies to this app.";
|
|
3745
|
+
}
|
|
3746
|
+
if (surface.state === "static-build-detected") {
|
|
3747
|
+
return "serverSurface: a static build was identified and no server runtime was recognized in the analysed source. This is NOT deployment attestation \u2014 it describes the source, not what is deployed, and a serverless function added at the platform level would not appear here. Advisories against this app are dependency and bundle concerns rather than request-path risk.";
|
|
3748
|
+
}
|
|
3749
|
+
return 'serverSurface is UNKNOWN: neither a server runtime nor a static build could be positively identified. This is the honest answer for an unrecognised stack, and it must not be read as "no server side" \u2014 an unparsed framework produces no endpoints, and entry-point recognition has no completeness flag.';
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
// src/map/sinks.ts
|
|
3753
|
+
import { createHash as createHash2 } from "crypto";
|
|
3754
|
+
var DB_OPS = /* @__PURE__ */ new Set(["insert", "update", "delete", "select", "upsert", "rpc"]);
|
|
3755
|
+
var PRISMA_OPS = /* @__PURE__ */ new Set(["create", "createMany", "update", "updateMany", "delete", "deleteMany", "upsert", "findFirst", "findUnique", "findMany", "count", "aggregate"]);
|
|
3756
|
+
var FS_CALLS = /^(readFile|writeFile|readFileSync|writeFileSync|appendFile|createReadStream|createWriteStream|unlink|rm|rmSync|mkdir|readdir|stat|open)$/;
|
|
3757
|
+
var EXEC_CALLS = /^(exec|execSync|spawn|spawnSync|execFile|execFileSync|fork)$/;
|
|
3758
|
+
var HTTP_CALLS = /^(fetch|got|request)$/;
|
|
3759
|
+
var HTTP_MEMBER_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "head", "request"]);
|
|
3760
|
+
var DB_PACKAGES = ["@supabase/supabase-js", "@prisma/client", "drizzle-orm", "knex", "kysely", "pg", "mysql2", "mysql", "sequelize", "typeorm", "mongoose", "better-sqlite3"];
|
|
3761
|
+
var MORE_DB_PACKAGES = [
|
|
3762
|
+
"postgres",
|
|
3763
|
+
"mssql",
|
|
3764
|
+
"tedious",
|
|
3765
|
+
"oracledb",
|
|
3766
|
+
"sqlite3",
|
|
3767
|
+
"mongodb",
|
|
3768
|
+
"ioredis",
|
|
3769
|
+
"@planetscale/database",
|
|
3770
|
+
"@neondatabase/serverless",
|
|
3771
|
+
"@libsql/client",
|
|
3772
|
+
"@vercel/postgres",
|
|
3773
|
+
"slonik",
|
|
3774
|
+
"sql.js"
|
|
3775
|
+
];
|
|
3776
|
+
var isDbPackage = (pkg) => DB_PACKAGES.includes(pkg) || MORE_DB_PACKAGES.includes(pkg) || [...DB_PACKAGES, ...MORE_DB_PACKAGES].some((p) => pkg.startsWith(p + "/"));
|
|
3777
|
+
var HTTP_PACKAGES = ["axios", "got", "node-fetch", "undici", "superagent", "ky"];
|
|
3778
|
+
var isHttpPackage = (pkg) => HTTP_PACKAGES.includes(pkg) || pkg === "node:http" || pkg === "node:https";
|
|
3779
|
+
var FS_PACKAGES = ["fs-extra", "graceful-fs", "memfs"];
|
|
3780
|
+
var isFsPackage = (pkg) => /^node:fs(\/promises)?$/.test(pkg) || FS_PACKAGES.includes(pkg);
|
|
3781
|
+
var EXEC_PACKAGES = ["execa", "cross-spawn", "shelljs", "zx"];
|
|
3782
|
+
var isExecPackage = (pkg) => pkg === "node:child_process" || EXEC_PACKAGES.includes(pkg);
|
|
3783
|
+
function recognizedSinkKinds(pkg) {
|
|
3784
|
+
const kinds = [];
|
|
3785
|
+
if (isDbPackage(pkg)) kinds.push("db");
|
|
3786
|
+
if (isFsPackage(pkg)) kinds.push("fs");
|
|
3787
|
+
if (isExecPackage(pkg)) kinds.push("exec");
|
|
3788
|
+
if (isHttpPackage(pkg)) kinds.push("http");
|
|
3789
|
+
return kinds;
|
|
3790
|
+
}
|
|
3791
|
+
function collectLocalSinks(sf, ts, bindings, ctx) {
|
|
3792
|
+
const map = /* @__PURE__ */ new Map();
|
|
3793
|
+
const visit = (node) => {
|
|
3794
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings, ctx));
|
|
3795
|
+
else if (ts.isVariableStatement(node)) {
|
|
3796
|
+
for (const decl of node.declarationList.declarations) {
|
|
3797
|
+
if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) {
|
|
3798
|
+
map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings, ctx));
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
ts.forEachChild(node, visit);
|
|
3803
|
+
};
|
|
3804
|
+
visit(sf);
|
|
3805
|
+
return map;
|
|
3806
|
+
}
|
|
3807
|
+
function sinksFrom(arrowOrNode, ts, localSinks, bindings, ctx) {
|
|
3808
|
+
if (!arrowOrNode) return [];
|
|
3809
|
+
const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode;
|
|
3810
|
+
if (!body) return [];
|
|
3811
|
+
const sinks = directSinks(body, ts, bindings, ctx);
|
|
3812
|
+
for (const called of localCalls(body, ts)) {
|
|
3813
|
+
for (const s of localSinks.get(called) ?? []) sinks.push(s);
|
|
3814
|
+
if (ctx) {
|
|
3815
|
+
const spec = bindings.resolve(called);
|
|
3816
|
+
if (spec && spec.startsWith(".")) {
|
|
3817
|
+
for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
if (ctx) {
|
|
3822
|
+
for (const [root, member] of namespaceMemberCalls(body, ts)) {
|
|
3823
|
+
const spec = bindings.resolve(root);
|
|
3824
|
+
if (spec && spec.startsWith(".")) {
|
|
3825
|
+
for (const s of ctx.graph.importedSinks(ctx.file, spec, member)) sinks.push(s);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
return dedupeSinks(sinks, ctx?.owner ?? "");
|
|
3830
|
+
}
|
|
3831
|
+
function namespaceMemberCalls(node, ts) {
|
|
3832
|
+
const out = [];
|
|
3833
|
+
const visit = (n) => {
|
|
3834
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && ts.isIdentifier(n.expression.expression)) {
|
|
3835
|
+
out.push([n.expression.expression.text, n.expression.name.text]);
|
|
3836
|
+
}
|
|
3837
|
+
ts.forEachChild(n, visit);
|
|
3838
|
+
};
|
|
3839
|
+
visit(node);
|
|
3840
|
+
return out;
|
|
3841
|
+
}
|
|
3842
|
+
function directSinks(node, ts, bindings, ctx) {
|
|
3843
|
+
const sinks = [];
|
|
3844
|
+
const baseOf = (base) => {
|
|
3845
|
+
const root = base ? rootIdentifier(base, ts) : void 0;
|
|
3846
|
+
if (!root) return {};
|
|
3847
|
+
const spec = bindings.resolve(root);
|
|
3848
|
+
const relative5 = spec !== void 0 && (spec.startsWith(".") || spec.startsWith("/"));
|
|
3849
|
+
const pkg = npmPackageOf(spec);
|
|
3850
|
+
if (pkg) return { pkg, root, spec };
|
|
3851
|
+
if (relative5 && ctx && spec) {
|
|
3852
|
+
const viaModule = ctx.graph.importedBinding(ctx.file, spec, bindings.exportNameOf(root) ?? root);
|
|
3853
|
+
if (viaModule) return { pkg: viaModule.package, root, spec };
|
|
3854
|
+
}
|
|
3855
|
+
return { local: bindings.locals.has(root), root, spec, relative: relative5 };
|
|
3856
|
+
};
|
|
3857
|
+
const attributionOf = (b, pkg) => b.pkg ? "import" : pkg ? "inferred" : void 0;
|
|
3858
|
+
const dbApi = (pkg, attribution) => {
|
|
3859
|
+
if (pkg && !isDbPackage(pkg)) return { apiUnconfirmed: true };
|
|
3860
|
+
return attribution === "import" || attribution === "global" ? { provider: "sql" } : {};
|
|
3861
|
+
};
|
|
3862
|
+
const infer = (kind) => {
|
|
3863
|
+
const table = kind === "db" ? DB_PACKAGES : HTTP_PACKAGES;
|
|
3864
|
+
for (const p of table) if (bindings.imports.has(p)) return p;
|
|
3865
|
+
return void 0;
|
|
3866
|
+
};
|
|
3867
|
+
const push = (s) => sinks.push(s);
|
|
3868
|
+
const visit = (n) => {
|
|
3869
|
+
if (n !== node && isUninvokedFunctionDeclaration(n, ts)) return;
|
|
3870
|
+
if (ts.isCallExpression(n)) {
|
|
3871
|
+
const callee = n.expression;
|
|
3872
|
+
if (ts.isPropertyAccessExpression(callee) && callee.name.text === "from") {
|
|
3873
|
+
const b = baseOf(callee.expression);
|
|
3874
|
+
const t = n.arguments[0];
|
|
3875
|
+
const table = t && ts.isStringLiteralLike(t) ? t.text : void 0;
|
|
3876
|
+
const parent = n.parent;
|
|
3877
|
+
if (!b.local && !b.relative && parent && ts.isPropertyAccessExpression(parent) && DB_OPS.has(parent.name.text)) {
|
|
3878
|
+
const pkg = b.pkg ?? infer("db");
|
|
3879
|
+
const attribution = attributionOf(b, pkg);
|
|
3880
|
+
push({ kind: "db", ...dbApi(pkg, attribution), package: pkg, table, op: parent.name.text, attribution, ...spanOf(opCallOf(parent, ts)) });
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
if (ts.isPropertyAccessExpression(callee)) {
|
|
3884
|
+
const method = callee.name.text;
|
|
3885
|
+
const b = baseOf(callee.expression);
|
|
3886
|
+
if (!b.local && !b.relative) {
|
|
3887
|
+
if (PRISMA_OPS.has(method) && ts.isPropertyAccessExpression(callee.expression)) {
|
|
3888
|
+
const prismaLikely = b.pkg === "@prisma/client" || !b.pkg && (bindings.imports.has("@prisma/client") || /prisma/i.test(b.root ?? ""));
|
|
3889
|
+
if (prismaLikely) push({ kind: "db", ...b.pkg ? { provider: "prisma" } : {}, package: "@prisma/client", table: callee.expression.name.text, op: method, attribution: b.pkg ? "import" : "inferred", ...spanOf(n) });
|
|
3890
|
+
}
|
|
3891
|
+
if (method === "query" || method === "execute") {
|
|
3892
|
+
const pkg = b.pkg ?? infer("db");
|
|
3893
|
+
const attribution = attributionOf(b, pkg);
|
|
3894
|
+
push({ kind: "db", ...dbApi(pkg, attribution), package: pkg, op: method, attribution, ...spanOf(n) });
|
|
3895
|
+
}
|
|
3896
|
+
if (FS_CALLS.test(method) && b.pkg && isFsPackage(b.pkg)) {
|
|
3897
|
+
push({ kind: "fs", package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
|
|
3898
|
+
}
|
|
3899
|
+
if (EXEC_CALLS.test(method) && b.pkg && isExecPackage(b.pkg)) {
|
|
3900
|
+
push({ kind: "exec", package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
|
|
3901
|
+
}
|
|
3902
|
+
if (HTTP_MEMBER_METHODS.has(method)) {
|
|
3903
|
+
if (b.pkg && isHttpPackage(b.pkg)) {
|
|
3904
|
+
push({ kind: "http", provider: b.root, package: b.pkg, op: method, attribution: "import", ...spanOf(n) });
|
|
3905
|
+
} else if (!b.spec && ts.isIdentifier(callee.expression) && /^(axios|http|https|got|ky)$/.test(callee.expression.text)) {
|
|
3906
|
+
push({ kind: "http", provider: callee.expression.text, package: infer("http"), op: method, attribution: "inferred", ...spanOf(n) });
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
}
|
|
3911
|
+
if (ts.isIdentifier(callee) && !bindings.locals.has(callee.text)) {
|
|
3912
|
+
const name = callee.text;
|
|
3913
|
+
const spec = bindings.resolve(name);
|
|
3914
|
+
const pkg = npmPackageOf(spec);
|
|
3915
|
+
const shadowed = isShadowedByEnclosingBinding(n, name, ts);
|
|
3916
|
+
const fromModule = spec !== void 0;
|
|
3917
|
+
const trueGlobal = !fromModule && !shadowed;
|
|
3918
|
+
if (HTTP_CALLS.test(name)) {
|
|
3919
|
+
if (pkg && isHttpPackage(pkg)) push({ kind: "http", provider: name, package: pkg, op: "request", attribution: "import", ...spanOf(n) });
|
|
3920
|
+
else if (name === "fetch" && trueGlobal) push({ kind: "http", provider: "fetch", op: "request", attribution: "global", ...spanOf(n) });
|
|
3921
|
+
}
|
|
3922
|
+
if (FS_CALLS.test(name) && pkg && isFsPackage(pkg)) {
|
|
3923
|
+
push({ kind: "fs", package: pkg, op: name, attribution: "import", ...spanOf(n) });
|
|
3924
|
+
}
|
|
3925
|
+
if (EXEC_CALLS.test(name) && pkg && isExecPackage(pkg)) {
|
|
3926
|
+
push({ kind: "exec", package: pkg, op: name, attribution: "import", ...spanOf(n) });
|
|
3927
|
+
}
|
|
3928
|
+
if (name === "eval" && trueGlobal) push({ kind: "eval", op: "eval", attribution: "global", ...spanOf(n) });
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "Function" && !bindings.locals.has("Function") && !isShadowedByEnclosingBinding(n, "Function", ts)) {
|
|
3932
|
+
push({ kind: "eval", op: "new Function", attribution: "global", ...spanOf(n) });
|
|
3933
|
+
}
|
|
3934
|
+
ts.forEachChild(n, visit);
|
|
3935
|
+
};
|
|
3936
|
+
visit(node);
|
|
3937
|
+
return sinks;
|
|
3938
|
+
}
|
|
3939
|
+
var ARGUMENT_ROLES = {
|
|
3940
|
+
exec: {
|
|
3941
|
+
exec: ["command"],
|
|
3942
|
+
execSync: ["command"],
|
|
3943
|
+
execFile: ["file", "args"],
|
|
3944
|
+
execFileSync: ["file", "args"],
|
|
3945
|
+
spawn: ["command", "args"],
|
|
3946
|
+
spawnSync: ["command", "args"],
|
|
3947
|
+
fork: ["file", "args"]
|
|
3948
|
+
},
|
|
3949
|
+
http: {
|
|
3950
|
+
fetch: ["url", "init"],
|
|
3951
|
+
request: ["url", "options"],
|
|
3952
|
+
get: ["url", "options"],
|
|
3953
|
+
head: ["url", "options"],
|
|
3954
|
+
delete: ["url", "options"],
|
|
3955
|
+
post: ["url", "body"],
|
|
3956
|
+
put: ["url", "body"],
|
|
3957
|
+
patch: ["url", "body"]
|
|
3958
|
+
},
|
|
3959
|
+
fs: {
|
|
3960
|
+
readFile: ["path"],
|
|
3961
|
+
readFileSync: ["path"],
|
|
3962
|
+
open: ["path"],
|
|
3963
|
+
writeFile: ["path", "content"],
|
|
3964
|
+
writeFileSync: ["path", "content"],
|
|
3965
|
+
appendFile: ["path", "content"],
|
|
3966
|
+
unlink: ["path"],
|
|
3967
|
+
rm: ["path"],
|
|
3968
|
+
rmSync: ["path"],
|
|
3969
|
+
mkdir: ["path"],
|
|
3970
|
+
readdir: ["path"],
|
|
3971
|
+
stat: ["path"],
|
|
3972
|
+
createReadStream: ["path"],
|
|
3973
|
+
createWriteStream: ["path"]
|
|
3974
|
+
},
|
|
3975
|
+
db: {
|
|
3976
|
+
query: ["sql", "values"],
|
|
3977
|
+
execute: ["sql", "values"],
|
|
3978
|
+
insert: ["values"],
|
|
3979
|
+
update: ["values"],
|
|
3980
|
+
upsert: ["values"],
|
|
3981
|
+
select: ["columns"],
|
|
3982
|
+
// Filters: the value half is still request data reaching the query, but as a bound parameter.
|
|
3983
|
+
eq: ["column", "value"],
|
|
3984
|
+
neq: ["column", "value"],
|
|
3985
|
+
gt: ["column", "value"],
|
|
3986
|
+
gte: ["column", "value"],
|
|
3987
|
+
lt: ["column", "value"],
|
|
3988
|
+
lte: ["column", "value"],
|
|
3989
|
+
like: ["column", "value"],
|
|
3990
|
+
ilike: ["column", "value"],
|
|
3991
|
+
match: ["values"],
|
|
3992
|
+
filter: ["column", "value"]
|
|
3993
|
+
},
|
|
3994
|
+
eval: { eval: ["code"], Function: ["code"] }
|
|
3995
|
+
};
|
|
3996
|
+
var CANDIDATE_FAMILIES = {
|
|
3997
|
+
http: { url: "ssrf" },
|
|
3998
|
+
exec: { command: "command-injection", file: "command-injection", args: "command-injection" },
|
|
3999
|
+
fs: { path: "path-traversal" },
|
|
4000
|
+
db: { sql: "sql-injection" },
|
|
4001
|
+
eval: { code: "code-injection" }
|
|
4002
|
+
};
|
|
4003
|
+
function argumentRoleOf(sinkKind, method, index, total = 0) {
|
|
4004
|
+
if (sinkKind === "eval" && method === "Function") return index === total - 1 ? "code" : "args";
|
|
4005
|
+
const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : void 0;
|
|
4006
|
+
return table?.[index] ?? "unknown";
|
|
4007
|
+
}
|
|
4008
|
+
function sinkId(s, owner) {
|
|
4009
|
+
return createHash2("sha256").update([s.kind, s.provider, s.package, s.table, s.op, s.file ?? owner, s.start, s.end].join("|")).digest("hex").slice(0, 12);
|
|
4010
|
+
}
|
|
4011
|
+
function dedupeSinks(sinks, owner) {
|
|
4012
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4013
|
+
const out = [];
|
|
4014
|
+
for (const s of sinks) {
|
|
4015
|
+
const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}:${s.start}`;
|
|
4016
|
+
if (!seen.has(key)) {
|
|
4017
|
+
seen.add(key);
|
|
4018
|
+
out.push({ ...s, id: sinkId(s, owner) });
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
return out;
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
// src/map/module-graph.ts
|
|
4025
|
+
import { readFileSync as readFileSync8, realpathSync as realpathSync2, statSync as statSync4 } from "fs";
|
|
4026
|
+
import { dirname as dirname4, join as join14, relative as relative3, resolve as resolvePath } from "path";
|
|
4027
|
+
var RESOLVE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
4028
|
+
function createModuleGraph(ts, opts) {
|
|
4029
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4030
|
+
const load = (file) => {
|
|
4031
|
+
if (cache.has(file)) return cache.get(file) ?? null;
|
|
4032
|
+
let entry = null;
|
|
4033
|
+
try {
|
|
4034
|
+
const text = readFileSync8(file, "utf8");
|
|
4035
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
|
|
4036
|
+
const bindings = buildModuleBindings(sf, ts);
|
|
4037
|
+
entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts), bindings };
|
|
4038
|
+
} catch {
|
|
4039
|
+
entry = null;
|
|
4040
|
+
}
|
|
4041
|
+
cache.set(file, entry);
|
|
4042
|
+
return entry;
|
|
4043
|
+
};
|
|
4044
|
+
const resolveInProject = (fromFile, specifier) => {
|
|
4045
|
+
const target = resolveRelativeModule(fromFile, specifier);
|
|
4046
|
+
if (!target) return void 0;
|
|
4047
|
+
if (!opts.followOutside) {
|
|
4048
|
+
let real = target;
|
|
4049
|
+
try {
|
|
4050
|
+
real = realpathSync2(target);
|
|
4051
|
+
} catch {
|
|
4052
|
+
}
|
|
4053
|
+
if (!isInside(real, opts.boundary)) return void 0;
|
|
4054
|
+
}
|
|
4055
|
+
return target;
|
|
4056
|
+
};
|
|
4057
|
+
return {
|
|
4058
|
+
resolveLocal: resolveInProject,
|
|
4059
|
+
importedSinks(fromFile, specifier, exportName) {
|
|
4060
|
+
const target = resolveInProject(fromFile, specifier);
|
|
4061
|
+
if (!target) return [];
|
|
4062
|
+
const mod = load(target);
|
|
4063
|
+
if (!mod) return [];
|
|
4064
|
+
const collected = [...mod.fnSinks.get(exportName) ?? []];
|
|
4065
|
+
for (const callee of mod.calleesOf.get(exportName) ?? []) {
|
|
4066
|
+
for (const s of mod.fnSinks.get(callee) ?? []) collected.push(s);
|
|
4067
|
+
}
|
|
4068
|
+
const rel = relative3(opts.cwd, target);
|
|
4069
|
+
return collected.map((s) => ({ ...s, file: rel }));
|
|
4070
|
+
},
|
|
4071
|
+
// A different question about the same module: not "what sinks are in there" but "what does this
|
|
4072
|
+
// export TRACE TO". The common AI-built layout puts the client in a lib file —
|
|
4073
|
+
// `export const db = createClient(...)` in `lib/db.ts`, imported everywhere — so the receiver of
|
|
4074
|
+
// `db.from('orders').insert(...)` resolves to a relative specifier and nothing else. Refusing it (as
|
|
4075
|
+
// an unattributable receiver) is right for app code but wrong here: one hop away it is a real
|
|
4076
|
+
// dependency, and that chain is import-to-import, fully static — evidence, not inference.
|
|
4077
|
+
importedBinding(fromFile, specifier, exportName) {
|
|
4078
|
+
const target = resolveInProject(fromFile, specifier);
|
|
4079
|
+
if (!target) return void 0;
|
|
4080
|
+
const mod = load(target);
|
|
4081
|
+
if (!mod) return void 0;
|
|
4082
|
+
const pkg = npmPackageOf(mod.bindings.resolve(exportName));
|
|
4083
|
+
if (pkg === void 0) return void 0;
|
|
4084
|
+
return {
|
|
4085
|
+
package: pkg,
|
|
4086
|
+
origin: mod.bindings.originOf(exportName) ?? "direct",
|
|
4087
|
+
name: mod.bindings.exportNameOf(exportName) ?? exportName
|
|
4088
|
+
};
|
|
4089
|
+
}
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
function resolveRelativeModule(fromFile, spec) {
|
|
4093
|
+
if (!spec.startsWith(".")) return void 0;
|
|
4094
|
+
const dir = dirname4(fromFile);
|
|
4095
|
+
const base = resolvePath(dir, spec);
|
|
4096
|
+
const candidates = [];
|
|
4097
|
+
const jsLike = /\.(js|jsx|mjs|cjs)$/.exec(base);
|
|
4098
|
+
if (jsLike) {
|
|
4099
|
+
const stem = base.slice(0, -jsLike[0].length);
|
|
4100
|
+
for (const e of RESOLVE_EXTS) candidates.push(stem + e);
|
|
4101
|
+
}
|
|
4102
|
+
candidates.push(base);
|
|
4103
|
+
for (const e of RESOLVE_EXTS) candidates.push(base + e);
|
|
4104
|
+
for (const e of RESOLVE_EXTS) candidates.push(join14(base, "index" + e));
|
|
4105
|
+
for (const c of candidates) {
|
|
4106
|
+
try {
|
|
4107
|
+
if (statSync4(c).isFile()) return c;
|
|
4108
|
+
} catch {
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
return void 0;
|
|
4112
|
+
}
|
|
4113
|
+
function collectCallees(sf, ts) {
|
|
4114
|
+
const map = /* @__PURE__ */ new Map();
|
|
4115
|
+
const visit = (node) => {
|
|
4116
|
+
let name;
|
|
4117
|
+
let body;
|
|
4118
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.body) {
|
|
4119
|
+
name = node.name.text;
|
|
4120
|
+
body = node.body;
|
|
4121
|
+
} else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFnLike(node.initializer, ts)) {
|
|
4122
|
+
name = node.name.text;
|
|
4123
|
+
body = node.initializer.body;
|
|
4124
|
+
}
|
|
4125
|
+
if (name && body) map.set(name, localCalls(body, ts));
|
|
4126
|
+
ts.forEachChild(node, visit);
|
|
4127
|
+
};
|
|
4128
|
+
visit(sf);
|
|
4129
|
+
return map;
|
|
4130
|
+
}
|
|
4131
|
+
|
|
4132
|
+
// src/map/coordinates.ts
|
|
4133
|
+
function runtimeCoordinate(source, path11) {
|
|
4134
|
+
if (/\[\d*\]/.test(path11)) {
|
|
4135
|
+
return { runtimeParameter: null, runtimeParameterReason: "array traversal: needs an array_key_value rule, not a dotted parameter" };
|
|
4136
|
+
}
|
|
4137
|
+
switch (source) {
|
|
4138
|
+
case "json-body":
|
|
4139
|
+
case "form-body":
|
|
4140
|
+
case "multipart":
|
|
4141
|
+
case "body":
|
|
4142
|
+
case "server-fn-data":
|
|
4143
|
+
return { runtimeParameter: `post.${path11}` };
|
|
4144
|
+
case "query":
|
|
4145
|
+
return { runtimeParameter: `get.${path11}` };
|
|
4146
|
+
case "cookie":
|
|
4147
|
+
return { runtimeParameter: `cookie.${path11}` };
|
|
4148
|
+
case "file":
|
|
4149
|
+
return { runtimeParameter: `files.${path11}` };
|
|
4150
|
+
case "header":
|
|
4151
|
+
return { runtimeParameter: `server.HTTP_${path11.toUpperCase().replace(/-/g, "_")}` };
|
|
4152
|
+
case "route-param":
|
|
4153
|
+
return { runtimeParameter: null, runtimeParameterReason: "route parameters are not exposed by the runtime resolver" };
|
|
4154
|
+
default:
|
|
4155
|
+
return { runtimeParameter: null, runtimeParameterReason: "input source could not be determined" };
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
function addressSpaceOf(source) {
|
|
4159
|
+
switch (source) {
|
|
4160
|
+
case "json-body":
|
|
4161
|
+
case "form-body":
|
|
4162
|
+
case "multipart":
|
|
4163
|
+
case "body":
|
|
4164
|
+
case "server-fn-data":
|
|
4165
|
+
return "post";
|
|
4166
|
+
case "query":
|
|
4167
|
+
return "get";
|
|
4168
|
+
case "cookie":
|
|
4169
|
+
return "cookie";
|
|
4170
|
+
case "file":
|
|
4171
|
+
return "files";
|
|
4172
|
+
case "header":
|
|
4173
|
+
return "server";
|
|
4174
|
+
case "route-param":
|
|
4175
|
+
return "route-param";
|
|
4176
|
+
default:
|
|
4177
|
+
return "unknown";
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
function isProvenFlow(confidence) {
|
|
4181
|
+
return confidence === "exact-local" || confidence === "transformed-local";
|
|
4182
|
+
}
|
|
4183
|
+
function inputIdOf(source, path11) {
|
|
4184
|
+
return `${addressSpaceOf(source)}:${path11}`;
|
|
4185
|
+
}
|
|
4186
|
+
function withCoordinates(fields, source) {
|
|
4187
|
+
return fields.map((f) => {
|
|
4188
|
+
const src = f.source ?? source;
|
|
4189
|
+
return { ...f, source: src, id: inputIdOf(src, f.name), ...runtimeCoordinate(src, f.name) };
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
// src/map/inputs.ts
|
|
4194
|
+
var ZOD_BASE = /* @__PURE__ */ new Set(["string", "number", "boolean", "array", "object", "enum", "bigint", "date", "record"]);
|
|
4195
|
+
var STRING_FORMATS = /* @__PURE__ */ new Set(["email", "uuid", "url", "ip", "ipv4", "ipv6", "cuid", "cuid2", "ulid", "emoji", "datetime", "base64", "jwt", "nanoid"]);
|
|
4196
|
+
var VALIDATOR_PACKAGES = /* @__PURE__ */ new Set(["zod", "valibot", "yup", "joi", "@hapi/joi", "superstruct"]);
|
|
4197
|
+
function inputsFromValidator(validatorCall, ts, bindings) {
|
|
4198
|
+
if (!validatorCall) return [];
|
|
4199
|
+
return zodObjectFields(validatorCall, ts, bindings);
|
|
4200
|
+
}
|
|
4201
|
+
function inputsFromHandler(params, body, ts, bindings, opts = {}) {
|
|
4202
|
+
const schemaSource = opts.validatorSource ?? "json-body";
|
|
4203
|
+
const schemaFields = zodObjectFields(body, ts, bindings);
|
|
4204
|
+
const reads = requestMemberAccesses(params, body, ts, opts);
|
|
4205
|
+
const byId = /* @__PURE__ */ new Map();
|
|
4206
|
+
const put = (name, source, extra = {}) => {
|
|
4207
|
+
const id = inputIdOf(source, name);
|
|
4208
|
+
if (byId.has(id)) return;
|
|
4209
|
+
byId.set(id, { ...extra, id, name, source, ...runtimeCoordinate(source, name) });
|
|
4210
|
+
};
|
|
4211
|
+
for (const f of schemaFields) {
|
|
4212
|
+
const { name, source, ...shape } = f;
|
|
4213
|
+
put(name, source ?? schemaSource, shape);
|
|
4214
|
+
}
|
|
4215
|
+
for (const { name, sources } of reads) for (const source of sources) put(name, source);
|
|
4216
|
+
return [...byId.values()];
|
|
4217
|
+
}
|
|
4218
|
+
function findValidatorObject(node, ts, bindings) {
|
|
4219
|
+
let found = null;
|
|
4220
|
+
const find = (n) => {
|
|
4221
|
+
if (found || !n) return;
|
|
4222
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "object") {
|
|
4223
|
+
const root = rootIdentifier(n.expression.expression, ts);
|
|
4224
|
+
const pkg = root ? npmPackageOf(bindings.resolve(root)) : void 0;
|
|
4225
|
+
const isValidator = pkg && VALIDATOR_PACKAGES.has(pkg) || !pkg && root === "z" && !bindings.locals.has(root);
|
|
4226
|
+
if (isValidator) {
|
|
4227
|
+
const arg = n.arguments[0];
|
|
4228
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
4229
|
+
found = arg;
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
ts.forEachChild(n, find);
|
|
4235
|
+
};
|
|
4236
|
+
find(node);
|
|
4237
|
+
return found;
|
|
4238
|
+
}
|
|
4239
|
+
function zodObjectFields(node, ts, bindings) {
|
|
4240
|
+
if (!node) return [];
|
|
4241
|
+
const lit = findValidatorObject(node.body ?? node, ts, bindings);
|
|
4242
|
+
return lit ? fieldsOfObject(lit, ts, bindings, "") : [];
|
|
4243
|
+
}
|
|
4244
|
+
function fieldsOfObject(objectLiteral, ts, bindings, prefix) {
|
|
4245
|
+
const fields = [];
|
|
4246
|
+
for (const p of objectLiteral.properties) {
|
|
4247
|
+
if (!ts.isPropertyAssignment(p) || !p.name) continue;
|
|
4248
|
+
const fname = p.name.text;
|
|
4249
|
+
if (!fname) continue;
|
|
4250
|
+
const shape = zodShape(p.initializer, ts);
|
|
4251
|
+
fields.push({ name: prefix + fname, ...shape });
|
|
4252
|
+
const nested = findValidatorObject(p.initializer, ts, bindings);
|
|
4253
|
+
if (nested) fields.push(...fieldsOfObject(nested, ts, bindings, prefix + fname + (shape.type === "array" ? "[]." : ".")));
|
|
4254
|
+
}
|
|
4255
|
+
return fields;
|
|
4256
|
+
}
|
|
4257
|
+
function numericValue(arg, ts) {
|
|
4258
|
+
if (!arg) return void 0;
|
|
4259
|
+
if (ts.isNumericLiteral(arg)) return Number(arg.text);
|
|
4260
|
+
if (ts.isPrefixUnaryExpression(arg) && arg.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(arg.operand)) return -Number(arg.operand.text);
|
|
4261
|
+
return void 0;
|
|
4262
|
+
}
|
|
4263
|
+
function zodShape(node, ts) {
|
|
4264
|
+
const shape = {};
|
|
4265
|
+
let cur = node;
|
|
4266
|
+
while (cur && ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
4267
|
+
const method = cur.expression.name.text;
|
|
4268
|
+
const arg0 = cur.arguments[0];
|
|
4269
|
+
if (ZOD_BASE.has(method) && !shape.type) shape.type = method;
|
|
4270
|
+
if (method === "min") {
|
|
4271
|
+
const v = numericValue(arg0, ts);
|
|
4272
|
+
if (v !== void 0) shape.min = v;
|
|
4273
|
+
}
|
|
4274
|
+
if (method === "max") {
|
|
4275
|
+
const v = numericValue(arg0, ts);
|
|
4276
|
+
if (v !== void 0) shape.max = v;
|
|
4277
|
+
}
|
|
4278
|
+
if (STRING_FORMATS.has(method) && !shape.format) shape.format = method;
|
|
4279
|
+
if (method === "regex" && arg0 && ts.isRegularExpressionLiteral(arg0) && !shape.pattern) shape.pattern = arg0.text;
|
|
4280
|
+
if (method === "optional" || method === "nullish") shape.optional = true;
|
|
4281
|
+
cur = cur.expression.expression;
|
|
4282
|
+
}
|
|
4283
|
+
return shape;
|
|
4284
|
+
}
|
|
4285
|
+
var REQ_SOURCES = ["body", "query", "params"];
|
|
4286
|
+
function requestMemberAccesses(params, body, ts, opts = {}) {
|
|
4287
|
+
if (!body) return [];
|
|
4288
|
+
const out = /* @__PURE__ */ new Map();
|
|
4289
|
+
const record = (name, source) => {
|
|
4290
|
+
const list = out.get(name) ?? [];
|
|
4291
|
+
if (!list.includes(source)) list.push(source);
|
|
4292
|
+
out.set(name, list);
|
|
4293
|
+
};
|
|
4294
|
+
const p0 = params?.[0];
|
|
4295
|
+
const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : void 0;
|
|
4296
|
+
const sourceNames = /* @__PURE__ */ new Map();
|
|
4297
|
+
const payloadNames = /* @__PURE__ */ new Set();
|
|
4298
|
+
if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text);
|
|
4299
|
+
if (p0 && !reqName && ts.isObjectBindingPattern(p0.name)) {
|
|
4300
|
+
for (const el of p0.name.elements) {
|
|
4301
|
+
const key = bindingKey(el, ts);
|
|
4302
|
+
if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) {
|
|
4303
|
+
sourceNames.set(el.name.text, namespaceSource(key));
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
const unwrap = (e) => {
|
|
4308
|
+
let cur = e;
|
|
4309
|
+
while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
|
|
4310
|
+
return cur;
|
|
4311
|
+
};
|
|
4312
|
+
const isPayloadExpr = (e) => ts.isIdentifier(e) && payloadNames.has(e.text);
|
|
4313
|
+
const isReqSourceExpr = (e) => isPayloadExpr(e) || ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text) || ts.isIdentifier(e) && sourceNames.has(e.text);
|
|
4314
|
+
const isBodyReadCall = (e) => {
|
|
4315
|
+
const inner = unwrap(e);
|
|
4316
|
+
return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ["json", "formData"].includes(inner.expression.name.text) && ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === reqName);
|
|
4317
|
+
};
|
|
4318
|
+
const visit = (n) => {
|
|
4319
|
+
if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) {
|
|
4320
|
+
record(n.name.text, sourceOfExpr(n.expression));
|
|
4321
|
+
}
|
|
4322
|
+
if (ts.isVariableDeclaration(n) && n.initializer) {
|
|
4323
|
+
const init = unwrap(n.initializer);
|
|
4324
|
+
if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer));
|
|
4325
|
+
if (ts.isObjectBindingPattern(n.name) && ts.isIdentifier(init) && reqName && init.text === reqName) {
|
|
4326
|
+
for (const el of n.name.elements) {
|
|
4327
|
+
const key = bindingKey(el, ts);
|
|
4328
|
+
if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name.text, namespaceSource(key));
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) {
|
|
4332
|
+
const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init);
|
|
4333
|
+
for (const el of n.name.elements) {
|
|
4334
|
+
const key = bindingKey(el, ts);
|
|
4335
|
+
if (key) record(key, src);
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
ts.forEachChild(n, visit);
|
|
4340
|
+
};
|
|
4341
|
+
visit(body);
|
|
4342
|
+
return [...out].map(([name, sources]) => ({ name, sources }));
|
|
4343
|
+
function sourceOfExpr(e) {
|
|
4344
|
+
if (isPayloadExpr(e)) return "server-fn-data";
|
|
4345
|
+
if (ts.isPropertyAccessExpression(e)) {
|
|
4346
|
+
if (e.name.text === "query") return "query";
|
|
4347
|
+
if (e.name.text === "params") return "route-param";
|
|
4348
|
+
if (e.name.text === "body") return "body";
|
|
4349
|
+
}
|
|
4350
|
+
if (ts.isIdentifier(e)) {
|
|
4351
|
+
const recorded = sourceNames.get(e.text);
|
|
4352
|
+
if (recorded) return recorded;
|
|
4353
|
+
}
|
|
4354
|
+
return "body";
|
|
4355
|
+
}
|
|
4356
|
+
function namespaceSource(key) {
|
|
4357
|
+
if (key === "query") return "query";
|
|
4358
|
+
if (key === "params") return "route-param";
|
|
4359
|
+
return "body";
|
|
4360
|
+
}
|
|
4361
|
+
function bodyReadSource(init) {
|
|
4362
|
+
const t = init?.getText?.() ?? "";
|
|
4363
|
+
return /formData\s*\(/.test(t) ? "form-body" : "json-body";
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
// src/map/flows.ts
|
|
4368
|
+
function spaceOfKey(key) {
|
|
4369
|
+
if (key === "query") return "get";
|
|
4370
|
+
if (key === "params") return "route-param";
|
|
4371
|
+
if (key === "body") return "post";
|
|
4372
|
+
if (key === "data" || key === "input" || key === "payload") return "post";
|
|
4373
|
+
return void 0;
|
|
4374
|
+
}
|
|
4375
|
+
function linkedFlows(body, params, inputs, sinks, ts) {
|
|
4376
|
+
const { flows, limitations } = linkFlows(body, params, inputs, sinks, ts);
|
|
4377
|
+
return limitations.length > 0 ? { flows, limitations } : { flows };
|
|
4378
|
+
}
|
|
4379
|
+
function linkFlows(bodyNode, params, inputs, sinks, ts) {
|
|
4380
|
+
if (!bodyNode || sinks.length === 0 || inputs.length === 0) return { flows: [], limitations: [] };
|
|
4381
|
+
const CONTAINER_KEYS = /* @__PURE__ */ new Set([...REQ_SOURCES, "data", "input", "payload"]);
|
|
4382
|
+
const rootPath = /* @__PURE__ */ new Map();
|
|
4383
|
+
const addRoot = (name, path11, space) => {
|
|
4384
|
+
if (!rootPath.has(name)) rootPath.set(name, { path: path11, space });
|
|
4385
|
+
};
|
|
4386
|
+
for (const p of params ?? []) {
|
|
4387
|
+
if (!p?.name) continue;
|
|
4388
|
+
if (ts.isIdentifier(p.name)) addRoot(p.name.text, "");
|
|
4389
|
+
else if (ts.isObjectBindingPattern(p.name)) {
|
|
4390
|
+
for (const el of p.name.elements) {
|
|
4391
|
+
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
|
|
4392
|
+
const key = bindingKey(el, ts);
|
|
4393
|
+
const container = key !== void 0 && CONTAINER_KEYS.has(key);
|
|
4394
|
+
addRoot(el.name.text, container ? "" : key ?? el.name.text, container ? spaceOfKey(key) : void 0);
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
const requestReadPath = (init) => {
|
|
4399
|
+
let cur = init;
|
|
4400
|
+
while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
|
|
4401
|
+
if (!cur) return void 0;
|
|
4402
|
+
if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
|
|
4403
|
+
const m = cur.expression.name.text;
|
|
4404
|
+
if (["json", "formData", "text"].includes(m)) {
|
|
4405
|
+
const root = rootIdentifier(cur.expression.expression, ts);
|
|
4406
|
+
return root && rootPath.has(root) ? { path: "", space: "post" } : void 0;
|
|
4407
|
+
}
|
|
4408
|
+
if (["parse", "safeParse", "validate", "cast"].includes(m)) {
|
|
4409
|
+
for (const a of cur.arguments) {
|
|
4410
|
+
const inner = requestReadPath(a);
|
|
4411
|
+
if (inner !== void 0) return inner;
|
|
4412
|
+
}
|
|
4413
|
+
return void 0;
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
const p = pathFromTainted(cur, ts, rootPath);
|
|
4417
|
+
return p;
|
|
4418
|
+
};
|
|
4419
|
+
const aliasVisit = (n) => {
|
|
4420
|
+
if (ts.isVariableDeclaration(n) && n.initializer) {
|
|
4421
|
+
const base = requestReadPath(n.initializer);
|
|
4422
|
+
if (base !== void 0) {
|
|
4423
|
+
if (ts.isIdentifier(n.name)) addRoot(n.name.text, base.path, base.space);
|
|
4424
|
+
else if (ts.isObjectBindingPattern(n.name)) {
|
|
4425
|
+
for (const el of n.name.elements) {
|
|
4426
|
+
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
|
|
4427
|
+
const key = bindingKey(el, ts);
|
|
4428
|
+
const space = base.space ?? (base.path === "" ? spaceOfKey(key) : void 0);
|
|
4429
|
+
const container = base.path === "" && key !== void 0 && CONTAINER_KEYS.has(key);
|
|
4430
|
+
addRoot(el.name.text, container ? "" : join22(base.path, key ?? el.name.text), space);
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
ts.forEachChild(n, aliasVisit);
|
|
4436
|
+
};
|
|
4437
|
+
aliasVisit(bodyNode);
|
|
4438
|
+
const callBySpan = /* @__PURE__ */ new Map();
|
|
4439
|
+
const callVisit = (n) => {
|
|
4440
|
+
if (ts.isCallExpression(n) || ts.isNewExpression(n)) {
|
|
4441
|
+
try {
|
|
4442
|
+
callBySpan.set(`${n.getStart()}:${n.getEnd()}`, n);
|
|
4443
|
+
} catch {
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
ts.forEachChild(n, callVisit);
|
|
4447
|
+
};
|
|
4448
|
+
callVisit(bodyNode);
|
|
4449
|
+
const flows = [];
|
|
4450
|
+
const allLimits = [];
|
|
4451
|
+
for (const sink of sinks) {
|
|
4452
|
+
const node = sink.file === void 0 && sink.start !== void 0 && sink.end !== void 0 ? callBySpan.get(`${sink.start}:${sink.end}`) : void 0;
|
|
4453
|
+
const reads = /* @__PURE__ */ new Map();
|
|
4454
|
+
const sinkLimits = [];
|
|
4455
|
+
if (node) {
|
|
4456
|
+
for (const call of fluentChainCalls(node, ts)) {
|
|
4457
|
+
const method = calleeName(call, ts);
|
|
4458
|
+
const args = call.arguments ?? [];
|
|
4459
|
+
for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l);
|
|
4460
|
+
for (let i = 0; i < args.length; i++) {
|
|
4461
|
+
const role = argumentRoleOf(sink.kind, method, i, args.length);
|
|
4462
|
+
const whole = pathFromTainted(args[i], ts, rootPath);
|
|
4463
|
+
for (const read2 of taintedReadPaths(args[i], ts, rootPath)) {
|
|
4464
|
+
const key = `${read2.space ?? "*"}:${read2.path}`;
|
|
4465
|
+
const exact = whole !== void 0 && whole.path === read2.path && whole.space === read2.space;
|
|
4466
|
+
const entry = reads.get(key) ?? { read: read2, roles: /* @__PURE__ */ new Set(), exact: false };
|
|
4467
|
+
entry.roles.add(role);
|
|
4468
|
+
entry.exact = entry.exact || exact;
|
|
4469
|
+
reads.set(key, entry);
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
for (const input of inputs) {
|
|
4475
|
+
const inputPath = normalizePath(input.name);
|
|
4476
|
+
const inputSpace = addressSpaceOf(input.source);
|
|
4477
|
+
const matched = [...reads.values()].filter(({ read: read2 }) => (read2.space === void 0 || read2.space === inputSpace) && (read2.path === inputPath || read2.path.startsWith(inputPath + ".")));
|
|
4478
|
+
const proven = matched.length > 0;
|
|
4479
|
+
const confidence = proven ? matched.some((m) => m.exact) ? "exact-local" : "transformed-local" : sink.file !== void 0 ? "imported" : sink.start === void 0 ? "unknown" : "heuristic";
|
|
4480
|
+
const roles = new Set(matched.flatMap(({ roles: rs }) => [...rs]));
|
|
4481
|
+
const family = sink.apiUnconfirmed ? void 0 : [...roles].map((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]).find(Boolean);
|
|
4482
|
+
const argumentRole = family ? [...roles].find((r) => CANDIDATE_FAMILIES[sink.kind]?.[r]) : [...roles].find((r) => r !== "unknown") ?? (proven ? "unknown" : void 0);
|
|
4483
|
+
const reasons = [];
|
|
4484
|
+
if (!proven) reasons.push(`flow evidence is "${confidence}": no proven local read of this input into the sink call`);
|
|
4485
|
+
if (!input.runtimeParameter) reasons.push(input.runtimeParameterReason ?? "input has no runtime parameter");
|
|
4486
|
+
if (sink.file !== void 0) reasons.push("sink is in an imported module: no local call-site evidence");
|
|
4487
|
+
if (sink.start === void 0) reasons.push("sink call could not be located in the source");
|
|
4488
|
+
if (sink.apiUnconfirmed) {
|
|
4489
|
+
reasons.push(`sink package "${sink.package}" is not a known ${sink.kind} provider: it does not establish a ${sink.kind} API (method name alone is not evidence)`);
|
|
4490
|
+
}
|
|
4491
|
+
if (sink.attribution !== "import" && sink.attribution !== "global") {
|
|
4492
|
+
reasons.push(sink.attribution === "inferred" ? `sink package "${sink.package}" was inferred from the file's other imports, not from the receiver (${sink.kind}.${sink.op ?? "?"}): the receiver may be any app object` : `sink receiver could not be traced to a dependency (${sink.kind}.${sink.op ?? "?"} on an unresolved receiver): a rule here would be a guess`);
|
|
4493
|
+
}
|
|
4494
|
+
if (proven && argumentRole === "unknown") reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? "?"}`);
|
|
4495
|
+
for (const l of sinkLimits) {
|
|
4496
|
+
reasons.push(l.kind === "dynamic-key" ? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter` : `spread reaches this sink (${l.detail}): the specific field is not identifiable`);
|
|
4497
|
+
}
|
|
4498
|
+
if (proven && argumentRole && argumentRole !== "unknown" && !family && !sink.apiUnconfirmed) {
|
|
4499
|
+
reasons.push(`argument role "${argumentRole}" on a ${sink.kind} sink is not a blockable pattern on its own`);
|
|
4500
|
+
}
|
|
4501
|
+
flows.push({
|
|
4502
|
+
input: input.name,
|
|
4503
|
+
inputId: input.id,
|
|
4504
|
+
sink,
|
|
4505
|
+
confidence,
|
|
4506
|
+
line: sink.line,
|
|
4507
|
+
...argumentRole ? { argumentRole } : {},
|
|
4508
|
+
...family ? { candidateFamily: family } : {},
|
|
4509
|
+
ruleGeneratable: reasons.length === 0,
|
|
4510
|
+
ruleGeneratableReasons: reasons
|
|
4511
|
+
});
|
|
4512
|
+
}
|
|
4513
|
+
for (const l of sinkLimits) allLimits.push(l);
|
|
4514
|
+
}
|
|
4515
|
+
return { flows, limitations: dedupeLimitations(allLimits) };
|
|
4516
|
+
}
|
|
4517
|
+
function dedupeLimitations(list) {
|
|
4518
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4519
|
+
return list.filter((l) => {
|
|
4520
|
+
const k = `${l.kind}:${l.detail}:${l.line}`;
|
|
4521
|
+
if (seen.has(k)) return false;
|
|
4522
|
+
seen.add(k);
|
|
4523
|
+
return true;
|
|
4524
|
+
});
|
|
4525
|
+
}
|
|
4526
|
+
function join22(base, seg) {
|
|
4527
|
+
return base ? `${base}.${seg}` : seg;
|
|
4528
|
+
}
|
|
4529
|
+
function normalizePath(path11) {
|
|
4530
|
+
return path11.replace(/\[\d*\]/g, "").split(".").filter(Boolean).join(".");
|
|
4531
|
+
}
|
|
4532
|
+
function fluentChainCalls(call, ts) {
|
|
4533
|
+
let root = call;
|
|
4534
|
+
for (; ; ) {
|
|
4535
|
+
const p = root.parent;
|
|
4536
|
+
if (p && ts.isPropertyAccessExpression(p) && p.expression === root) {
|
|
4537
|
+
root = p;
|
|
4538
|
+
continue;
|
|
4539
|
+
}
|
|
4540
|
+
if (p && ts.isCallExpression(p) && p.expression === root) {
|
|
4541
|
+
root = p;
|
|
4542
|
+
continue;
|
|
4543
|
+
}
|
|
4544
|
+
if (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p)) && p.expression === root) {
|
|
4545
|
+
root = p;
|
|
4546
|
+
continue;
|
|
4547
|
+
}
|
|
4548
|
+
break;
|
|
4549
|
+
}
|
|
4550
|
+
const out = [];
|
|
4551
|
+
const collect = (n) => {
|
|
4552
|
+
if (!n) return;
|
|
4553
|
+
if (ts.isCallExpression(n) || ts.isNewExpression(n)) out.push(n);
|
|
4554
|
+
if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n) || ts.isAwaitExpression(n) || ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) {
|
|
4555
|
+
collect(n.expression);
|
|
4556
|
+
}
|
|
4557
|
+
};
|
|
4558
|
+
collect(root);
|
|
4559
|
+
return out;
|
|
4560
|
+
}
|
|
4561
|
+
function taintedReadPaths(node, ts, rootPath) {
|
|
4562
|
+
const out = [];
|
|
4563
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4564
|
+
const add = (r) => {
|
|
4565
|
+
const key = `${r.space ?? "*"}:${r.path}`;
|
|
4566
|
+
if (!seen.has(key)) {
|
|
4567
|
+
seen.add(key);
|
|
4568
|
+
out.push(r);
|
|
4569
|
+
}
|
|
4570
|
+
};
|
|
4571
|
+
const visit = (n) => {
|
|
4572
|
+
if (!n) return;
|
|
4573
|
+
if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) {
|
|
4574
|
+
const read2 = pathFromTainted(n, ts, rootPath);
|
|
4575
|
+
if (read2 !== void 0) {
|
|
4576
|
+
add(read2);
|
|
4577
|
+
return;
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) {
|
|
4581
|
+
const r = rootPath.get(n.text);
|
|
4582
|
+
if (r.path) add({ path: normalizePath(r.path), space: r.space });
|
|
4583
|
+
}
|
|
4584
|
+
ts.forEachChild(n, visit);
|
|
4585
|
+
};
|
|
4586
|
+
visit(node);
|
|
4587
|
+
return out;
|
|
4588
|
+
}
|
|
4589
|
+
function sinkArgumentLimitations(node, ts, rootPath) {
|
|
4590
|
+
const out = [];
|
|
4591
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4592
|
+
const add = (kind, detail, n) => {
|
|
4593
|
+
const key = `${kind}:${detail}`;
|
|
4594
|
+
if (seen.has(key)) return;
|
|
4595
|
+
seen.add(key);
|
|
4596
|
+
out.push({ kind, detail, line: lineOf(n) });
|
|
4597
|
+
};
|
|
4598
|
+
const text = (n) => {
|
|
4599
|
+
try {
|
|
4600
|
+
return String(n.getText()).replace(/\s+/g, " ").slice(0, 120);
|
|
4601
|
+
} catch {
|
|
4602
|
+
return "<expression>";
|
|
4603
|
+
}
|
|
4604
|
+
};
|
|
4605
|
+
const visit = (n) => {
|
|
4606
|
+
if (!n) return;
|
|
4607
|
+
if (ts.isElementAccessExpression(n)) {
|
|
4608
|
+
const root = rootIdentifier(n.expression, ts);
|
|
4609
|
+
const arg = n.argumentExpression;
|
|
4610
|
+
if (root && rootPath.has(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) {
|
|
4611
|
+
add("dynamic-key", text(n), n);
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
if ((ts.isSpreadAssignment?.(n) || ts.isSpreadElement(n)) && n.expression) {
|
|
4615
|
+
const root = rootIdentifier(n.expression, ts);
|
|
4616
|
+
if (root && rootPath.has(root)) add("spread-into-sink", text(n.parent ?? n), n);
|
|
4617
|
+
}
|
|
4618
|
+
ts.forEachChild(n, visit);
|
|
4619
|
+
};
|
|
4620
|
+
visit(node);
|
|
4621
|
+
return out;
|
|
4622
|
+
}
|
|
4623
|
+
function pathFromTainted(node, ts, rootPath) {
|
|
4624
|
+
const segs = [];
|
|
4625
|
+
let cur = node;
|
|
4626
|
+
for (; ; ) {
|
|
4627
|
+
if (ts.isPropertyAccessExpression(cur)) {
|
|
4628
|
+
segs.unshift(cur.name.text);
|
|
4629
|
+
cur = cur.expression;
|
|
4630
|
+
continue;
|
|
4631
|
+
}
|
|
4632
|
+
if (ts.isElementAccessExpression(cur)) {
|
|
4633
|
+
const a = cur.argumentExpression;
|
|
4634
|
+
segs.unshift(a && ts.isStringLiteralLike(a) ? a.text : "[]");
|
|
4635
|
+
cur = cur.expression;
|
|
4636
|
+
continue;
|
|
4637
|
+
}
|
|
4638
|
+
if (ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) {
|
|
4639
|
+
cur = cur.expression;
|
|
4640
|
+
continue;
|
|
4641
|
+
}
|
|
4642
|
+
break;
|
|
4643
|
+
}
|
|
4644
|
+
if (!cur || !ts.isIdentifier(cur)) return void 0;
|
|
4645
|
+
const base = rootPath.get(cur.text);
|
|
4646
|
+
if (base === void 0) return void 0;
|
|
4647
|
+
let space = base.space;
|
|
4648
|
+
if (base.path === "" && segs.length > 1 && REQ_SOURCES.includes(segs[0])) {
|
|
4649
|
+
space = spaceOfKey(segs[0]) ?? space;
|
|
4650
|
+
segs.shift();
|
|
4651
|
+
}
|
|
4652
|
+
return { path: normalizePath([base.path, ...segs].filter(Boolean).join(".")), space };
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
// src/map/entries.ts
|
|
4656
|
+
var HTTP_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
4657
|
+
function extractFromFile(sf, ts, localSinks, bindings, ctx) {
|
|
4658
|
+
const out = [];
|
|
4659
|
+
const isServerActionsFile = fileHasUseServer(sf, ts);
|
|
4660
|
+
const visit = (node) => {
|
|
4661
|
+
if (ts.isVariableStatement(node) && hasExport(node, ts)) {
|
|
4662
|
+
for (const decl of node.declarationList.declarations) {
|
|
4663
|
+
if (decl.initializer && ts.isCallExpression(decl.initializer)) {
|
|
4664
|
+
const chain = unwindChain(decl.initializer, ts);
|
|
4665
|
+
if (chain.baseName === "createServerFn" && ts.isIdentifier(decl.name)) {
|
|
4666
|
+
const validatorCall = chain.calls["inputValidator"] ?? chain.calls["validator"];
|
|
4667
|
+
const inputs = withCoordinates(inputsFromValidator(validatorCall, ts, bindings), "server-fn-data");
|
|
4668
|
+
const handlerFn = chain.calls["handler"]?.arguments?.[0];
|
|
4669
|
+
const sinks = sinksFrom(handlerFn, ts, localSinks, bindings, ctx);
|
|
4670
|
+
const handlerBody = handlerFn && isFnLike(handlerFn, ts) ? handlerFn.body : void 0;
|
|
4671
|
+
const ep = {
|
|
4672
|
+
name: decl.name.text,
|
|
4673
|
+
entryKind: "server-fn",
|
|
4674
|
+
method: methodFromObjectArg(chain.baseCall, ts),
|
|
4675
|
+
...spanOf(decl),
|
|
4676
|
+
inputs,
|
|
4677
|
+
sinks,
|
|
4678
|
+
...linkedFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts)
|
|
4679
|
+
};
|
|
4680
|
+
if (validatorCall && inputs.length === 0) ep.inputsResolved = false;
|
|
4681
|
+
out.push(ep);
|
|
4682
|
+
continue;
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) {
|
|
4686
|
+
if (HTTP_METHODS.has(decl.name.text)) {
|
|
4687
|
+
out.push(handlerEntry(decl.name.text, decl.name.text, decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl)));
|
|
4688
|
+
} else if (isServerActionsFile) {
|
|
4689
|
+
out.push(handlerEntry(decl.name.text, "server-action", decl.initializer.parameters, decl.initializer.body, ts, localSinks, bindings, ctx, spanOf(decl)));
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
if (ts.isFunctionDeclaration(node) && node.name && hasExport(node, ts)) {
|
|
4695
|
+
if (HTTP_METHODS.has(node.name.text)) {
|
|
4696
|
+
out.push(handlerEntry(node.name.text, node.name.text, node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node)));
|
|
4697
|
+
} else if (isServerActionsFile || hasUseServerDirective(node, ts)) {
|
|
4698
|
+
out.push(handlerEntry(node.name.text, "server-action", node.parameters, node.body, ts, localSinks, bindings, ctx, spanOf(node)));
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
if (ts.isCallExpression(node)) {
|
|
4702
|
+
const c = node.expression;
|
|
4703
|
+
const denoServe = ts.isPropertyAccessExpression(c) && c.name.text === "serve" && ts.isIdentifier(c.expression) && c.expression.text === "Deno";
|
|
4704
|
+
const bareServe = ts.isIdentifier(c) && c.text === "serve";
|
|
4705
|
+
if (denoServe || bareServe) {
|
|
4706
|
+
const handler = node.arguments.find((a) => isFnLike(a, ts));
|
|
4707
|
+
if (handler) {
|
|
4708
|
+
out.push(handlerEntry(functionNameFromPath(ctx.file) ?? "serve", "edge-function", handler.parameters, handler.body, ts, localSinks, bindings, ctx, spanOf(node)));
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
4713
|
+
const mname = node.expression.name.text;
|
|
4714
|
+
if (ROUTE_REGISTER.has(mname)) {
|
|
4715
|
+
const args = node.arguments;
|
|
4716
|
+
const first = args[0];
|
|
4717
|
+
const route = first && ts.isStringLiteralLike(first) ? first.text : routeFromChain(node.expression.expression, ts);
|
|
4718
|
+
const handler = args[args.length - 1];
|
|
4719
|
+
if (route !== void 0 && handler && isFnLike(handler, ts)) {
|
|
4720
|
+
out.push(handlerEntry(route, "route-registration", handler.parameters, handler.body, ts, localSinks, bindings, ctx, {
|
|
4721
|
+
// `use`/`all` register handlers but are not HTTP methods — leave method undefined.
|
|
4722
|
+
method: HTTP_METHODS.has(mname.toUpperCase()) ? mname.toUpperCase() : void 0,
|
|
4723
|
+
route,
|
|
4724
|
+
...spanOf(node)
|
|
4725
|
+
}));
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
if (mname === "route") {
|
|
4729
|
+
const arg = node.arguments[0];
|
|
4730
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
4731
|
+
const reg = routeObject(arg, ts);
|
|
4732
|
+
if (reg.url && reg.handler) {
|
|
4733
|
+
for (const m of reg.methods.length ? reg.methods : [void 0]) {
|
|
4734
|
+
out.push(handlerEntry(reg.url, "route-registration", reg.handler.parameters, reg.handler.body, ts, localSinks, bindings, ctx, { method: m, route: reg.url, ...spanOf(node) }));
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
ts.forEachChild(node, visit);
|
|
4741
|
+
};
|
|
4742
|
+
visit(sf);
|
|
4743
|
+
return out;
|
|
4744
|
+
}
|
|
4745
|
+
function fileHasUseServer(sf, ts) {
|
|
4746
|
+
const first = sf.statements?.[0];
|
|
4747
|
+
return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === "use server");
|
|
4748
|
+
}
|
|
4749
|
+
function hasUseServerDirective(fn, ts) {
|
|
4750
|
+
const first = fn.body?.statements?.[0];
|
|
4751
|
+
return Boolean(first && ts.isExpressionStatement(first) && ts.isStringLiteralLike(first.expression) && first.expression.text === "use server");
|
|
4752
|
+
}
|
|
4753
|
+
function handlerEntry(name, kindLabel, params, body, ts, localSinks, bindings, ctx, extra = {}) {
|
|
4754
|
+
const entryKind = kindLabel === "route-registration" || kindLabel === "server-action" || kindLabel === "edge-function" ? kindLabel : "route-handler";
|
|
4755
|
+
const payloadStyle = kindLabel === "server-action";
|
|
4756
|
+
const inputs = inputsFromHandler(params, body, ts, bindings, {
|
|
4757
|
+
payloadParam: payloadStyle,
|
|
4758
|
+
validatorSource: payloadStyle ? "server-fn-data" : "json-body"
|
|
4759
|
+
});
|
|
4760
|
+
const sinks = sinksFrom({ body, parameters: params, isSyntheticBody: true }, ts, localSinks, bindings, ctx);
|
|
4761
|
+
return {
|
|
4762
|
+
name,
|
|
4763
|
+
entryKind,
|
|
4764
|
+
method: extra.method ?? (HTTP_METHODS.has(name) ? name : void 0),
|
|
4765
|
+
route: extra.route,
|
|
4766
|
+
line: extra.line,
|
|
4767
|
+
start: extra.start,
|
|
4768
|
+
end: extra.end,
|
|
4769
|
+
inputs,
|
|
4770
|
+
sinks,
|
|
4771
|
+
...linkedFlows(body, params, inputs, sinks, ts)
|
|
4772
|
+
};
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
// src/map/imports.ts
|
|
4776
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
4777
|
+
import { join as join15 } from "path";
|
|
4778
|
+
var MAX_SITES = 5;
|
|
4779
|
+
var NPM_NAME = /^(?:@[^/@\s~][^/@\s]*\/)?[^/@\s.~][^/@\s]*$/;
|
|
4780
|
+
function isNpmPackageName(pkg) {
|
|
4781
|
+
return pkg.startsWith("node:") || NPM_NAME.test(pkg);
|
|
4782
|
+
}
|
|
4783
|
+
function readPathAliases(cwd) {
|
|
4784
|
+
const aliases = [];
|
|
4785
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
4786
|
+
let paths;
|
|
4787
|
+
try {
|
|
4788
|
+
const raw = readFileSync9(join15(cwd, name), "utf8");
|
|
4789
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
4790
|
+
paths = parsed.compilerOptions?.paths;
|
|
4791
|
+
} catch {
|
|
4792
|
+
continue;
|
|
4793
|
+
}
|
|
4794
|
+
for (const key of Object.keys(paths ?? {})) {
|
|
4795
|
+
if (key.startsWith(".")) continue;
|
|
4796
|
+
if (key.endsWith("*")) aliases.push({ prefix: key.slice(0, -1), wildcard: true });
|
|
4797
|
+
else aliases.push({ prefix: key, wildcard: false });
|
|
4798
|
+
}
|
|
4799
|
+
}
|
|
4800
|
+
return aliases;
|
|
4801
|
+
}
|
|
4802
|
+
function stripJsonComments(text) {
|
|
4803
|
+
let out = "";
|
|
4804
|
+
let inString = false;
|
|
4805
|
+
let quote = "";
|
|
4806
|
+
for (let i = 0; i < text.length; i++) {
|
|
4807
|
+
const c = text[i];
|
|
4808
|
+
const next = text[i + 1];
|
|
4809
|
+
if (inString) {
|
|
4810
|
+
out += c;
|
|
4811
|
+
if (c === "\\") {
|
|
4812
|
+
out += next ?? "";
|
|
4813
|
+
i++;
|
|
4814
|
+
continue;
|
|
4815
|
+
}
|
|
4816
|
+
if (c === quote) inString = false;
|
|
4817
|
+
continue;
|
|
4818
|
+
}
|
|
4819
|
+
if (c === '"' || c === "'") {
|
|
4820
|
+
inString = true;
|
|
4821
|
+
quote = c;
|
|
4822
|
+
out += c;
|
|
4823
|
+
continue;
|
|
4824
|
+
}
|
|
4825
|
+
if (c === "/" && next === "/") {
|
|
4826
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
4827
|
+
out += "\n";
|
|
4828
|
+
continue;
|
|
4829
|
+
}
|
|
4830
|
+
if (c === "/" && next === "*") {
|
|
4831
|
+
i += 2;
|
|
4832
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
4833
|
+
i++;
|
|
4834
|
+
continue;
|
|
4835
|
+
}
|
|
4836
|
+
out += c;
|
|
4837
|
+
}
|
|
4838
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
4839
|
+
}
|
|
4840
|
+
function collectFileImports(sf, ts) {
|
|
4841
|
+
const found = [];
|
|
4842
|
+
const lineOf2 = (node) => {
|
|
4843
|
+
try {
|
|
4844
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
4845
|
+
} catch {
|
|
4846
|
+
return void 0;
|
|
4847
|
+
}
|
|
4848
|
+
};
|
|
4849
|
+
const visit = (node) => {
|
|
4850
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
4851
|
+
const names = [];
|
|
4852
|
+
const clause = node.importClause;
|
|
4853
|
+
if (clause?.name) names.push("default");
|
|
4854
|
+
const nb = clause?.namedBindings;
|
|
4855
|
+
if (nb) {
|
|
4856
|
+
if (ts.isNamespaceImport(nb)) names.push("*");
|
|
4857
|
+
else if (ts.isNamedImports(nb)) {
|
|
4858
|
+
for (const el of nb.elements) names.push((el.propertyName ?? el.name).text);
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
found.push({ specifier: node.moduleSpecifier.text, names, line: lineOf2(node) });
|
|
4862
|
+
}
|
|
4863
|
+
if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
|
4864
|
+
const names = [];
|
|
4865
|
+
const ec = node.exportClause;
|
|
4866
|
+
if (ec && ts.isNamedExports(ec)) for (const el of ec.elements) names.push((el.propertyName ?? el.name).text);
|
|
4867
|
+
else if (ec && ts.isNamespaceExport(ec)) names.push("*");
|
|
4868
|
+
else names.push("*");
|
|
4869
|
+
found.push({ specifier: node.moduleSpecifier.text, names, line: lineOf2(node) });
|
|
4870
|
+
}
|
|
4871
|
+
if (ts.isCallExpression(node)) {
|
|
4872
|
+
const arg = node.arguments[0];
|
|
4873
|
+
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
4874
|
+
const isDynamic = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
4875
|
+
if ((isRequire || isDynamic) && arg && ts.isStringLiteralLike(arg)) {
|
|
4876
|
+
found.push({ specifier: arg.text, names: [isRequire ? "require" : "import()"], line: lineOf2(node) });
|
|
4877
|
+
}
|
|
4878
|
+
}
|
|
4879
|
+
ts.forEachChild(node, visit);
|
|
4880
|
+
};
|
|
4881
|
+
visit(sf);
|
|
4882
|
+
return found;
|
|
4883
|
+
}
|
|
4884
|
+
function scanFileImports(text, ts) {
|
|
4885
|
+
let refs;
|
|
4886
|
+
try {
|
|
4887
|
+
const pre = ts.preProcessFile(
|
|
4888
|
+
text,
|
|
4889
|
+
/* readImportFiles */
|
|
4890
|
+
true,
|
|
4891
|
+
/* detectJavaScriptImports */
|
|
4892
|
+
true
|
|
4893
|
+
);
|
|
4894
|
+
refs = pre.importedFiles ?? [];
|
|
4895
|
+
} catch {
|
|
4896
|
+
return null;
|
|
4897
|
+
}
|
|
4898
|
+
if (refs.length === 0) return [];
|
|
4899
|
+
const lineStarts = lineStartOffsets(text);
|
|
4900
|
+
return refs.map((r) => ({ specifier: r.fileName, names: [], line: lineAt(lineStarts, r.pos) }));
|
|
4901
|
+
}
|
|
4902
|
+
function countUnresolvableImports(text, ts) {
|
|
4903
|
+
let scanner;
|
|
4904
|
+
try {
|
|
4905
|
+
scanner = ts.createScanner(
|
|
4906
|
+
ts.ScriptTarget.Latest,
|
|
4907
|
+
/* skipTrivia */
|
|
4908
|
+
true,
|
|
4909
|
+
void 0,
|
|
4910
|
+
text
|
|
4911
|
+
);
|
|
4912
|
+
} catch {
|
|
4913
|
+
return 0;
|
|
4914
|
+
}
|
|
4915
|
+
const CALLEE_KINDS = new Set([
|
|
4916
|
+
ts.SyntaxKind.Identifier,
|
|
4917
|
+
ts.SyntaxKind.RequireKeyword,
|
|
4918
|
+
ts.SyntaxKind.ImportKeyword
|
|
4919
|
+
].filter((k) => k !== void 0));
|
|
4920
|
+
const DOT_KINDS = new Set([ts.SyntaxKind.DotToken, ts.SyntaxKind.QuestionDotToken].filter((k) => k !== void 0));
|
|
4921
|
+
const EMPTY = { kind: void 0, text: "" };
|
|
4922
|
+
const history = [EMPTY, EMPTY, EMPTY];
|
|
4923
|
+
const remember = (kind, text2) => {
|
|
4924
|
+
history[2] = history[1];
|
|
4925
|
+
history[1] = history[0];
|
|
4926
|
+
history[0] = { kind, text: text2 };
|
|
4927
|
+
};
|
|
4928
|
+
const escapedLoaderReference = (next) => {
|
|
4929
|
+
const callee = history[0];
|
|
4930
|
+
const qualifier = history[1];
|
|
4931
|
+
if (callee.kind === void 0 || !CALLEE_KINDS.has(callee.kind) || callee.text !== "require") return false;
|
|
4932
|
+
if (qualifier.kind !== void 0 && DOT_KINDS.has(qualifier.kind)) return false;
|
|
4933
|
+
return next !== ts.SyntaxKind.OpenParenToken && next !== ts.SyntaxKind.QuestionDotToken && next !== ts.SyntaxKind.DotToken;
|
|
4934
|
+
};
|
|
4935
|
+
let count = 0;
|
|
4936
|
+
try {
|
|
4937
|
+
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
4938
|
+
const text2 = scanner.getTokenText();
|
|
4939
|
+
if (escapedLoaderReference(token)) count++;
|
|
4940
|
+
if (token !== ts.SyntaxKind.OpenParenToken) {
|
|
4941
|
+
remember(token, text2);
|
|
4942
|
+
continue;
|
|
4943
|
+
}
|
|
4944
|
+
const optionalCall = history[0].kind === ts.SyntaxKind.QuestionDotToken;
|
|
4945
|
+
const callee = optionalCall ? history[1] : history[0];
|
|
4946
|
+
const qualifier = optionalCall ? history[2] : history[1];
|
|
4947
|
+
const isRequireCall = callee.kind !== void 0 && CALLEE_KINDS.has(callee.kind) && (callee.text === "require" || callee.text === "import") && !(qualifier.kind !== void 0 && DOT_KINDS.has(qualifier.kind));
|
|
4948
|
+
if (!isRequireCall) {
|
|
4949
|
+
remember(token, text2);
|
|
4950
|
+
continue;
|
|
4951
|
+
}
|
|
4952
|
+
remember(token, text2);
|
|
4953
|
+
const argument = scanner.scan();
|
|
4954
|
+
const argumentText = scanner.getTokenText();
|
|
4955
|
+
remember(argument, argumentText);
|
|
4956
|
+
const isLiteral = argument === ts.SyntaxKind.StringLiteral || argument === ts.SyntaxKind.NoSubstitutionTemplateLiteral;
|
|
4957
|
+
if (!isLiteral) {
|
|
4958
|
+
count++;
|
|
4959
|
+
continue;
|
|
4960
|
+
}
|
|
4961
|
+
const following = scanner.scan();
|
|
4962
|
+
remember(following, scanner.getTokenText());
|
|
4963
|
+
const argumentEnded = following === ts.SyntaxKind.CloseParenToken || following === ts.SyntaxKind.CommaToken;
|
|
4964
|
+
if (!argumentEnded) count++;
|
|
4965
|
+
}
|
|
4966
|
+
if (escapedLoaderReference(ts.SyntaxKind.EndOfFileToken)) count++;
|
|
4967
|
+
} catch {
|
|
4968
|
+
return count;
|
|
4969
|
+
}
|
|
4970
|
+
return count;
|
|
4971
|
+
}
|
|
4972
|
+
function createImportInventory(aliases = []) {
|
|
4973
|
+
const byPackage = /* @__PURE__ */ new Map();
|
|
4974
|
+
return {
|
|
4975
|
+
/** @param parsed whether these edges came from a full parse (names are trustworthy) or the scan. */
|
|
4976
|
+
add(relFile, edges, parsed) {
|
|
4977
|
+
for (const edge of edges) {
|
|
4978
|
+
const aliased = aliases.some((a) => a.wildcard ? edge.specifier.startsWith(a.prefix) : edge.specifier === a.prefix);
|
|
4979
|
+
if (aliased) continue;
|
|
4980
|
+
const pkg = npmPackageOf(edge.specifier);
|
|
4981
|
+
if (!pkg) continue;
|
|
4982
|
+
if (!isNpmPackageName(pkg)) continue;
|
|
4983
|
+
let acc = byPackage.get(pkg);
|
|
4984
|
+
if (!acc) {
|
|
4985
|
+
acc = { specifiers: /* @__PURE__ */ new Set(), names: /* @__PURE__ */ new Set(), namesComplete: true, sites: [], siteCount: 0 };
|
|
4986
|
+
byPackage.set(pkg, acc);
|
|
4987
|
+
}
|
|
4988
|
+
acc.specifiers.add(edge.specifier);
|
|
4989
|
+
for (const n of edge.names) acc.names.add(n);
|
|
4990
|
+
if (!parsed) acc.namesComplete = false;
|
|
4991
|
+
acc.siteCount++;
|
|
4992
|
+
if (acc.sites.length < MAX_SITES) acc.sites.push({ file: relFile, line: edge.line });
|
|
4993
|
+
}
|
|
4994
|
+
},
|
|
4995
|
+
list() {
|
|
4996
|
+
return [...byPackage.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([pkg, acc]) => {
|
|
4997
|
+
const names = [...acc.names].sort();
|
|
4998
|
+
const entry = {
|
|
4999
|
+
package: pkg,
|
|
5000
|
+
specifiers: [...acc.specifiers].sort(),
|
|
5001
|
+
namesComplete: acc.namesComplete,
|
|
5002
|
+
sites: acc.sites,
|
|
5003
|
+
siteCount: acc.siteCount,
|
|
5004
|
+
recognizedSinkKinds: recognizedSinkKinds(pkg)
|
|
5005
|
+
};
|
|
5006
|
+
if (names.length > 0) entry.names = names;
|
|
5007
|
+
return entry;
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
};
|
|
5011
|
+
}
|
|
5012
|
+
function lineStartOffsets(text) {
|
|
5013
|
+
const starts = [0];
|
|
5014
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
5015
|
+
return starts;
|
|
5016
|
+
}
|
|
5017
|
+
function lineAt(starts, pos) {
|
|
5018
|
+
let lo = 0;
|
|
5019
|
+
let hi = starts.length - 1;
|
|
5020
|
+
while (lo < hi) {
|
|
5021
|
+
const mid = lo + hi + 1 >> 1;
|
|
5022
|
+
if ((starts[mid] ?? 0) <= pos) lo = mid;
|
|
5023
|
+
else hi = mid - 1;
|
|
5024
|
+
}
|
|
5025
|
+
return lo + 1;
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
// src/map/invocations.ts
|
|
5029
|
+
var MAX_SITES2 = 3;
|
|
5030
|
+
function collectInvocations(sf, ts, bindings, ctx) {
|
|
5031
|
+
const found = [];
|
|
5032
|
+
const counts = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
|
|
5033
|
+
const isLocalName = (name, node) => bindings.locals.has(name) || isShadowedByEnclosingBinding(node, name, ts);
|
|
5034
|
+
const lineOf2 = (node) => {
|
|
5035
|
+
try {
|
|
5036
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
5037
|
+
} catch {
|
|
5038
|
+
return void 0;
|
|
5039
|
+
}
|
|
5040
|
+
};
|
|
5041
|
+
const traceRoot = (root) => {
|
|
5042
|
+
const specifier = bindings.resolve(root);
|
|
5043
|
+
if (specifier === void 0) return null;
|
|
5044
|
+
const direct = npmPackageOf(specifier);
|
|
5045
|
+
if (direct !== void 0) {
|
|
5046
|
+
const resolution = bindings.originOf(root) ?? "direct";
|
|
5047
|
+
return { pkg: direct, specifier, resolution, named: resolution === "direct" };
|
|
5048
|
+
}
|
|
5049
|
+
const exportName = bindings.exportNameOf(root) ?? root;
|
|
5050
|
+
const viaModule = ctx.graph.importedBinding(ctx.file, specifier, exportName);
|
|
5051
|
+
if (viaModule !== void 0) {
|
|
5052
|
+
return {
|
|
5053
|
+
pkg: viaModule.package,
|
|
5054
|
+
specifier,
|
|
5055
|
+
resolution: "reexport",
|
|
5056
|
+
named: viaModule.origin === "direct",
|
|
5057
|
+
apiName: viaModule.name
|
|
5058
|
+
};
|
|
5059
|
+
}
|
|
5060
|
+
return null;
|
|
5061
|
+
};
|
|
5062
|
+
const push = (traced, api, receiver, kind, node) => {
|
|
5063
|
+
const span = spanOf(node);
|
|
5064
|
+
found.push({
|
|
5065
|
+
package: traced.pkg,
|
|
5066
|
+
specifiers: [traced.specifier],
|
|
5067
|
+
api,
|
|
5068
|
+
...receiver !== void 0 ? { receiver } : {},
|
|
5069
|
+
symbol: receiver !== void 0 ? `${receiver}.${api}` : api,
|
|
5070
|
+
kind,
|
|
5071
|
+
// Only ever `import`: a global has no package to correlate against, and an inferred package is not
|
|
5072
|
+
// evidence about the value being called.
|
|
5073
|
+
attribution: "import",
|
|
5074
|
+
resolution: traced.resolution,
|
|
5075
|
+
callCount: 1,
|
|
5076
|
+
sites: [{ file: ctx.owner, line: lineOf2(node), start: span.start, end: span.end }]
|
|
5077
|
+
});
|
|
5078
|
+
};
|
|
5079
|
+
const visit = (node) => {
|
|
5080
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
|
|
5081
|
+
const callee = node.expression;
|
|
5082
|
+
counts.total++;
|
|
5083
|
+
if (ts.isIdentifier(callee)) {
|
|
5084
|
+
const traced = traceRoot(callee.text);
|
|
5085
|
+
if (traced?.named) {
|
|
5086
|
+
const api = traced.apiName ?? bindings.exportNameOf(callee.text) ?? callee.text;
|
|
5087
|
+
push(traced, api, void 0, ts.isNewExpression(node) ? "construct" : "call", node);
|
|
5088
|
+
counts.dependency++;
|
|
5089
|
+
} else if (traced) {
|
|
5090
|
+
counts.ambiguous++;
|
|
5091
|
+
} else if (isLocalName(callee.text, node)) {
|
|
5092
|
+
counts.local++;
|
|
5093
|
+
} else {
|
|
5094
|
+
counts.ambiguous++;
|
|
5095
|
+
}
|
|
5096
|
+
} else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
|
|
5097
|
+
const root = rootIdentifier(callee.expression, ts);
|
|
5098
|
+
const traced = root !== void 0 ? traceRoot(root) : null;
|
|
5099
|
+
if (traced && root !== void 0) {
|
|
5100
|
+
counts.dependency++;
|
|
5101
|
+
const receiver = traced.resolution === "direct" && bindings.isPackageMember(root) ? bindings.exportNameOf(root) ?? root : void 0;
|
|
5102
|
+
push(traced, callee.name.text, receiver, "member", node);
|
|
5103
|
+
} else if (root !== void 0 && ts.isIdentifier(callee.expression) && isLocalName(root, node)) {
|
|
5104
|
+
counts.local++;
|
|
5105
|
+
} else {
|
|
5106
|
+
counts.ambiguous++;
|
|
5107
|
+
}
|
|
5108
|
+
} else {
|
|
5109
|
+
counts.ambiguous++;
|
|
5110
|
+
}
|
|
5111
|
+
}
|
|
5112
|
+
ts.forEachChild(node, visit);
|
|
5113
|
+
};
|
|
5114
|
+
visit(sf);
|
|
5115
|
+
return { invocations: found, counts };
|
|
5116
|
+
}
|
|
5117
|
+
function createInvocationInventory() {
|
|
5118
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
5119
|
+
return {
|
|
5120
|
+
add(invocations) {
|
|
5121
|
+
for (const invocation of invocations) {
|
|
5122
|
+
const key = `${invocation.package}|${invocation.symbol}|${invocation.kind}|${invocation.resolution}`;
|
|
5123
|
+
const existing = byKey.get(key);
|
|
5124
|
+
if (existing === void 0) {
|
|
5125
|
+
byKey.set(key, { ...invocation });
|
|
5126
|
+
continue;
|
|
5127
|
+
}
|
|
5128
|
+
existing.callCount += invocation.callCount;
|
|
5129
|
+
for (const specifier of invocation.specifiers) {
|
|
5130
|
+
if (!existing.specifiers.includes(specifier)) existing.specifiers.push(specifier);
|
|
5131
|
+
}
|
|
5132
|
+
if (existing.sites.length < MAX_SITES2) existing.sites.push(...invocation.sites.slice(0, MAX_SITES2 - existing.sites.length));
|
|
5133
|
+
}
|
|
5134
|
+
},
|
|
5135
|
+
list() {
|
|
5136
|
+
return [...byKey.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, invocation]) => ({ ...invocation, specifiers: [...invocation.specifiers].sort() }));
|
|
5137
|
+
}
|
|
5138
|
+
};
|
|
5139
|
+
}
|
|
5140
|
+
|
|
5141
|
+
// src/map/extract.ts
|
|
5142
|
+
async function extractInputMap(cwd, ts, options = {}) {
|
|
5143
|
+
const notes = [];
|
|
5144
|
+
const endpoints = [];
|
|
5145
|
+
const failed = [];
|
|
5146
|
+
let boundary = cwd;
|
|
5147
|
+
try {
|
|
5148
|
+
boundary = realpathSync3(cwd);
|
|
5149
|
+
} catch {
|
|
5150
|
+
}
|
|
5151
|
+
const graph = createModuleGraph(ts, { cwd, boundary, followOutside: options.followSymlinks });
|
|
5152
|
+
const stats = { discovered: 0, unwalked: 0 };
|
|
5153
|
+
const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], /* @__PURE__ */ new Set(), stats);
|
|
5154
|
+
const imports = createImportInventory(readPathAliases(cwd));
|
|
5155
|
+
const invocations = createInvocationInventory();
|
|
5156
|
+
let parsed = 0;
|
|
5157
|
+
let preFiltered = 0;
|
|
5158
|
+
let importScanFailures = 0;
|
|
5159
|
+
let unresolvableImports = 0;
|
|
5160
|
+
let sourceBytes = 0;
|
|
5161
|
+
const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
|
|
5162
|
+
const startedAt = Date.now();
|
|
5163
|
+
const entryFiles = /* @__PURE__ */ new Set();
|
|
5164
|
+
const hopTargets = /* @__PURE__ */ new Set();
|
|
5165
|
+
for (const file of files) {
|
|
5166
|
+
try {
|
|
5167
|
+
const text = readFileSync10(file, "utf8");
|
|
5168
|
+
const relFile = relative4(cwd, file);
|
|
5169
|
+
sourceBytes += text.length;
|
|
5170
|
+
if (!hasEntrySignal(text)) {
|
|
5171
|
+
preFiltered++;
|
|
5172
|
+
const scanned = scanFileImports(text, ts);
|
|
5173
|
+
if (scanned === null) importScanFailures++;
|
|
5174
|
+
else imports.add(relFile, scanned, false);
|
|
5175
|
+
unresolvableImports += countUnresolvableImports(text, ts);
|
|
5176
|
+
continue;
|
|
5177
|
+
}
|
|
5178
|
+
parsed++;
|
|
5179
|
+
entryFiles.add(file);
|
|
5180
|
+
const fingerprint = createHash3("sha256").update(text).digest("hex").slice(0, 16);
|
|
5181
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file));
|
|
5182
|
+
const bindings = buildModuleBindings(sf, ts);
|
|
5183
|
+
for (const specifier of bindings.imports) {
|
|
5184
|
+
if (!specifier.startsWith(".")) continue;
|
|
5185
|
+
const target = graph.resolveLocal(file, specifier);
|
|
5186
|
+
if (target !== void 0) hopTargets.add(target);
|
|
5187
|
+
}
|
|
5188
|
+
imports.add(relFile, collectFileImports(sf, ts), true);
|
|
5189
|
+
unresolvableImports += countUnresolvableImports(text, ts);
|
|
5190
|
+
const ctx = { file, owner: relFile, graph };
|
|
5191
|
+
const called = collectInvocations(sf, ts, bindings, ctx);
|
|
5192
|
+
invocations.add(called.invocations);
|
|
5193
|
+
calls.total += called.counts.total;
|
|
5194
|
+
calls.dependency += called.counts.dependency;
|
|
5195
|
+
calls.local += called.counts.local;
|
|
5196
|
+
calls.ambiguous += called.counts.ambiguous;
|
|
5197
|
+
const localSinks = collectLocalSinks(sf, ts, bindings, ctx);
|
|
5198
|
+
for (const ep of extractFromFile(sf, ts, localSinks, bindings, ctx)) {
|
|
5199
|
+
if (ep.route === void 0 && ep.entryKind === "edge-function") {
|
|
5200
|
+
const fn = functionNameFromPath(relFile);
|
|
5201
|
+
if (fn) ep.route = "/" + fn;
|
|
5202
|
+
}
|
|
5203
|
+
if (ep.route === void 0 && (ep.entryKind === "route-handler" || ep.entryKind === "server-action")) {
|
|
5204
|
+
const derived = routeFromFilePath(relFile);
|
|
5205
|
+
if (derived.route) {
|
|
5206
|
+
ep.route = derived.route;
|
|
5207
|
+
if (derived.dynamic) ep.routeDynamic = true;
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
endpoints.push({ ...ep, file: relFile, fingerprint });
|
|
5211
|
+
}
|
|
5212
|
+
} catch (e) {
|
|
5213
|
+
if (typeof process !== "undefined" && process.env?.PS_MAP_DEBUG) console.error("[patchstack] map error", file, e);
|
|
5214
|
+
failed.push(relative4(cwd, file));
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
let hopParsed = 0;
|
|
5218
|
+
let hopFailures = 0;
|
|
5219
|
+
for (const target of hopTargets) {
|
|
5220
|
+
if (entryFiles.has(target)) continue;
|
|
5221
|
+
try {
|
|
5222
|
+
const text = readFileSync10(target, "utf8");
|
|
5223
|
+
const sf = ts.createSourceFile(target, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, target));
|
|
5224
|
+
const bindings = buildModuleBindings(sf, ts);
|
|
5225
|
+
const called = collectInvocations(sf, ts, bindings, { file: target, owner: relative4(cwd, target), graph });
|
|
5226
|
+
invocations.add(called.invocations);
|
|
5227
|
+
calls.total += called.counts.total;
|
|
5228
|
+
calls.dependency += called.counts.dependency;
|
|
5229
|
+
calls.local += called.counts.local;
|
|
5230
|
+
calls.ambiguous += called.counts.ambiguous;
|
|
5231
|
+
hopParsed++;
|
|
5232
|
+
} catch (e) {
|
|
5233
|
+
if (typeof process !== "undefined" && process.env?.PS_MAP_DEBUG) console.error("[patchstack] map hop error", target, e);
|
|
5234
|
+
hopFailures++;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
notes.push("Static analysis is best-effort \u2014 this is the DETECTED surface, not a proof of completeness.");
|
|
5238
|
+
notes.push('`inputs` and `sinks` are INVENTORIES (both present in the handler). Only `flows` asserts that an input reaches a sink: require confidence "exact-local" or "transformed-local" before pinning a rule, and identify the input by `inputId` \u2014 a field NAME can occur in more than one request namespace.');
|
|
5239
|
+
notes.push("Sinks are followed into same-file helpers and ONE hop into an imported relative module (a dependency\u2019s internals are not followed); deeper or dynamic indirection is not traced. Sinks inside declared-but-uncalled local functions are excluded.");
|
|
5240
|
+
notes.push('A sink `package` is resolved from the file\u2019s imports (`attribution: "import"`) or inferred from another import in the same file (`"inferred"`); an inferred package is a hint for a reviewer, not evidence about the receiver, and never licenses a rule.');
|
|
5241
|
+
if (!options.followSymlinks) notes.push("Symlinks leaving the project directory were not followed (use --follow-symlinks to include them).");
|
|
5242
|
+
if (failed.length > 0) {
|
|
5243
|
+
const sample = failed.slice(0, 5).join(", ");
|
|
5244
|
+
notes.push(`${failed.length} file(s) could not be analyzed and were skipped (fail-open): ${sample}${failed.length > 5 ? ", \u2026" : ""}.`);
|
|
5245
|
+
}
|
|
5246
|
+
const unresolved = endpoints.filter((e) => e.inputsResolved === false).length;
|
|
5247
|
+
if (unresolved > 0) {
|
|
5248
|
+
notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed \u2014 their inputs are UNKNOWN, not empty (marked inputsResolved: false).`);
|
|
5249
|
+
}
|
|
5250
|
+
const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => isProvenFlow(f.confidence))).length;
|
|
5251
|
+
if (heuristicOnly > 0) {
|
|
5252
|
+
notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no proven data link \u2014 their flows say "may reach", not "does reach" (see each flow's confidence).`);
|
|
5253
|
+
}
|
|
5254
|
+
if (endpoints.length === 0) notes.push("No recognized server-side entry points found under the analyzed roots.");
|
|
5255
|
+
const importList = imports.list();
|
|
5256
|
+
const unmodelled = importList.filter((d) => d.recognizedSinkKinds.length === 0).length;
|
|
5257
|
+
const importCoverageGaps = {
|
|
5258
|
+
unreadableFiles: failed.length,
|
|
5259
|
+
unscannableFiles: importScanFailures,
|
|
5260
|
+
unwalkedPaths: stats.unwalked,
|
|
5261
|
+
unresolvableImports
|
|
5262
|
+
};
|
|
5263
|
+
const environmentalGaps = failed.length + importScanFailures + stats.unwalked;
|
|
5264
|
+
const importsComplete = environmentalGaps === 0 && unresolvableImports === 0;
|
|
5265
|
+
notes.push("`imports` lists every package the app imports, from ALL source files \u2014 not only files holding an entry point. Absence of a SINK for a package is never evidence; absence of the PACKAGE is evidence only when coverage.importsComplete is true.");
|
|
5266
|
+
notes.push(`${unmodelled} of ${importList.length} imported package(s) have no recognized sink family (recognizedSinkKinds: []). The extractor models a small set of API families, so for those packages it cannot tell whether input reaches them: a vulnerability in one must stay "needs review" and can never be closed as unreachable using this map.`);
|
|
5267
|
+
if (environmentalGaps > 0) {
|
|
5268
|
+
notes.push(`The import inventory is INCOMPLETE: ${failed.length} file(s) could not be read, ${importScanFailures} could not be scanned, and ${stats.unwalked} path(s) could not be walked at all (unreadable directory, broken link, or a symlink leaving the project). A package may therefore be imported without appearing in \`imports\`. Do not read a package's absence as "not imported" while coverage.importsComplete is false.`);
|
|
5269
|
+
}
|
|
5270
|
+
if (unresolvableImports > 0) {
|
|
5271
|
+
notes.push(`The import inventory is INCOMPLETE for a reason no re-run can fix: ${unresolvableImports} import(s) do not name a resolvable module \u2014 either the specifier is computed at runtime (\`require(REGISTRY[kind])\`) or the loader itself was aliased (\`const r = require\`), so which package is loaded is not knowable from the source. Those imports appear NOWHERE in \`imports\` \u2014 not as an unresolved entry, simply absent \u2014 so a package's absence is not evidence of it being unused. This is a property of the application's code, not a scan failure. Reported conservatively: an aliased loader counts even if every call through it uses a literal.`);
|
|
5272
|
+
}
|
|
5273
|
+
const invocationList = invocations.list();
|
|
5274
|
+
if (invocationList.length > 0 || parsed > 0) {
|
|
5275
|
+
notes.push(`\`apiInvocations\` records ${invocationList.length} dependency API call(s) resolved from ${parsed} entry file(s) plus ${hopParsed} local module(s) they import directly. POSITIVE EVIDENCE ONLY: a package missing from it may still have its API called \u2014 see coverage.apiInventoryLimitations.`);
|
|
5276
|
+
}
|
|
5277
|
+
if (hopFailures > 0) {
|
|
5278
|
+
notes.push(`${hopFailures} local module(s) imported by an entry file could not be parsed, so any dependency call written in them is missing from \`apiInvocations\`.`);
|
|
5279
|
+
}
|
|
5280
|
+
let rssBytes;
|
|
5281
|
+
let peakRssBytes;
|
|
5282
|
+
try {
|
|
5283
|
+
if (typeof process !== "undefined") {
|
|
5284
|
+
if (typeof process.memoryUsage === "function") rssBytes = process.memoryUsage().rss;
|
|
5285
|
+
if (typeof process.resourceUsage === "function") {
|
|
5286
|
+
const maxRssKb = process.resourceUsage().maxRSS;
|
|
5287
|
+
if (typeof maxRssKb === "number" && maxRssKb > 0) peakRssBytes = maxRssKb * 1024;
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
} catch {
|
|
5291
|
+
}
|
|
5292
|
+
const deploymentShapes = detectDeploymentShapes(cwd, { boundary, followOutside: options.followSymlinks });
|
|
5293
|
+
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.`);
|
|
5294
|
+
const serverSurface = classifyServerSurface(cwd, endpoints.length, deploymentShapes, ts);
|
|
5295
|
+
notes.push(surfaceNote(serverSurface));
|
|
5296
|
+
return {
|
|
5297
|
+
version: 3,
|
|
5298
|
+
framework: detectFramework(cwd),
|
|
5299
|
+
deploymentShapes,
|
|
5300
|
+
serverSurface,
|
|
5301
|
+
endpoints,
|
|
5302
|
+
imports: importList,
|
|
5303
|
+
apiInvocations: invocationList,
|
|
5304
|
+
coverage: {
|
|
5305
|
+
adapter: "agnostic-v1",
|
|
5306
|
+
filesDiscovered: stats.discovered,
|
|
5307
|
+
filesParsed: parsed,
|
|
5308
|
+
filesPreFiltered: preFiltered,
|
|
5309
|
+
filesHopParsed: hopParsed,
|
|
5310
|
+
filesSkipped: failed.length,
|
|
5311
|
+
pathsUnwalked: stats.unwalked,
|
|
5312
|
+
importsComplete,
|
|
5313
|
+
importCoverageGaps,
|
|
5314
|
+
apiInvocations: invocationList.length,
|
|
5315
|
+
callsTotal: calls.total,
|
|
5316
|
+
callsDependency: calls.dependency,
|
|
5317
|
+
callsLocal: calls.local,
|
|
5318
|
+
callsAmbiguous: calls.ambiguous,
|
|
5319
|
+
sourceBytes,
|
|
5320
|
+
analysisMs: Date.now() - startedAt,
|
|
5321
|
+
...rssBytes !== void 0 ? { rssBytes } : {},
|
|
5322
|
+
...peakRssBytes !== void 0 ? { peakRssBytes } : {},
|
|
5323
|
+
// The list IS the partiality statement. There is deliberately no `apiInventoryComplete`: parsing
|
|
5324
|
+
// every file would raise recall without removing any of these, so no parsing budget could make an
|
|
5325
|
+
// absent invocation mean "not called".
|
|
5326
|
+
apiInventoryLimitations: [
|
|
5327
|
+
"only files carrying an entry-point signal, and the local modules they import directly, are parsed \u2014 a call two hops away is unseen",
|
|
5328
|
+
"a computed callee or property (`client[name]()`) cannot be resolved to an API",
|
|
5329
|
+
"a dynamic import()/require() with a non-literal specifier is not traced",
|
|
5330
|
+
"reflection, generated code, and syntax that fails to parse are invisible",
|
|
5331
|
+
"a receiver reached through more than one local hop, or through dependency injection, is not followed"
|
|
5332
|
+
],
|
|
5333
|
+
roots: ["."],
|
|
5334
|
+
notes
|
|
5335
|
+
}
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
|
|
5339
|
+
// src/map/index.ts
|
|
5340
|
+
async function buildInputMap(cwd, options = {}) {
|
|
5341
|
+
const ts = await loadTypeScript(cwd);
|
|
5342
|
+
if (!ts) {
|
|
5343
|
+
return {
|
|
5344
|
+
map: null,
|
|
5345
|
+
error: "Could not resolve a TypeScript compiler to parse the app. `map` uses your project\u2019s own `typescript` (already a devDependency of a TypeScript app) \u2014 install it and retry."
|
|
5346
|
+
};
|
|
5347
|
+
}
|
|
5348
|
+
const map = await extractInputMap(cwd, ts, options);
|
|
5349
|
+
return { map };
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5352
|
+
// src/setup.ts
|
|
5353
|
+
import { readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
|
|
5354
|
+
import path10 from "path";
|
|
5355
|
+
var SCAN_COMMAND = "patchstack-connect scan";
|
|
5356
|
+
var MARK_BUILD_COMMAND = "patchstack-connect mark-build";
|
|
5357
|
+
function setupProtection(cwd) {
|
|
5358
|
+
const install = runProtect(cwd);
|
|
5359
|
+
const verification = runVerify(cwd);
|
|
5360
|
+
return { install, verification };
|
|
5361
|
+
}
|
|
5362
|
+
function appendHook(existing, command) {
|
|
5363
|
+
if (existing === void 0 || existing.trim().length === 0) {
|
|
5364
|
+
return command;
|
|
5365
|
+
}
|
|
5366
|
+
if (existing.includes(command)) {
|
|
5367
|
+
return existing;
|
|
5368
|
+
}
|
|
5369
|
+
return `${existing} && ${command}`;
|
|
5370
|
+
}
|
|
5371
|
+
function wireBuildScripts(cwd, packageManager) {
|
|
5372
|
+
const target = path10.join(cwd, "package.json");
|
|
5373
|
+
const raw = readFileSync11(target, "utf8");
|
|
5374
|
+
const pkg = JSON.parse(raw);
|
|
5375
|
+
const scripts = pkg.scripts ?? {};
|
|
5376
|
+
const build = scripts.build;
|
|
5377
|
+
const postinstall = appendHook(scripts.postinstall, SCAN_COMMAND);
|
|
5378
|
+
if (build === void 0 || build.trim().length === 0) {
|
|
5379
|
+
if (postinstall === scripts.postinstall) {
|
|
5380
|
+
return {
|
|
5381
|
+
changed: false,
|
|
5382
|
+
strategy: "postinstall-only",
|
|
5383
|
+
detail: "dependency-install scan already wired; no build script to integrate."
|
|
5384
|
+
};
|
|
5385
|
+
}
|
|
5386
|
+
scripts.postinstall = postinstall;
|
|
5387
|
+
} else if (packageManager === "bun") {
|
|
5388
|
+
let nextBuild = build;
|
|
5389
|
+
if (!nextBuild.includes(SCAN_COMMAND)) {
|
|
5390
|
+
nextBuild = `${SCAN_COMMAND} && ${nextBuild}`;
|
|
5391
|
+
}
|
|
5392
|
+
if (!nextBuild.includes(MARK_BUILD_COMMAND)) {
|
|
5393
|
+
nextBuild = `${nextBuild} && ${MARK_BUILD_COMMAND}`;
|
|
5394
|
+
}
|
|
5395
|
+
if (nextBuild === build && postinstall === scripts.postinstall) {
|
|
5396
|
+
return {
|
|
5397
|
+
changed: false,
|
|
5398
|
+
strategy: "build-chain",
|
|
5399
|
+
detail: "dependency-install scan and build chain already wired."
|
|
5400
|
+
};
|
|
5401
|
+
}
|
|
5402
|
+
scripts.postinstall = postinstall;
|
|
5403
|
+
scripts.build = nextBuild;
|
|
5404
|
+
} else {
|
|
5405
|
+
const prebuild = appendHook(scripts.prebuild, SCAN_COMMAND);
|
|
5406
|
+
const postbuild = appendHook(scripts.postbuild, MARK_BUILD_COMMAND);
|
|
5407
|
+
if (prebuild === scripts.prebuild && postbuild === scripts.postbuild && postinstall === scripts.postinstall) {
|
|
5408
|
+
return {
|
|
5409
|
+
changed: false,
|
|
5410
|
+
strategy: "lifecycle-hooks",
|
|
5411
|
+
detail: "dependency-install, prebuild, and postbuild hooks are already wired."
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
scripts.postinstall = postinstall;
|
|
5415
|
+
scripts.prebuild = prebuild;
|
|
5416
|
+
scripts.postbuild = postbuild;
|
|
5417
|
+
}
|
|
5418
|
+
pkg.scripts = scripts;
|
|
5419
|
+
const indentMatch = raw.match(/^[\t ]+(?=")/m)?.[0];
|
|
5420
|
+
const indent = indentMatch?.includes(" ") ? " " : indentMatch?.length ?? 2;
|
|
5421
|
+
const trailingNewline = raw.endsWith("\n") ? "\n" : "";
|
|
5422
|
+
writeFileSync6(target, `${JSON.stringify(pkg, null, indent)}${trailingNewline}`, "utf8");
|
|
5423
|
+
if (build === void 0 || build.trim().length === 0) {
|
|
5424
|
+
return {
|
|
5425
|
+
changed: true,
|
|
5426
|
+
strategy: "postinstall-only",
|
|
5427
|
+
detail: "added a dependency-install scan; package.json has no build script."
|
|
5428
|
+
};
|
|
5429
|
+
}
|
|
5430
|
+
return packageManager === "bun" ? {
|
|
5431
|
+
changed: true,
|
|
5432
|
+
strategy: "build-chain",
|
|
5433
|
+
detail: "added a dependency-install scan and chained scan/mark-build around the build."
|
|
5434
|
+
} : {
|
|
5435
|
+
changed: true,
|
|
5436
|
+
strategy: "lifecycle-hooks",
|
|
5437
|
+
detail: "added scans to postinstall/prebuild and mark-build to postbuild."
|
|
5438
|
+
};
|
|
5439
|
+
}
|
|
5440
|
+
|
|
5441
|
+
// src/cli.ts
|
|
5442
|
+
var HELP = `@patchstack/connect \u2014 scan your lockfile and report packages to Patchstack.
|
|
5443
|
+
|
|
5444
|
+
Usage:
|
|
5445
|
+
patchstack-connect scan [options] Scan lockfile and POST to Patchstack.
|
|
5446
|
+
If no UUID is configured, the server
|
|
5447
|
+
provisions one and we persist it. After a
|
|
5448
|
+
successful post it also adds/updates the
|
|
5449
|
+
disclosure-widget <script> tag in the root
|
|
5450
|
+
HTML shell (index.html, public/index.html,
|
|
5451
|
+
or src/app.html) \u2014 opt out with
|
|
5452
|
+
"widget": false in .patchstackrc.json
|
|
5453
|
+
patchstack-connect setup [options] Finish the bounded project setup: run scan,
|
|
5454
|
+
manage the widget, install + verify runtime
|
|
5455
|
+
protection, and wire dependency/build scans.
|
|
5456
|
+
Never runs the project build
|
|
5457
|
+
patchstack-connect map [--dir <p>] [--out <f>] [--upload]
|
|
5458
|
+
Map the app's attack surface: entry points, the
|
|
5459
|
+
inputs each reads, the sinks it can reach, and
|
|
5460
|
+
evidence-backed input\u2192sink flows (each labelled
|
|
5461
|
+
with how the link was established). Best-effort static
|
|
5462
|
+
analysis \u2014 reports the DETECTED surface, with
|
|
5463
|
+
coverage counters. Prints JSON (--out writes a
|
|
5464
|
+
file; --follow-symlinks leaves the project dir).
|
|
5465
|
+
Uses the app's own TypeScript
|
|
5466
|
+
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
|
|
5467
|
+
with an existing site UUID
|
|
5468
|
+
patchstack-connect status [options] Show current configuration and whether the
|
|
5469
|
+
site still exists on Patchstack (active /
|
|
5470
|
+
removed)
|
|
5471
|
+
patchstack-connect uninstall [options] Signal Patchstack that this package is being
|
|
5472
|
+
removed from the project. An unclaimed site
|
|
5473
|
+
record is deleted; a claimed site is flagged
|
|
5474
|
+
for its owner to remove in the dashboard.
|
|
5475
|
+
Does NOT touch local files \u2014 see the
|
|
5476
|
+
"Uninstalling" steps in AGENT-INSTALL.md
|
|
5477
|
+
patchstack-connect mark-build [options] Stamp built HTML with a production flag +
|
|
5478
|
+
build fingerprint, and ensure the widget
|
|
5479
|
+
tag in built pages (run as a postbuild step)
|
|
5480
|
+
patchstack-connect protect [--demo|--check] Install always-on runtime protection (the
|
|
5481
|
+
guard). Auto-wires supported server stacks;
|
|
5482
|
+
for others it scaffolds a
|
|
5483
|
+
generic guard + prints a wiring plan.
|
|
5484
|
+
--demo seeds a broad sample rule set (for
|
|
5485
|
+
demonstrations, not production).
|
|
5486
|
+
--check verifies the guard is wired (exit 1
|
|
5487
|
+
if not) \u2014 for the wire-then-verify loop.
|
|
5488
|
+
patchstack-connect demo node-serialize [--url URL] Run the production-backed node-serialize
|
|
5489
|
+
walkthrough: verify the vulnerable package,
|
|
5490
|
+
scan it, wait for live rule 18843, install +
|
|
5491
|
+
verify the guard, and print test requests.
|
|
5492
|
+
Does not install vulnerable dependencies.
|
|
5493
|
+
patchstack-connect demo-guide node-serialize Show a read-only, state-aware walkthrough for
|
|
5494
|
+
preparing, running, proving, and cleaning up
|
|
5495
|
+
the production-backed local demo.
|
|
5496
|
+
patchstack-connect guide [--full] Show this project's setup status (what's done,
|
|
5497
|
+
what's missing, with tailored commands), then
|
|
5498
|
+
print the full setup guide. --full prints the
|
|
5499
|
+
guide even when setup is complete
|
|
5500
|
+
patchstack-connect login [options] Recover this site's Patchstack credential when
|
|
5501
|
+
.patchstackrc.json has been lost. Prints a short
|
|
5502
|
+
code to approve in the dashboard; approving
|
|
5503
|
+
rotates the credential, so the old one stops
|
|
5504
|
+
working
|
|
5505
|
+
patchstack-connect help Print this message
|
|
5506
|
+
|
|
5507
|
+
Options (for scan, setup, status, and uninstall):
|
|
5508
|
+
--site-uuid <uuid> Override the configured site UUID
|
|
5509
|
+
--endpoint <url> Override the API endpoint
|
|
5510
|
+
--dry-run (scan only) Show the payload without posting
|
|
2925
5511
|
|
|
2926
5512
|
Options (for mark-build):
|
|
2927
5513
|
--dir <path> Build output directory (default: auto-detect
|
|
@@ -2934,6 +5520,7 @@ Options (for demo and demo-guide):
|
|
|
2934
5520
|
Environment:
|
|
2935
5521
|
PATCHSTACK_SITE_UUID Site UUID
|
|
2936
5522
|
PATCHSTACK_API_KEY WP-format site API key for block-log reporting (never put in the widget)
|
|
5523
|
+
PATCHSTACK_PULSE_AUTH Credential for authenticated Pulse ingest (defaults to PATCHSTACK_API_KEY)
|
|
2937
5524
|
PATCHSTACK_TELEMETRY Set to off to disable block-log reporting
|
|
2938
5525
|
PATCHSTACK_API_BASE API origin for /oauth/token and /api/logs/log (default: https://api.patchstack.com)
|
|
2939
5526
|
PATCHSTACK_ENDPOINT API endpoint (default: https://api.patchstack.com/monitor/pulse/manifest)
|
|
@@ -2953,7 +5540,7 @@ Examples:
|
|
|
2953
5540
|
npx @patchstack/connect demo node-serialize
|
|
2954
5541
|
npx @patchstack/connect demo-guide node-serialize
|
|
2955
5542
|
`;
|
|
2956
|
-
var VALUE_FLAGS = /* @__PURE__ */ new Set(["site-uuid", "endpoint", "dir", "url"]);
|
|
5543
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["site-uuid", "endpoint", "dir", "url", "out"]);
|
|
2957
5544
|
function parseArgs(argv) {
|
|
2958
5545
|
const args = argv.slice(2);
|
|
2959
5546
|
const positional = [];
|
|
@@ -3005,6 +5592,103 @@ async function runInit(args) {
|
|
|
3005
5592
|
console.log("Next: run `npx @patchstack/connect scan` to send your first manifest.");
|
|
3006
5593
|
return 0;
|
|
3007
5594
|
}
|
|
5595
|
+
async function runLogin(args) {
|
|
5596
|
+
if (process.env.CI !== void 0 && process.env.CI !== "" && process.env.CI !== "false") {
|
|
5597
|
+
console.error("`login` is interactive and cannot run in CI. Set PATCHSTACK_PULSE_AUTH instead.");
|
|
5598
|
+
return 1;
|
|
5599
|
+
}
|
|
5600
|
+
const config = await resolveConfig({
|
|
5601
|
+
cwd: process.cwd(),
|
|
5602
|
+
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
5603
|
+
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
5604
|
+
});
|
|
5605
|
+
const result = await login(config, (userCode, verificationUri) => {
|
|
5606
|
+
console.log(`
|
|
5607
|
+
Your code: ${userCode}`);
|
|
5608
|
+
console.log(` Approve at: ${verificationUri}
|
|
5609
|
+
`);
|
|
5610
|
+
console.log(" Waiting for approval\u2026");
|
|
5611
|
+
});
|
|
5612
|
+
if (result.status === "approved") {
|
|
5613
|
+
console.log("\n \u2713 Credential restored and saved to .patchstackrc.json.\n");
|
|
5614
|
+
return 0;
|
|
5615
|
+
}
|
|
5616
|
+
console.error(`
|
|
5617
|
+
${result.message ?? "Login failed."}
|
|
5618
|
+
`);
|
|
5619
|
+
return 1;
|
|
5620
|
+
}
|
|
5621
|
+
async function runMap(args) {
|
|
5622
|
+
const cwd = getStringFlag(args.flags, "dir") ?? process.cwd();
|
|
5623
|
+
const { map, error } = await buildInputMap(cwd, {
|
|
5624
|
+
followSymlinks: args.flags.get("follow-symlinks") === true
|
|
5625
|
+
});
|
|
5626
|
+
if (!map) {
|
|
5627
|
+
console.error(`patchstack: ${error}`);
|
|
5628
|
+
return 1;
|
|
5629
|
+
}
|
|
5630
|
+
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
|
|
5631
|
+
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
|
|
5632
|
+
const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0);
|
|
5633
|
+
const c = map.coverage;
|
|
5634
|
+
console.error(
|
|
5635
|
+
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ${proven} proven input\u2192sink flow(s) [${map.framework}].`
|
|
5636
|
+
);
|
|
5637
|
+
console.error(
|
|
5638
|
+
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
|
|
5639
|
+
// simply contain no server entry point (most of a project is client code). Only `skipped` is a
|
|
5640
|
+
// failure to analyse.
|
|
5641
|
+
`patchstack: ${c.filesDiscovered} file(s) found \u2014 ${c.filesParsed} analysed, ${c.filesPreFiltered} skipped (no server entry point)` + (c.filesSkipped ? `, ${c.filesSkipped} could not be analysed` : "") + `. DETECTED surface only \u2014 static analysis is best-effort; every flow carries the tier it was established at ("exact-local" and "transformed-local" are proven; "imported", "heuristic" and "unknown" are not).`
|
|
5642
|
+
);
|
|
5643
|
+
const invoked = map.apiInvocations ?? [];
|
|
5644
|
+
if (invoked.length > 0) {
|
|
5645
|
+
const c2 = map.coverage;
|
|
5646
|
+
const dependency = c2.callsDependency ?? 0;
|
|
5647
|
+
const ambiguous = c2.callsAmbiguous ?? 0;
|
|
5648
|
+
const denominator = dependency + ambiguous;
|
|
5649
|
+
const quality = denominator > 0 ? Math.round(100 * dependency / denominator) : 100;
|
|
5650
|
+
console.error(
|
|
5651
|
+
`patchstack: ${invoked.length} dependency API call(s) resolved across ${new Set(invoked.map((i) => i.package)).size} package(s) from ${c2.callsTotal ?? 0} call site(s) \u2014 ${quality}% of dependency-candidate receivers resolved (${c2.callsLocal ?? 0} local, ${ambiguous} ambiguous). Positive evidence only: absence here never means an API is not called.`
|
|
5652
|
+
);
|
|
5653
|
+
}
|
|
5654
|
+
const imported = map.imports ?? [];
|
|
5655
|
+
if (imported.length > 0) {
|
|
5656
|
+
const unmodelled = imported.filter((d) => d.recognizedSinkKinds.length === 0).length;
|
|
5657
|
+
console.error(
|
|
5658
|
+
`patchstack: ${imported.length} package(s) imported \u2014 ${unmodelled} with no recognized sink family, so a vulnerability in those cannot be judged reachable or unreachable from this map.`
|
|
5659
|
+
);
|
|
5660
|
+
}
|
|
5661
|
+
const json = JSON.stringify(map, null, 2);
|
|
5662
|
+
const out = getStringFlag(args.flags, "out");
|
|
5663
|
+
if (out) {
|
|
5664
|
+
writeFileSync7(out, json);
|
|
5665
|
+
console.error(`patchstack: wrote ${out}`);
|
|
5666
|
+
} else if (args.flags.get("upload") !== true) {
|
|
5667
|
+
console.log(json);
|
|
5668
|
+
}
|
|
5669
|
+
if (args.flags.get("upload") === true) {
|
|
5670
|
+
if (map.endpoints.length === 0) {
|
|
5671
|
+
console.error("patchstack: nothing to upload \u2014 no server entry points were detected.");
|
|
5672
|
+
return 0;
|
|
5673
|
+
}
|
|
5674
|
+
const config = await resolveConfig({
|
|
5675
|
+
cwd,
|
|
5676
|
+
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
5677
|
+
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
5678
|
+
});
|
|
5679
|
+
const outcome = await postInputMap(config, map);
|
|
5680
|
+
if (outcome.result === "stored") {
|
|
5681
|
+
console.error(`patchstack: uploaded the attack surface (revision ${outcome.revision}).`);
|
|
5682
|
+
} else if (outcome.result === "unchanged") {
|
|
5683
|
+
console.error(`patchstack: attack surface unchanged since revision ${outcome.revision} \u2014 nothing to store.`);
|
|
5684
|
+
} else if (outcome.result === "skipped") {
|
|
5685
|
+
console.error(`patchstack: did not upload the attack surface \u2014 ${outcome.message}`);
|
|
5686
|
+
} else {
|
|
5687
|
+
console.error(`patchstack: could not upload the attack surface \u2014 ${outcome.message}`);
|
|
5688
|
+
}
|
|
5689
|
+
}
|
|
5690
|
+
return 0;
|
|
5691
|
+
}
|
|
3008
5692
|
async function runScan(args, options = {}) {
|
|
3009
5693
|
const dryRun = args.flags.get("dry-run") === true;
|
|
3010
5694
|
const config = await resolveConfig({
|
|
@@ -3061,7 +5745,8 @@ async function runScan(args, options = {}) {
|
|
|
3061
5745
|
}
|
|
3062
5746
|
if (typeof response.api_key === "string" && response.api_key.length > 0) {
|
|
3063
5747
|
const target = await persistApiKey(process.cwd(), response.api_key);
|
|
3064
|
-
|
|
5748
|
+
await persistPulseAuth(process.cwd(), response.api_key);
|
|
5749
|
+
console.log(`Saved API key to ${target} (authenticates Pulse ingest and block-log reporting; keep out of the public widget).`);
|
|
3065
5750
|
}
|
|
3066
5751
|
if (response.stored) {
|
|
3067
5752
|
console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`);
|
|
@@ -3263,7 +5948,7 @@ async function runGuide(args) {
|
|
|
3263
5948
|
console.log("\u2014\u2014\u2014\u2014 Full reference guide (ships as AGENT-INSTALL.md) \u2014\u2014\u2014\u2014");
|
|
3264
5949
|
console.log("");
|
|
3265
5950
|
const guidePath = new URL("../AGENT-INSTALL.md", import.meta.url);
|
|
3266
|
-
console.log(
|
|
5951
|
+
console.log(readFileSync12(guidePath, "utf8"));
|
|
3267
5952
|
return 0;
|
|
3268
5953
|
}
|
|
3269
5954
|
async function runSetup(args) {
|
|
@@ -3354,6 +6039,40 @@ async function runStatus(args) {
|
|
|
3354
6039
|
}
|
|
3355
6040
|
return 0;
|
|
3356
6041
|
}
|
|
6042
|
+
async function runUninstall(args) {
|
|
6043
|
+
const config = await resolveConfig({
|
|
6044
|
+
cwd: process.cwd(),
|
|
6045
|
+
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
6046
|
+
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
6047
|
+
});
|
|
6048
|
+
if (config.siteUuid === null) {
|
|
6049
|
+
console.log("No site UUID configured \u2014 there is no site record to signal about.");
|
|
6050
|
+
console.log('Continue with the local removal steps in AGENT-INSTALL.md ("Uninstalling").');
|
|
6051
|
+
return 0;
|
|
6052
|
+
}
|
|
6053
|
+
console.log(`Signalling Patchstack that @patchstack/connect is being removed (site ${config.siteUuid})\u2026`);
|
|
6054
|
+
const outcome = await postPackageRemoved(config);
|
|
6055
|
+
switch (outcome.result) {
|
|
6056
|
+
case "deleted":
|
|
6057
|
+
console.log("Site record removed from Patchstack (the site was unclaimed).");
|
|
6058
|
+
break;
|
|
6059
|
+
case "flagged":
|
|
6060
|
+
console.log("This site is claimed by a Patchstack account, so its record was kept and flagged.");
|
|
6061
|
+
console.log("Its owner can remove it at https://app.patchstack.com to free the site slot.");
|
|
6062
|
+
break;
|
|
6063
|
+
case "gone":
|
|
6064
|
+
console.log("The site record no longer exists on Patchstack \u2014 nothing to signal.");
|
|
6065
|
+
break;
|
|
6066
|
+
case "failed":
|
|
6067
|
+
console.warn(`Could not signal Patchstack${outcome.message !== null ? ` (${outcome.message})` : ""}.`);
|
|
6068
|
+
console.warn("The site record may remain \u2014 it can always be removed from the dashboard at https://app.patchstack.com.");
|
|
6069
|
+
break;
|
|
6070
|
+
}
|
|
6071
|
+
console.log("");
|
|
6072
|
+
console.log("This command only signals Patchstack. The local integration files must still be");
|
|
6073
|
+
console.log('removed \u2014 follow the "Uninstalling" steps in AGENT-INSTALL.md.');
|
|
6074
|
+
return 0;
|
|
6075
|
+
}
|
|
3357
6076
|
function describeStack(stack) {
|
|
3358
6077
|
const parts = [stack.builder, stack.framework, stack.ui, stack.runtime].filter(
|
|
3359
6078
|
(part) => part !== null
|
|
@@ -3410,7 +6129,7 @@ async function runMarkBuild(args) {
|
|
|
3410
6129
|
let marked = 0;
|
|
3411
6130
|
let widgetTouched = 0;
|
|
3412
6131
|
for (const file of files) {
|
|
3413
|
-
const before =
|
|
6132
|
+
const before = readFileSync12(file, "utf8");
|
|
3414
6133
|
let after = injectMarker(before, snippet);
|
|
3415
6134
|
if (widgetUuid !== null) {
|
|
3416
6135
|
const ensured = ensureWidgetInHtml(after, widgetUuid);
|
|
@@ -3443,6 +6162,8 @@ async function main() {
|
|
|
3443
6162
|
return runScan(args);
|
|
3444
6163
|
case "status":
|
|
3445
6164
|
return runStatus(args);
|
|
6165
|
+
case "uninstall":
|
|
6166
|
+
return runUninstall(args);
|
|
3446
6167
|
case "mark-build":
|
|
3447
6168
|
return runMarkBuild(args);
|
|
3448
6169
|
case "protect":
|
|
@@ -3455,6 +6176,10 @@ async function main() {
|
|
|
3455
6176
|
return runGuide(args);
|
|
3456
6177
|
case "setup":
|
|
3457
6178
|
return runSetup(args);
|
|
6179
|
+
case "map":
|
|
6180
|
+
return runMap(args);
|
|
6181
|
+
case "login":
|
|
6182
|
+
return runLogin(args);
|
|
3458
6183
|
default:
|
|
3459
6184
|
console.error(`Unknown command: ${args.command}
|
|
3460
6185
|
`);
|