@patchstack/connect 0.3.30 → 0.3.31
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 +5 -2
- package/README.md +16 -1
- package/dist/cli.js +214 -109
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +48 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +48 -1
- package/dist/index.d.ts +48 -1
- package/dist/index.js +48 -18
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +176 -28
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.edge.js +129 -11
- package/dist/protect.edge.js.map +2 -2
- package/dist/protect.js +130 -12
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZSP76JWQ.js → refresh-manifest-KNZQBC4V.js} +48 -18
- package/dist/refresh-manifest-KNZQBC4V.js.map +1 -0
- package/package.json +1 -1
- package/dist/refresh-manifest-ZSP76JWQ.js.map +0 -1
package/AGENT-INSTALL.md
CHANGED
|
@@ -21,12 +21,15 @@ Every command at a glance — what it does, whether it reads your source, what i
|
|
|
21
21
|
| `login` | Recover a lost credential for an existing site: print an owner-approval link and poll (10 min). Approving **rotates** the credential. Not usable in CI. | No | New credential into `.patchstackrc.local.json` on approval | Device-code request + approval poll |
|
|
22
22
|
| `uninstall` | Signal Patchstack that the package is being removed: an unclaimed record is deleted, a claimed one is flagged. Does **not** touch local files. | No | Nothing local | Removal signal |
|
|
23
23
|
|
|
24
|
-
Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` transmits nothing but package names + versions — never source code, env var values, file paths, or git history.
|
|
24
|
+
Only `map` reads your source, and only `map --upload` sends anything derived from it. `scan` transmits nothing but package names + versions — never source code, env var values, file paths, or git history. `scan --install-paths` additionally sends where each package sits in the dependency tree; it is off unless you pass it.
|
|
25
25
|
|
|
26
26
|
## Package and command behavior
|
|
27
27
|
|
|
28
28
|
- Package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. `npm view @patchstack/connect` shows the live registry metadata.
|
|
29
|
-
- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted.
|
|
29
|
+
- **What is sent to Patchstack is the dependency list only** — read from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — package names + versions, for vulnerability matching. No source code, no env var values, no file paths, no git history is ever transmitted.
|
|
30
|
+
- **`scan --install-paths` is the one exception, and it is opt-in.** It adds where each package sits in the dependency tree — repo-relative paths made of `node_modules` segments, plus a workspace directory name when a workspace pins its own copy. They are read from the lockfile's own keys or from the `node_modules` walk, **never from your source tree**: no path to a file you wrote is sent by either form of `scan`.
|
|
31
|
+
- Why it exists: the same package is routinely installed twice at different versions, and without the locations an advisory affecting only one of them cannot be matched to the copy your code actually loads. Node resolves an import by walking up from the importing file, so the location is what distinguishes "you are running the vulnerable copy" from "the vulnerable copy is installed but nothing reaches it". Absent them, every installed version has to be treated as if the app used it — warnings about code you never call, and protection rules pinned to routes that run the safe copy.
|
|
32
|
+
- Why it is off by default: it widens what leaves the machine, so it is your explicit choice and not a consequence of upgrading the package. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.)
|
|
30
33
|
- **One command reads source files:** `map` (see below) parses your server source to report your app's attack surface. It runs only when you invoke it and prints to stdout. It transmits nothing unless you explicitly pass `--upload`, which sends that description of your app's structure to your own site's Patchstack endpoint — never source code, and never without that flag. No other command reads source (`protect` writes guard files but does not analyze your code).
|
|
31
34
|
- **`scan` makes up to two source edits, both in the project's root shell:** the disclosure widget's `<script>` tag, and the production marker. Neither runs on `--dry-run`, both are idempotent, both leave a pre-existing manual install untouched, and `"widget": false` in `.patchstackrc.json` disables both.
|
|
32
35
|
- The **widget tag** goes in the root HTML shell — the first of `index.html`, `public/index.html`, or `src/app.html` that exists — and only after a successful post, because it carries the site UUID.
|
package/README.md
CHANGED
|
@@ -216,6 +216,7 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
|
|
|
216
216
|
```json
|
|
217
217
|
{
|
|
218
218
|
"ecosystem": "npm",
|
|
219
|
+
"installPathsComplete": false,
|
|
219
220
|
"packages": [
|
|
220
221
|
{ "name": "axios", "version": "1.6.0" },
|
|
221
222
|
{ "name": "lodash", "version": "4.17.15" },
|
|
@@ -224,7 +225,21 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM
|
|
|
224
225
|
}
|
|
225
226
|
```
|
|
226
227
|
|
|
227
|
-
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile.
|
|
228
|
+
That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile.
|
|
229
|
+
|
|
230
|
+
### `scan --install-paths` (opt-in)
|
|
231
|
+
|
|
232
|
+
Pass it and each entry also carries where that version is installed:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{ "name": "lodash", "version": "4.17.15", "paths": ["apps/api/node_modules/lodash"] }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Why you might want it: the two `lodash` entries above are not a contrived example — the same package is routinely installed twice at different versions. An advisory affecting only `4.17.15` cannot otherwise be matched to the copy your code actually loads. Node resolves an import by walking up from the importing file, so the location is what separates "you are running the vulnerable copy" from "the vulnerable copy is installed but nothing reaches it". Without it every installed version has to be treated as if the app used it — warnings about code you never call, and protection rules pinned to routes that run the safe copy.
|
|
239
|
+
|
|
240
|
+
These are repo-relative locations built from `node_modules` segments, plus a workspace directory name when a workspace pins its own copy. They come from the lockfile's own keys or from the `node_modules` walk — **never from your source tree**. No path to a file you wrote is sent by either form of `scan`.
|
|
241
|
+
|
|
242
|
+
`installPathsComplete` says whether the set is total. It is `false` without the flag, and `false` with it whenever the source cannot supply locations — a `yarn.lock` is flat because hoisting is decided at install time, and a v1 `package-lock.json` records the dependency graph rather than the installed tree. Whenever it is `false`, a missing `paths` means **"not recorded"**, never "not installed there". (The `map` command reads source files locally to report your attack surface; it transmits nothing unless you pass `--upload`, which sends that structural description — route paths, parameter names, the dependency behind each sink, and file/line locations, never file contents — to your own site's endpoint so rules can be pinned to your real parameter names.) Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.)
|
|
228
243
|
|
|
229
244
|
## Supported lockfiles
|
|
230
245
|
|
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 readFileSync14, writeFileSync as
|
|
4
|
+
import { readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
|
|
5
5
|
|
|
6
6
|
// src/parsers/index.ts
|
|
7
7
|
import { access } from "fs/promises";
|
|
@@ -330,7 +330,7 @@ async function walkNodeModules(cwd) {
|
|
|
330
330
|
seen.set(await realpath(cwd), -1);
|
|
331
331
|
} catch {
|
|
332
332
|
}
|
|
333
|
-
return dedupeByShallowest(await walk(root, 0, seen));
|
|
333
|
+
return dedupeByShallowest(await walk(root, 0, seen, cwd));
|
|
334
334
|
}
|
|
335
335
|
function dedupeByShallowest(found) {
|
|
336
336
|
const best = /* @__PURE__ */ new Map();
|
|
@@ -341,7 +341,7 @@ function dedupeByShallowest(found) {
|
|
|
341
341
|
return [...best.values()].map(({ entry }) => entry);
|
|
342
342
|
}
|
|
343
343
|
var CONCURRENCY = 32;
|
|
344
|
-
async function walk(dir, depth, seen) {
|
|
344
|
+
async function walk(dir, depth, seen, root) {
|
|
345
345
|
let names;
|
|
346
346
|
try {
|
|
347
347
|
names = await readdir(dir);
|
|
@@ -353,7 +353,7 @@ async function walk(dir, depth, seen) {
|
|
|
353
353
|
const fullPath = path3.join(dir, name);
|
|
354
354
|
const resolved = await claim(fullPath, seen, depth);
|
|
355
355
|
if (resolved === null) return [];
|
|
356
|
-
if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen);
|
|
356
|
+
if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen, root);
|
|
357
357
|
if (name.startsWith("@")) {
|
|
358
358
|
let subNames;
|
|
359
359
|
try {
|
|
@@ -366,16 +366,16 @@ async function walk(dir, depth, seen) {
|
|
|
366
366
|
const scopedDir = path3.join(fullPath, sub);
|
|
367
367
|
const scopedReal = await claim(scopedDir, seen, depth);
|
|
368
368
|
if (scopedReal === null) return [];
|
|
369
|
-
return packageAndNested(scopedDir, scopedReal, depth, seen);
|
|
369
|
+
return packageAndNested(scopedDir, scopedReal, depth, seen, root);
|
|
370
370
|
});
|
|
371
371
|
return scoped.flat();
|
|
372
372
|
}
|
|
373
|
-
return packageAndNested(fullPath, resolved, depth, seen);
|
|
373
|
+
return packageAndNested(fullPath, resolved, depth, seen, root);
|
|
374
374
|
});
|
|
375
375
|
return perEntry.flat();
|
|
376
376
|
}
|
|
377
|
-
async function packageAndNested(pkgDir, resolved, depth, seen) {
|
|
378
|
-
const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
|
|
377
|
+
async function packageAndNested(pkgDir, resolved, depth, seen, root) {
|
|
378
|
+
const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
|
|
379
379
|
return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
|
|
380
380
|
}
|
|
381
381
|
async function mapLimit(items, fn) {
|
|
@@ -390,7 +390,10 @@ async function mapLimit(items, fn) {
|
|
|
390
390
|
await Promise.all(workers);
|
|
391
391
|
return results;
|
|
392
392
|
}
|
|
393
|
-
|
|
393
|
+
function installLocation(root, pkgDir, impl = path3) {
|
|
394
|
+
return impl.relative(root, pkgDir).split(impl.sep).join("/");
|
|
395
|
+
}
|
|
396
|
+
async function readPackage(pkgDir, depth, root) {
|
|
394
397
|
let raw;
|
|
395
398
|
try {
|
|
396
399
|
raw = await readFile4(path3.join(pkgDir, "package.json"), "utf8");
|
|
@@ -409,14 +412,14 @@ async function readPackage(pkgDir, depth) {
|
|
|
409
412
|
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
410
413
|
return null;
|
|
411
414
|
}
|
|
412
|
-
return { name: parsed.name, version: parsed.version, direct: depth === 0 };
|
|
415
|
+
return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
|
|
413
416
|
}
|
|
414
|
-
async function walkNested(pkgDir, depth, seen) {
|
|
417
|
+
async function walkNested(pkgDir, depth, seen, root) {
|
|
415
418
|
const nested = path3.join(pkgDir, "node_modules");
|
|
416
419
|
if (await claim(nested, seen, depth + 1) === null) {
|
|
417
420
|
return [];
|
|
418
421
|
}
|
|
419
|
-
return walk(nested, depth + 1, seen);
|
|
422
|
+
return walk(nested, depth + 1, seen, root);
|
|
420
423
|
}
|
|
421
424
|
async function claim(dir, seen, depth) {
|
|
422
425
|
let resolved;
|
|
@@ -1059,21 +1062,33 @@ async function exists(filePath) {
|
|
|
1059
1062
|
}
|
|
1060
1063
|
|
|
1061
1064
|
// src/normalize.ts
|
|
1062
|
-
function buildWirePayload(manifest) {
|
|
1065
|
+
function buildWirePayload(manifest, options = {}) {
|
|
1066
|
+
const withPaths = options.installPaths === true;
|
|
1063
1067
|
const seen = /* @__PURE__ */ new Map();
|
|
1064
1068
|
const wirePackages = [];
|
|
1069
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
1070
|
+
let entriesWithoutPath = 0;
|
|
1065
1071
|
for (const entry of manifest.packages) {
|
|
1072
|
+
const identity = `${entry.name}@${entry.version}`;
|
|
1073
|
+
const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
|
|
1074
|
+
if (location === void 0) entriesWithoutPath++;
|
|
1075
|
+
const existing = byIdentity.get(identity);
|
|
1076
|
+
if (existing) {
|
|
1077
|
+
if (location !== void 0) addPath(existing, location);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1066
1080
|
const versions = seen.get(entry.name);
|
|
1067
1081
|
if (versions) {
|
|
1068
|
-
if (versions.has(entry.version)) {
|
|
1069
|
-
continue;
|
|
1070
|
-
}
|
|
1071
1082
|
versions.add(entry.version);
|
|
1072
1083
|
} else {
|
|
1073
1084
|
seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
|
|
1074
1085
|
}
|
|
1075
|
-
|
|
1086
|
+
const wire6 = { name: entry.name, version: entry.version };
|
|
1087
|
+
if (location !== void 0) addPath(wire6, location);
|
|
1088
|
+
byIdentity.set(identity, wire6);
|
|
1089
|
+
wirePackages.push(wire6);
|
|
1076
1090
|
}
|
|
1091
|
+
for (const wire6 of wirePackages) wire6.paths?.sort();
|
|
1077
1092
|
wirePackages.sort((a, b) => {
|
|
1078
1093
|
if (a.name === b.name) {
|
|
1079
1094
|
return compareVersions(a.version, b.version);
|
|
@@ -1087,7 +1102,18 @@ function buildWirePayload(manifest) {
|
|
|
1087
1102
|
}
|
|
1088
1103
|
}
|
|
1089
1104
|
return {
|
|
1090
|
-
payload: {
|
|
1105
|
+
payload: {
|
|
1106
|
+
ecosystem: manifest.ecosystem,
|
|
1107
|
+
packages: wirePackages,
|
|
1108
|
+
// Every entry had a location, so a missing path anywhere means the package is not installed there.
|
|
1109
|
+
// One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
|
|
1110
|
+
// no way to tell which entry was the incomplete one.
|
|
1111
|
+
//
|
|
1112
|
+
// No separate check for "locations were not requested": with them off every entry counts as without
|
|
1113
|
+
// one, so this is already false — which is the default, and the safe direction. An explicit
|
|
1114
|
+
// `withPaths &&` here was a guard no test could distinguish from its absence.
|
|
1115
|
+
installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
|
|
1116
|
+
},
|
|
1091
1117
|
stats: {
|
|
1092
1118
|
uniqueNames: seen.size,
|
|
1093
1119
|
duplicateNames,
|
|
@@ -1095,6 +1121,10 @@ function buildWirePayload(manifest) {
|
|
|
1095
1121
|
}
|
|
1096
1122
|
};
|
|
1097
1123
|
}
|
|
1124
|
+
function addPath(wire6, installPath) {
|
|
1125
|
+
if (wire6.paths === void 0) wire6.paths = [installPath];
|
|
1126
|
+
else if (!wire6.paths.includes(installPath)) wire6.paths.push(installPath);
|
|
1127
|
+
}
|
|
1098
1128
|
function compareVersions(a, b) {
|
|
1099
1129
|
if (a === b) {
|
|
1100
1130
|
return 0;
|
|
@@ -4078,6 +4108,9 @@ async function login(config, onPrompt, deps = {}) {
|
|
|
4078
4108
|
return waitForApproval(config, started.pending, deps);
|
|
4079
4109
|
}
|
|
4080
4110
|
|
|
4111
|
+
// src/map-command.ts
|
|
4112
|
+
import { writeFileSync as writeFileSync8 } from "fs";
|
|
4113
|
+
|
|
4081
4114
|
// src/map/ts-loader.ts
|
|
4082
4115
|
import { createRequire } from "module";
|
|
4083
4116
|
import { pathToFileURL } from "url";
|
|
@@ -4485,7 +4518,30 @@ var SKIP_DIRS2 = /* @__PURE__ */ new Set([
|
|
|
4485
4518
|
"temp",
|
|
4486
4519
|
"__pycache__"
|
|
4487
4520
|
]);
|
|
4488
|
-
|
|
4521
|
+
var SKIP_IS_DEFINITIONAL = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
4522
|
+
var PROBE_BUDGET = 4096;
|
|
4523
|
+
function probeForSource(dir, budget) {
|
|
4524
|
+
let entries;
|
|
4525
|
+
try {
|
|
4526
|
+
entries = readdirSync5(dir, { withFileTypes: true });
|
|
4527
|
+
} catch {
|
|
4528
|
+
return "unknown";
|
|
4529
|
+
}
|
|
4530
|
+
const dirs = [];
|
|
4531
|
+
for (const e of entries) {
|
|
4532
|
+
if (budget.left-- <= 0) return "unknown";
|
|
4533
|
+
if (e.isDirectory()) {
|
|
4534
|
+
if (!SKIP_IS_DEFINITIONAL.has(e.name)) dirs.push(join13(dir, e.name));
|
|
4535
|
+
} else if (isSourceFile(e.name)) return true;
|
|
4536
|
+
else if (e.isSymbolicLink()) return "unknown";
|
|
4537
|
+
}
|
|
4538
|
+
for (const sub of dirs) {
|
|
4539
|
+
const found = probeForSource(sub, budget);
|
|
4540
|
+
if (found !== false) return found;
|
|
4541
|
+
}
|
|
4542
|
+
return false;
|
|
4543
|
+
}
|
|
4544
|
+
function collectSources(dir, boundary, opts, out = [], seen = /* @__PURE__ */ new Set(), stats = { discovered: 0, unwalked: 0, skippedWithSource: [], skippedUnknown: 0 }, probeBudget = { left: PROBE_BUDGET }) {
|
|
4489
4545
|
let key;
|
|
4490
4546
|
try {
|
|
4491
4547
|
key = realpathSync(dir);
|
|
@@ -4508,9 +4564,18 @@ function collectSources(dir, boundary, opts, out = [], seen = /* @__PURE__ */ ne
|
|
|
4508
4564
|
}
|
|
4509
4565
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
4510
4566
|
for (const e of entries) {
|
|
4511
|
-
if (SKIP_DIRS2.has(e.name) || e.name.startsWith(".") && e.name !== ".")
|
|
4567
|
+
if (SKIP_DIRS2.has(e.name) || e.name.startsWith(".") && e.name !== ".") {
|
|
4568
|
+
const probeable = !SKIP_IS_DEFINITIONAL.has(e.name);
|
|
4569
|
+
if (probeable && !e.isDirectory() && e.isSymbolicLink()) stats.skippedUnknown++;
|
|
4570
|
+
else if (probeable && e.isDirectory()) {
|
|
4571
|
+
const held = probeForSource(join13(dir, e.name), probeBudget);
|
|
4572
|
+
if (held === true) stats.skippedWithSource.push(join13(dir, e.name));
|
|
4573
|
+
else if (held === "unknown") stats.skippedUnknown++;
|
|
4574
|
+
}
|
|
4575
|
+
continue;
|
|
4576
|
+
}
|
|
4512
4577
|
const full = join13(dir, e.name);
|
|
4513
|
-
if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats);
|
|
4578
|
+
if (e.isDirectory()) collectSources(full, boundary, opts, out, seen, stats, probeBudget);
|
|
4514
4579
|
else if (e.isSymbolicLink()) {
|
|
4515
4580
|
let st, real;
|
|
4516
4581
|
try {
|
|
@@ -4524,7 +4589,7 @@ function collectSources(dir, boundary, opts, out = [], seen = /* @__PURE__ */ ne
|
|
|
4524
4589
|
if (st.isDirectory() || isSourceFile(e.name)) stats.unwalked++;
|
|
4525
4590
|
continue;
|
|
4526
4591
|
}
|
|
4527
|
-
if (st.isDirectory()) collectSources(full, boundary, opts, out, seen, stats);
|
|
4592
|
+
if (st.isDirectory()) collectSources(full, boundary, opts, out, seen, stats, probeBudget);
|
|
4528
4593
|
else if (st.isFile() && isSourceFile(e.name)) {
|
|
4529
4594
|
out.push(full);
|
|
4530
4595
|
stats.discovered++;
|
|
@@ -5349,7 +5414,7 @@ function zodShape(node, ts) {
|
|
|
5349
5414
|
}
|
|
5350
5415
|
return shape;
|
|
5351
5416
|
}
|
|
5352
|
-
var REQ_SOURCES = ["body", "query", "params"];
|
|
5417
|
+
var REQ_SOURCES = ["body", "query", "params", "headers", "cookies", "files"];
|
|
5353
5418
|
function requestMemberAccesses(params, body, ts, opts = {}) {
|
|
5354
5419
|
if (!body) return [];
|
|
5355
5420
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -5386,6 +5451,12 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
|
|
|
5386
5451
|
if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression)) {
|
|
5387
5452
|
record(n.name.text, sourceOfExpr(n.expression));
|
|
5388
5453
|
}
|
|
5454
|
+
if (ts.isElementAccessExpression(n) && isReqSourceExpr(n.expression) && n.argumentExpression && ts.isStringLiteral(n.argumentExpression)) {
|
|
5455
|
+
record(n.argumentExpression.text, sourceOfExpr(n.expression));
|
|
5456
|
+
}
|
|
5457
|
+
if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === "get" && isReqSourceExpr(n.expression.expression) && n.arguments.length === 1 && n.arguments[0] && ts.isStringLiteral(n.arguments[0])) {
|
|
5458
|
+
record(n.arguments[0].text, sourceOfExpr(n.expression.expression));
|
|
5459
|
+
}
|
|
5389
5460
|
if (ts.isVariableDeclaration(n) && n.initializer) {
|
|
5390
5461
|
const init = unwrap(n.initializer);
|
|
5391
5462
|
if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer));
|
|
@@ -5410,9 +5481,8 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
|
|
|
5410
5481
|
function sourceOfExpr(e) {
|
|
5411
5482
|
if (isPayloadExpr(e)) return "server-fn-data";
|
|
5412
5483
|
if (ts.isPropertyAccessExpression(e)) {
|
|
5413
|
-
|
|
5414
|
-
if (e.name.text
|
|
5415
|
-
if (e.name.text === "body") return "body";
|
|
5484
|
+
const named = namespaceSource(e.name.text);
|
|
5485
|
+
if (REQ_SOURCES.includes(e.name.text)) return named;
|
|
5416
5486
|
}
|
|
5417
5487
|
if (ts.isIdentifier(e)) {
|
|
5418
5488
|
const recorded = sourceNames.get(e.text);
|
|
@@ -5423,6 +5493,9 @@ function requestMemberAccesses(params, body, ts, opts = {}) {
|
|
|
5423
5493
|
function namespaceSource(key) {
|
|
5424
5494
|
if (key === "query") return "query";
|
|
5425
5495
|
if (key === "params") return "route-param";
|
|
5496
|
+
if (key === "headers") return "header";
|
|
5497
|
+
if (key === "cookies") return "cookie";
|
|
5498
|
+
if (key === "files") return "file";
|
|
5426
5499
|
return "body";
|
|
5427
5500
|
}
|
|
5428
5501
|
function bodyReadSource(init) {
|
|
@@ -5437,6 +5510,9 @@ function spaceOfKey(key) {
|
|
|
5437
5510
|
if (key === "params") return "route-param";
|
|
5438
5511
|
if (key === "body") return "post";
|
|
5439
5512
|
if (key === "data" || key === "input" || key === "payload") return "post";
|
|
5513
|
+
if (key === "headers") return "server";
|
|
5514
|
+
if (key === "cookies") return "cookie";
|
|
5515
|
+
if (key === "files") return "files";
|
|
5440
5516
|
return void 0;
|
|
5441
5517
|
}
|
|
5442
5518
|
function linkedFlows(body, params, inputs, sinks, ts) {
|
|
@@ -6216,7 +6292,7 @@ async function extractInputMap(cwd, ts, options = {}) {
|
|
|
6216
6292
|
} catch {
|
|
6217
6293
|
}
|
|
6218
6294
|
const graph = createModuleGraph(ts, { cwd, boundary, followOutside: options.followSymlinks });
|
|
6219
|
-
const stats = { discovered: 0, unwalked: 0 };
|
|
6295
|
+
const stats = { discovered: 0, unwalked: 0, skippedWithSource: [], skippedUnknown: 0 };
|
|
6220
6296
|
const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], /* @__PURE__ */ new Set(), stats);
|
|
6221
6297
|
const imports = createImportInventory(readPathAliases(cwd));
|
|
6222
6298
|
const invocations = createInvocationInventory();
|
|
@@ -6321,19 +6397,31 @@ async function extractInputMap(cwd, ts, options = {}) {
|
|
|
6321
6397
|
if (endpoints.length === 0) notes.push("No recognized server-side entry points found under the analyzed roots.");
|
|
6322
6398
|
const importList = imports.list();
|
|
6323
6399
|
const unmodelled = importList.filter((d) => d.recognizedSinkKinds.length === 0).length;
|
|
6400
|
+
const skippedDirsWithSource = stats.skippedWithSource.map((d) => relative4(cwd, d));
|
|
6324
6401
|
const importCoverageGaps = {
|
|
6325
6402
|
unreadableFiles: failed.length,
|
|
6326
6403
|
unscannableFiles: importScanFailures,
|
|
6327
6404
|
unwalkedPaths: stats.unwalked,
|
|
6328
|
-
unresolvableImports
|
|
6405
|
+
unresolvableImports,
|
|
6406
|
+
// A directory skipped by NAME that turned out to hold source. It moved no other counter, so before
|
|
6407
|
+
// this an app whose server lived under `vendor/` was indistinguishable from one with no such
|
|
6408
|
+
// directory — and the inventory still claimed to be complete.
|
|
6409
|
+
skippedDirsWithSource,
|
|
6410
|
+
skippedDirsUnsettled: stats.skippedUnknown
|
|
6329
6411
|
};
|
|
6330
|
-
const environmentalGaps = failed.length + importScanFailures + stats.unwalked;
|
|
6412
|
+
const environmentalGaps = failed.length + importScanFailures + stats.unwalked + stats.skippedWithSource.length + stats.skippedUnknown;
|
|
6331
6413
|
const importsComplete = environmentalGaps === 0 && unresolvableImports === 0;
|
|
6332
6414
|
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.");
|
|
6333
6415
|
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.`);
|
|
6334
6416
|
if (environmentalGaps > 0) {
|
|
6335
6417
|
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.`);
|
|
6336
6418
|
}
|
|
6419
|
+
if (stats.skippedWithSource.length > 0) {
|
|
6420
|
+
notes.push(`${stats.skippedWithSource.length} director(ies) skipped by name hold source that was NOT analyzed: ${skippedDirsWithSource.slice(0, 10).join(", ")}${skippedDirsWithSource.length > 10 ? ", \u2026" : ""}. The walk excludes these names because they are usually generated or vendored, which is a guess about where app source lives \u2014 and here the guess was wrong. Code in them can import, and serve, anything. Point the scan at the directory, or move the app source out of it, before reading any package's absence as evidence.`);
|
|
6421
|
+
}
|
|
6422
|
+
if (stats.skippedUnknown > 0) {
|
|
6423
|
+
notes.push(`${stats.skippedUnknown} skipped director(ies) could not be settled either way \u2014 the probe for source hit its budget or the directory could not be read. Counted against completeness deliberately: "we stopped looking" is not the same answer as "nothing there".`);
|
|
6424
|
+
}
|
|
6337
6425
|
if (unresolvableImports > 0) {
|
|
6338
6426
|
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.`);
|
|
6339
6427
|
}
|
|
@@ -6416,8 +6504,88 @@ async function buildInputMap(cwd, options = {}) {
|
|
|
6416
6504
|
return { map };
|
|
6417
6505
|
}
|
|
6418
6506
|
|
|
6507
|
+
// src/flags.ts
|
|
6508
|
+
function getStringFlag(flags, name) {
|
|
6509
|
+
const value = flags.get(name);
|
|
6510
|
+
return typeof value === "string" ? value : void 0;
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
// src/map-command.ts
|
|
6514
|
+
async function runMap(flags) {
|
|
6515
|
+
const cwd = getStringFlag(flags, "dir") ?? process.cwd();
|
|
6516
|
+
const { map, error } = await buildInputMap(cwd, {
|
|
6517
|
+
followSymlinks: flags.get("follow-symlinks") === true
|
|
6518
|
+
});
|
|
6519
|
+
if (!map) {
|
|
6520
|
+
console.error(`patchstack: ${error}`);
|
|
6521
|
+
return 1;
|
|
6522
|
+
}
|
|
6523
|
+
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
|
|
6524
|
+
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
|
|
6525
|
+
const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0);
|
|
6526
|
+
const c = map.coverage;
|
|
6527
|
+
console.error(
|
|
6528
|
+
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ${proven} proven input\u2192sink flow(s) [${map.framework}].`
|
|
6529
|
+
);
|
|
6530
|
+
console.error(
|
|
6531
|
+
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
|
|
6532
|
+
// simply contain no server entry point (most of a project is client code). Only `skipped` is a
|
|
6533
|
+
// failure to analyse.
|
|
6534
|
+
`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).`
|
|
6535
|
+
);
|
|
6536
|
+
const invoked = map.apiInvocations ?? [];
|
|
6537
|
+
if (invoked.length > 0) {
|
|
6538
|
+
const c2 = map.coverage;
|
|
6539
|
+
const dependency = c2.callsDependency ?? 0;
|
|
6540
|
+
const ambiguous = c2.callsAmbiguous ?? 0;
|
|
6541
|
+
const denominator = dependency + ambiguous;
|
|
6542
|
+
const quality = denominator > 0 ? Math.round(100 * dependency / denominator) : 100;
|
|
6543
|
+
console.error(
|
|
6544
|
+
`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.`
|
|
6545
|
+
);
|
|
6546
|
+
}
|
|
6547
|
+
const imported = map.imports ?? [];
|
|
6548
|
+
if (imported.length > 0) {
|
|
6549
|
+
const unmodelled = imported.filter((d) => d.recognizedSinkKinds.length === 0).length;
|
|
6550
|
+
console.error(
|
|
6551
|
+
`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.`
|
|
6552
|
+
);
|
|
6553
|
+
}
|
|
6554
|
+
const json = JSON.stringify(map, null, 2);
|
|
6555
|
+
const out = getStringFlag(flags, "out");
|
|
6556
|
+
if (out) {
|
|
6557
|
+
writeFileSync8(out, json);
|
|
6558
|
+
console.error(`patchstack: wrote ${out}`);
|
|
6559
|
+
} else if (flags.get("upload") !== true) {
|
|
6560
|
+
console.log(json);
|
|
6561
|
+
}
|
|
6562
|
+
if (flags.get("upload") === true) {
|
|
6563
|
+
if (map.endpoints.length === 0) {
|
|
6564
|
+
console.error(
|
|
6565
|
+
"patchstack: no server entry points were recognized \u2014 uploading the import inventory and coverage notes anyway, so a vulnerability can still be judged imported or not. Nothing here can decide whether a request reaches it."
|
|
6566
|
+
);
|
|
6567
|
+
}
|
|
6568
|
+
const config = await resolveConfig({
|
|
6569
|
+
cwd,
|
|
6570
|
+
cliSiteUuid: getStringFlag(flags, "site-uuid"),
|
|
6571
|
+
cliEndpoint: getStringFlag(flags, "endpoint")
|
|
6572
|
+
});
|
|
6573
|
+
const outcome = await postInputMap(config, map);
|
|
6574
|
+
if (outcome.result === "stored") {
|
|
6575
|
+
console.error(`patchstack: uploaded the attack surface (revision ${outcome.revision}).`);
|
|
6576
|
+
} else if (outcome.result === "unchanged") {
|
|
6577
|
+
console.error(`patchstack: attack surface unchanged since revision ${outcome.revision} \u2014 nothing to store.`);
|
|
6578
|
+
} else if (outcome.result === "skipped") {
|
|
6579
|
+
console.error(`patchstack: did not upload the attack surface \u2014 ${outcome.message}`);
|
|
6580
|
+
} else {
|
|
6581
|
+
console.error(`patchstack: could not upload the attack surface \u2014 ${outcome.message}`);
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
return 0;
|
|
6585
|
+
}
|
|
6586
|
+
|
|
6419
6587
|
// src/setup.ts
|
|
6420
|
-
import { readFileSync as readFileSync13, writeFileSync as
|
|
6588
|
+
import { readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
|
|
6421
6589
|
import path11 from "path";
|
|
6422
6590
|
var SCAN_COMMAND = "patchstack-connect scan";
|
|
6423
6591
|
var MARK_BUILD_COMMAND = "patchstack-connect mark-build";
|
|
@@ -6486,7 +6654,7 @@ function wireBuildScripts(cwd, packageManager) {
|
|
|
6486
6654
|
const indentMatch = raw.match(/^[\t ]+(?=")/m)?.[0];
|
|
6487
6655
|
const indent = indentMatch?.includes(" ") ? " " : indentMatch?.length ?? 2;
|
|
6488
6656
|
const trailingNewline = raw.endsWith("\n") ? "\n" : "";
|
|
6489
|
-
|
|
6657
|
+
writeFileSync9(target, `${JSON.stringify(pkg, null, indent)}${trailingNewline}`, "utf8");
|
|
6490
6658
|
if (build === void 0 || build.trim().length === 0) {
|
|
6491
6659
|
return {
|
|
6492
6660
|
changed: true,
|
|
@@ -6587,6 +6755,11 @@ Options (for scan, setup, status, and uninstall):
|
|
|
6587
6755
|
--site-uuid <uuid> Override the configured site UUID
|
|
6588
6756
|
--endpoint <url> Override the API endpoint
|
|
6589
6757
|
--dry-run (scan only) Show the payload without posting
|
|
6758
|
+
--install-paths (scan only) Also send where each package is installed
|
|
6759
|
+
in the dependency tree (node_modules/lodash, and the
|
|
6760
|
+
same under a workspace that pins its own copy), so an
|
|
6761
|
+
advisory can be matched to the copy your code actually
|
|
6762
|
+
loads. Off by default; never source file paths
|
|
6590
6763
|
|
|
6591
6764
|
Options (for mark-build):
|
|
6592
6765
|
--dir <path> Build output directory (default: auto-detect
|
|
@@ -6651,10 +6824,6 @@ function parseArgs(argv) {
|
|
|
6651
6824
|
flags
|
|
6652
6825
|
};
|
|
6653
6826
|
}
|
|
6654
|
-
function getStringFlag(flags, name) {
|
|
6655
|
-
const value = flags.get(name);
|
|
6656
|
-
return typeof value === "string" ? value : void 0;
|
|
6657
|
-
}
|
|
6658
6827
|
async function runInit(args) {
|
|
6659
6828
|
const uuid = args.positional[0];
|
|
6660
6829
|
if (!uuid) {
|
|
@@ -6752,77 +6921,6 @@ async function runLogin(args) {
|
|
|
6752
6921
|
`);
|
|
6753
6922
|
return 1;
|
|
6754
6923
|
}
|
|
6755
|
-
async function runMap(args) {
|
|
6756
|
-
const cwd = getStringFlag(args.flags, "dir") ?? process.cwd();
|
|
6757
|
-
const { map, error } = await buildInputMap(cwd, {
|
|
6758
|
-
followSymlinks: args.flags.get("follow-symlinks") === true
|
|
6759
|
-
});
|
|
6760
|
-
if (!map) {
|
|
6761
|
-
console.error(`patchstack: ${error}`);
|
|
6762
|
-
return 1;
|
|
6763
|
-
}
|
|
6764
|
-
const inputs = map.endpoints.reduce((n, e) => n + e.inputs.length, 0);
|
|
6765
|
-
const sinks = map.endpoints.reduce((n, e) => n + e.sinks.length, 0);
|
|
6766
|
-
const proven = map.endpoints.reduce((n, e) => n + e.flows.filter((f) => isProvenFlow(f.confidence)).length, 0);
|
|
6767
|
-
const c = map.coverage;
|
|
6768
|
-
console.error(
|
|
6769
|
-
`patchstack: ${map.endpoints.length} entry point(s), ${inputs} input(s), ${sinks} sink(s), ${proven} proven input\u2192sink flow(s) [${map.framework}].`
|
|
6770
|
-
);
|
|
6771
|
-
console.error(
|
|
6772
|
-
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
|
|
6773
|
-
// simply contain no server entry point (most of a project is client code). Only `skipped` is a
|
|
6774
|
-
// failure to analyse.
|
|
6775
|
-
`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).`
|
|
6776
|
-
);
|
|
6777
|
-
const invoked = map.apiInvocations ?? [];
|
|
6778
|
-
if (invoked.length > 0) {
|
|
6779
|
-
const c2 = map.coverage;
|
|
6780
|
-
const dependency = c2.callsDependency ?? 0;
|
|
6781
|
-
const ambiguous = c2.callsAmbiguous ?? 0;
|
|
6782
|
-
const denominator = dependency + ambiguous;
|
|
6783
|
-
const quality = denominator > 0 ? Math.round(100 * dependency / denominator) : 100;
|
|
6784
|
-
console.error(
|
|
6785
|
-
`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.`
|
|
6786
|
-
);
|
|
6787
|
-
}
|
|
6788
|
-
const imported = map.imports ?? [];
|
|
6789
|
-
if (imported.length > 0) {
|
|
6790
|
-
const unmodelled = imported.filter((d) => d.recognizedSinkKinds.length === 0).length;
|
|
6791
|
-
console.error(
|
|
6792
|
-
`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.`
|
|
6793
|
-
);
|
|
6794
|
-
}
|
|
6795
|
-
const json = JSON.stringify(map, null, 2);
|
|
6796
|
-
const out = getStringFlag(args.flags, "out");
|
|
6797
|
-
if (out) {
|
|
6798
|
-
writeFileSync9(out, json);
|
|
6799
|
-
console.error(`patchstack: wrote ${out}`);
|
|
6800
|
-
} else if (args.flags.get("upload") !== true) {
|
|
6801
|
-
console.log(json);
|
|
6802
|
-
}
|
|
6803
|
-
if (args.flags.get("upload") === true) {
|
|
6804
|
-
if (map.endpoints.length === 0) {
|
|
6805
|
-
console.error("patchstack: nothing to upload \u2014 no server entry points were detected.");
|
|
6806
|
-
return 0;
|
|
6807
|
-
}
|
|
6808
|
-
const config = await resolveConfig({
|
|
6809
|
-
cwd,
|
|
6810
|
-
cliSiteUuid: getStringFlag(args.flags, "site-uuid"),
|
|
6811
|
-
cliEndpoint: getStringFlag(args.flags, "endpoint")
|
|
6812
|
-
});
|
|
6813
|
-
const outcome = await postInputMap(config, map);
|
|
6814
|
-
if (outcome.result === "stored") {
|
|
6815
|
-
console.error(`patchstack: uploaded the attack surface (revision ${outcome.revision}).`);
|
|
6816
|
-
} else if (outcome.result === "unchanged") {
|
|
6817
|
-
console.error(`patchstack: attack surface unchanged since revision ${outcome.revision} \u2014 nothing to store.`);
|
|
6818
|
-
} else if (outcome.result === "skipped") {
|
|
6819
|
-
console.error(`patchstack: did not upload the attack surface \u2014 ${outcome.message}`);
|
|
6820
|
-
} else {
|
|
6821
|
-
console.error(`patchstack: could not upload the attack surface \u2014 ${outcome.message}`);
|
|
6822
|
-
}
|
|
6823
|
-
}
|
|
6824
|
-
return 0;
|
|
6825
|
-
}
|
|
6826
6924
|
async function runScan(args, options = {}) {
|
|
6827
6925
|
const dryRun = args.flags.get("dry-run") === true;
|
|
6828
6926
|
const config = await resolveConfig({
|
|
@@ -6834,10 +6932,17 @@ async function runScan(args, options = {}) {
|
|
|
6834
6932
|
for (const warning of manifest.warnings ?? []) {
|
|
6835
6933
|
console.warn(`patchstack: ${warning}`);
|
|
6836
6934
|
}
|
|
6837
|
-
const
|
|
6935
|
+
const installPaths = args.flags.get("install-paths") === true;
|
|
6936
|
+
const { payload, stats } = buildWirePayload(manifest, { installPaths });
|
|
6838
6937
|
console.log(
|
|
6839
6938
|
`Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names (${manifest.ecosystem} ecosystem).`
|
|
6840
6939
|
);
|
|
6940
|
+
if (installPaths) {
|
|
6941
|
+
const located = payload.packages.filter((pkg) => pkg.paths !== void 0).length;
|
|
6942
|
+
console.log(
|
|
6943
|
+
payload.installPathsComplete ? `Including each package's install location in the dependency tree (--install-paths), for all ${located}.` : `Including install locations (--install-paths), for ${located} of ${payload.packages.length} \u2014 this lockfile format does not record them for the rest, which will be reported as "not recorded" rather than "not installed there".`
|
|
6944
|
+
);
|
|
6945
|
+
}
|
|
6841
6946
|
console.log(
|
|
6842
6947
|
`Reporting under the ${config.environment} environment (override with PATCHSTACK_ENVIRONMENT).`
|
|
6843
6948
|
);
|
|
@@ -7330,7 +7435,7 @@ async function runMarkBuild(args) {
|
|
|
7330
7435
|
after = ensured.html;
|
|
7331
7436
|
}
|
|
7332
7437
|
if (after !== before) {
|
|
7333
|
-
|
|
7438
|
+
writeFileSync10(file, after);
|
|
7334
7439
|
marked += 1;
|
|
7335
7440
|
}
|
|
7336
7441
|
}
|
|
@@ -7368,7 +7473,7 @@ async function main() {
|
|
|
7368
7473
|
case "setup":
|
|
7369
7474
|
return runSetup(args);
|
|
7370
7475
|
case "map":
|
|
7371
|
-
return runMap(args);
|
|
7476
|
+
return runMap(args.flags);
|
|
7372
7477
|
case "login":
|
|
7373
7478
|
return runLogin(args);
|
|
7374
7479
|
default:
|