nerdsystem 0.0.27 → 0.0.29
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/dist/commands/deploy.d.ts +18 -1
- package/dist/commands/deploy.js +44 -7
- package/dist/commands/init.js +1 -1
- package/dist/commands/session-review.d.ts +1 -1
- package/dist/commands/session-review.js +4 -1
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.js +15 -1
- package/dist/commands/upgrade.js +20 -3
- package/dist/gates/code.d.ts +8 -0
- package/dist/gates/code.js +52 -2
- package/dist/gates/design.js +1 -5
- package/dist/gates/fs.d.ts +2 -0
- package/dist/gates/fs.js +4 -0
- package/dist/gates/rules.js +1 -1
- package/dist/gates/static.js +1 -5
- package/dist/util.d.ts +16 -0
- package/dist/util.js +28 -3
- package/package.json +1 -1
|
@@ -16,7 +16,24 @@ export interface BuildOutput {
|
|
|
16
16
|
* never uploads it, and it is rewritten by every build, so the copy is removed here instead of being
|
|
17
17
|
* reported on every run with nothing for anyone to do about it. The file in the app's own folder stays.
|
|
18
18
|
*/
|
|
19
|
-
export declare function dropLocalSecretCopies(build: BuildOutput):
|
|
19
|
+
export declare function dropLocalSecretCopies(build: BuildOutput): number;
|
|
20
|
+
/**
|
|
21
|
+
* The build's own output names that copy twice ("Using secrets defined in .dev.vars", then the file among
|
|
22
|
+
* what it wrote), which reads as if local secrets were about to be uploaded. This sentence is printed in
|
|
23
|
+
* place of those lines, so the explanation is where the alarm would have been. True before and after the copy is deleted.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SECRET_COPY_NOTE = "The build tool makes a copy of your local secrets file (.dev.vars) in its output folder, for local previews. nerd deletes that copy when the build ends and never uploads it: the live app gets its secrets from `nerd secret set`.";
|
|
26
|
+
/**
|
|
27
|
+
* Every check of a deploy as one list, so its closing count is the one `nerd check` gives for the same
|
|
28
|
+
* code: the scan of the built output is in it, and a check that ran on the code and again on the built
|
|
29
|
+
* output (the policy's size limits) is one check, with the later answer.
|
|
30
|
+
*/
|
|
31
|
+
export declare function wholeRun(source: GateResult[], built: GateResult[]): GateResult[];
|
|
32
|
+
/** For the build's output: the lines that name the local secrets file become the one sentence above, said once. */
|
|
33
|
+
export declare function secretCopyLines(): {
|
|
34
|
+
line: (text: string) => string | null;
|
|
35
|
+
said: () => boolean;
|
|
36
|
+
};
|
|
20
37
|
export declare function readBuildOutput(root: string): BuildOutput;
|
|
21
38
|
export declare function applyRemoteMigrations(root: string, api: ApiClient): Promise<{
|
|
22
39
|
applied: string[];
|
package/dist/commands/deploy.js
CHANGED
|
@@ -32,10 +32,44 @@ function walk(dir, base = dir) {
|
|
|
32
32
|
*/
|
|
33
33
|
export function dropLocalSecretCopies(build) {
|
|
34
34
|
if (!existsSync(build.workerDir))
|
|
35
|
-
return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
return 0;
|
|
36
|
+
let dropped = 0;
|
|
37
|
+
for (const name of readdirSync(build.workerDir)) {
|
|
38
|
+
if (!ENV_FILES.test(name))
|
|
39
|
+
continue;
|
|
40
|
+
rmSync(path.join(build.workerDir, name), { force: true });
|
|
41
|
+
dropped++;
|
|
42
|
+
}
|
|
43
|
+
return dropped;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The build's own output names that copy twice ("Using secrets defined in .dev.vars", then the file among
|
|
47
|
+
* what it wrote), which reads as if local secrets were about to be uploaded. This sentence is printed in
|
|
48
|
+
* place of those lines, so the explanation is where the alarm would have been. True before and after the copy is deleted.
|
|
49
|
+
*/
|
|
50
|
+
export const SECRET_COPY_NOTE = "The build tool makes a copy of your local secrets file (.dev.vars) in its output folder, for local previews. nerd deletes that copy when the build ends and never uploads it: the live app gets its secrets from `nerd secret set`.";
|
|
51
|
+
/**
|
|
52
|
+
* Every check of a deploy as one list, so its closing count is the one `nerd check` gives for the same
|
|
53
|
+
* code: the scan of the built output is in it, and a check that ran on the code and again on the built
|
|
54
|
+
* output (the policy's size limits) is one check, with the later answer.
|
|
55
|
+
*/
|
|
56
|
+
export function wholeRun(source, built) {
|
|
57
|
+
return [...source.filter((r) => !built.some((b) => b.name === r.name)), ...built];
|
|
58
|
+
}
|
|
59
|
+
/** For the build's output: the lines that name the local secrets file become the one sentence above, said once. */
|
|
60
|
+
export function secretCopyLines() {
|
|
61
|
+
let said = false;
|
|
62
|
+
return {
|
|
63
|
+
line: (text) => {
|
|
64
|
+
if (!/\.dev\.vars\b/.test(text))
|
|
65
|
+
return text;
|
|
66
|
+
if (said)
|
|
67
|
+
return null;
|
|
68
|
+
said = true;
|
|
69
|
+
return SECRET_COPY_NOTE;
|
|
70
|
+
},
|
|
71
|
+
said: () => said,
|
|
72
|
+
};
|
|
39
73
|
}
|
|
40
74
|
export function readBuildOutput(root) {
|
|
41
75
|
const dist = path.join(root, "dist");
|
|
@@ -231,17 +265,20 @@ export async function deploy(opts) {
|
|
|
231
265
|
const gates = await runGates(SOURCE_GATES, { root, knownSecrets, approvedSuppressions: await approvedSuppressions(root), log: (l) => log.info(l) }, { stopOnFail: true });
|
|
232
266
|
if (!gates.ok)
|
|
233
267
|
throw new CliError(`Blocked by: ${gates.failed.map((g) => g.title).join(", ")}. Fix the findings above, then deploy again.`, 2);
|
|
234
|
-
|
|
268
|
+
const secretCopy = secretCopyLines();
|
|
235
269
|
if (!opts["skip-build"]) {
|
|
236
270
|
log.step("Building with vite");
|
|
237
271
|
ensureDeps(root);
|
|
238
|
-
await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true });
|
|
272
|
+
await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true, stdoutLine: secretCopy.line });
|
|
239
273
|
}
|
|
240
274
|
const build = readBuildOutput(root);
|
|
241
|
-
|
|
275
|
+
// Said here only when the build's output did not carry it (a build made earlier, or a tool that words it differently).
|
|
276
|
+
if (dropLocalSecretCopies(build) && !secretCopy.said())
|
|
277
|
+
log.info(SECRET_COPY_NOTE);
|
|
242
278
|
const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
|
|
243
279
|
if (!built.ok)
|
|
244
280
|
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
281
|
+
log.ok(gateSummary(wholeRun(gates.results, built.results)));
|
|
245
282
|
const r = await shipBuild({ root, nerd, api, build, results: [...gates.results, ...built.results], opts });
|
|
246
283
|
// The app is live at this point. The platform says what the review on file holds, and now and then reads the code itself.
|
|
247
284
|
const reviewPage = `${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/review`;
|
package/dist/commands/init.js
CHANGED
|
@@ -192,4 +192,4 @@ export async function init(dir, args) {
|
|
|
192
192
|
log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
|
|
193
193
|
}
|
|
194
194
|
// Kept in sync with packages/sdk/package.json by the release script.
|
|
195
|
-
const SDK_VERSION = "0.0.
|
|
195
|
+
const SDK_VERSION = "0.0.29";
|
|
@@ -20,7 +20,7 @@ export declare function reviewFiles(root: string): Hashes;
|
|
|
20
20
|
/** Records the code as it stands as reviewed. */
|
|
21
21
|
export declare function writeReviewed(root: string, files: Hashes): void;
|
|
22
22
|
export interface ReviewDue {
|
|
23
|
-
/** Whether code
|
|
23
|
+
/** Whether the app's own code under `src/` has changed since the last report from this folder (a stylesheet, the README, or package.json alone does not count). */
|
|
24
24
|
due: boolean;
|
|
25
25
|
/** No report has been made from this folder: everything is unreviewed as far as this computer knows. */
|
|
26
26
|
first: boolean;
|
|
@@ -64,7 +64,10 @@ export function reviewDue(root) {
|
|
|
64
64
|
return { due: Object.keys(now).length > 0, first: true, changed: Object.keys(now).sort() };
|
|
65
65
|
const changed = Object.keys(now).filter((p) => before[p] !== now[p]).sort();
|
|
66
66
|
const removed = Object.keys(before).filter((p) => !(p in now));
|
|
67
|
-
|
|
67
|
+
// What asks for a review is the app's own code. A stylesheet, the README, or package.json alone (which
|
|
68
|
+
// `nerd upgrade` itself rewrites when it moves the SDK's version) is read with the next review and does not ask for one.
|
|
69
|
+
const code = (p) => p.startsWith("src/") && !p.endsWith(".css");
|
|
70
|
+
return { due: [...changed, ...removed].some(code), first: false, changed };
|
|
68
71
|
}
|
|
69
72
|
/** What is open from earlier reviews, as rule ids and places only. Empty when the platform cannot be asked. */
|
|
70
73
|
async function openFindings(api, base) {
|
|
@@ -15,6 +15,12 @@ export interface Attention {
|
|
|
15
15
|
items: AttentionItem[];
|
|
16
16
|
/** When the review the findings come from was made, so a finding already fixed since is not a surprise. */
|
|
17
17
|
reviewedAt?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The app's own code has changed since the last review reported from this folder, so `nerd check` will
|
|
20
|
+
* ask for one before the next deploy. Not something to raise with the person, and never part of what a
|
|
21
|
+
* new session is shown: work in progress is the normal state of a folder.
|
|
22
|
+
*/
|
|
23
|
+
reviewDue?: boolean;
|
|
18
24
|
}
|
|
19
25
|
/** What is open from the latest review of this app (its agent's own, or the platform's read), mapped through this CLI's own catalog. */
|
|
20
26
|
export declare function reviewItems(api: ApiClient, base: string): Promise<{
|
|
@@ -32,6 +38,8 @@ export declare function reviewItems(api: ApiClient, base: string): Promise<{
|
|
|
32
38
|
* that any of it exists, let alone open a dashboard to find it.
|
|
33
39
|
*/
|
|
34
40
|
export declare function gatherAttention(root: string): Promise<Attention>;
|
|
41
|
+
/** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
|
|
42
|
+
export declare const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
|
|
35
43
|
/** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
|
|
36
44
|
export declare function attentionText(a: Attention): string;
|
|
37
45
|
/**
|
package/dist/commands/status.js
CHANGED
|
@@ -5,6 +5,7 @@ import { RULES } from "../gates/rules.js";
|
|
|
5
5
|
import { currentVersions, pinned, versionFindings } from "../gates/versions.js";
|
|
6
6
|
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
7
7
|
import { localDay, log } from "../util.js";
|
|
8
|
+
import { reviewDue } from "./session-review.js";
|
|
8
9
|
import { cliVersion } from "./source.js";
|
|
9
10
|
const within = (p, ms) => Promise.race([p.catch(() => null), new Promise((r) => setTimeout(() => r(null), ms))]);
|
|
10
11
|
const item = (rule_id, extra = {}) => {
|
|
@@ -56,11 +57,19 @@ export async function gatherAttention(root) {
|
|
|
56
57
|
items.push(it);
|
|
57
58
|
}
|
|
58
59
|
let reviewedAt;
|
|
60
|
+
let due = false;
|
|
59
61
|
try {
|
|
60
62
|
const nerd = readNerdJson(root);
|
|
61
63
|
app = `${nerd.org}/${nerd.project}`;
|
|
62
64
|
const api = ApiClient.fromConfig();
|
|
63
65
|
const base = projectPath(nerd);
|
|
66
|
+
try {
|
|
67
|
+
// The same question the `review` check asks, under the same conditions (linked and signed in), so the two agree.
|
|
68
|
+
due = reviewDue(root).due;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// a folder that cannot be listed has nothing to say here
|
|
72
|
+
}
|
|
64
73
|
const [review, project] = await Promise.all([reviewItems(api, base), within(api.get(base), 5000)]);
|
|
65
74
|
items.push(...review.items);
|
|
66
75
|
reviewedAt = review.reviewedAt;
|
|
@@ -78,8 +87,10 @@ export async function gatherAttention(root) {
|
|
|
78
87
|
catch {
|
|
79
88
|
// not linked, or not signed in: the versions above are all there is to say
|
|
80
89
|
}
|
|
81
|
-
return { app, items, ...(reviewedAt ? { reviewedAt } : {}) };
|
|
90
|
+
return { app, items, ...(reviewedAt ? { reviewedAt } : {}), ...(due ? { reviewDue: true } : {}) };
|
|
82
91
|
}
|
|
92
|
+
/** Said by `nerd status` when asked directly, so it does not answer "nothing" a moment before `nerd check` asks for a review. */
|
|
93
|
+
export const REVIEW_DUE_NOTE = "The app's code as it is now has not been reviewed from this folder. Nothing to raise with the person: `nerd check` will ask for the review (`nerd review checklist`) before the next deploy.";
|
|
83
94
|
/** Short enough to sit at the top of a session: what, where, and one line on what to do about each kind. */
|
|
84
95
|
export function attentionText(a) {
|
|
85
96
|
if (!a.items.length)
|
|
@@ -120,6 +131,9 @@ export async function status(opts) {
|
|
|
120
131
|
process.stdout.write(`${text}\n`);
|
|
121
132
|
else if (!opts.hook)
|
|
122
133
|
log.ok(`Nothing needs attention${a.app ? ` for ${a.app}` : ""}.`);
|
|
134
|
+
// A new session is not told: unreviewed work in progress is not news, and the check asks at the right moment.
|
|
135
|
+
if (!opts.hook && a.reviewDue)
|
|
136
|
+
process.stdout.write(`${text ? "\n" : ""}${REVIEW_DUE_NOTE}\n`);
|
|
123
137
|
}
|
|
124
138
|
/** `nerd review`: only what is open from the latest review, with every fix written out. */
|
|
125
139
|
export async function review(opts) {
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -43,6 +43,8 @@ export function upgradeProject(root, tpl, version) {
|
|
|
43
43
|
// A workspace or file link is someone developing the platform itself; leave it alone.
|
|
44
44
|
if (!current || !/^(workspace|file|link):/.test(current))
|
|
45
45
|
pkg.dependencies["@nerdsystem/sdk"] = `^${version}`;
|
|
46
|
+
// Whether moving the SDK's version is all that happens to package.json: nothing below adds to it.
|
|
47
|
+
const versionOnly = JSON.stringify(pkg) !== before && Boolean(pkg.dependencies.zod && pkg.devDependencies.vitest && pkg.scripts.test);
|
|
46
48
|
if (!pkg.dependencies.zod && tplPkg.dependencies?.zod)
|
|
47
49
|
pkg.dependencies.zod = tplPkg.dependencies.zod;
|
|
48
50
|
if (!pkg.devDependencies.vitest && tplPkg.devDependencies?.vitest)
|
|
@@ -116,6 +118,22 @@ export function upgradeProject(root, tpl, version) {
|
|
|
116
118
|
const ignore = read(path.join(root, ".gitignore")) ?? "";
|
|
117
119
|
if (!/^\.nerd\/?$/m.test(ignore))
|
|
118
120
|
write(".gitignore", `${ignore}${ignore.endsWith("\n") || !ignore ? "" : "\n"}.nerd/\n`);
|
|
121
|
+
// Nothing to do after an upgrade that found everything current. When the only change is the SDK's version
|
|
122
|
+
// in package.json, the steps say so: the longer wording reads as if the app's code were behind.
|
|
123
|
+
if (!changed.length)
|
|
124
|
+
return { from, to: version, changed, next: [] };
|
|
125
|
+
if (versionOnly && changed.length === 1 && changed[0] === "package.json") {
|
|
126
|
+
return {
|
|
127
|
+
from,
|
|
128
|
+
to: version,
|
|
129
|
+
changed,
|
|
130
|
+
next: [
|
|
131
|
+
"Run `pnpm install`.",
|
|
132
|
+
"Run `nerd check`. Only the SDK's version changed, so expect what it reported before. Anything new is something the newer version checks that the older one did not: work through it as usual.",
|
|
133
|
+
"Run `nerd deploy` when the check passes, and tell the person in a sentence that the app was brought up to date.",
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
119
137
|
const next = ["Run `pnpm install`.", "Run `nerd check` and work through what it reports. It names what this app's own code should adopt (for example sign-in for every route, checked input, access tests)."];
|
|
120
138
|
if (startersWritten.includes("test/access.test.ts"))
|
|
121
139
|
next.push("`test/access.test.ts` is the starter's: keep the first test, and rewrite the owner test for this app's own routes. Then set `\"tests\": \"required\"` in nerd.json.");
|
|
@@ -134,9 +152,8 @@ export async function upgrade(opts) {
|
|
|
134
152
|
return;
|
|
135
153
|
}
|
|
136
154
|
if (!r.changed.length)
|
|
137
|
-
log.ok(`Already current with nerd ${r.to}. Nothing was changed.`);
|
|
138
|
-
|
|
139
|
-
log.ok(`Updated ${r.changed.length} file(s) for nerd ${r.to}${r.from && r.from !== r.to ? ` (the app was on SDK ${r.from})` : ""}: ${r.changed.join(", ")}`);
|
|
155
|
+
return log.ok(`Already current with nerd ${r.to}. Nothing was changed, and there is nothing to do.`);
|
|
156
|
+
log.ok(`Updated ${r.changed.length} file(s) for nerd ${r.to}${r.from && r.from !== r.to ? ` (the app was on SDK ${r.from})` : ""}: ${r.changed.join(", ")}`);
|
|
140
157
|
log.info("Your app's own code was not touched. Next:");
|
|
141
158
|
r.next.forEach((n, i) => log.info(` ${i + 1}. ${n}`));
|
|
142
159
|
}
|
package/dist/gates/code.d.ts
CHANGED
|
@@ -13,5 +13,13 @@ export declare const typecheckGate: Gate;
|
|
|
13
13
|
export declare function depsRuleFor(severity: AdvisorySeverity): string;
|
|
14
14
|
export declare const depsGate: Gate;
|
|
15
15
|
export declare const policyGate: Gate;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the code at `index` sits in a route that only people with a given role can call: the route
|
|
18
|
+
* names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
|
|
19
|
+
* Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
|
|
20
|
+
* a query there with no user filter is not reported. A route that asks only for the role every new
|
|
21
|
+
* user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
|
|
22
|
+
*/
|
|
23
|
+
export declare function inRoleOnlyRoute(text: string, index: number): boolean;
|
|
16
24
|
/** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
|
|
17
25
|
export declare const piiGate: Gate;
|
package/dist/gates/code.js
CHANGED
|
@@ -2,13 +2,15 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { staticChecks } from "../commands/check.js";
|
|
5
|
-
import {
|
|
5
|
+
import { shellCommand } from "../util.js";
|
|
6
|
+
import { lineOf, sourceFiles, withoutComments } from "./fs.js";
|
|
6
7
|
import { finding } from "./secrets.js";
|
|
7
8
|
import { BLOCKED_PACKAGES, MINING_POOLS, normalizeSeverity, osvSeverity } from "./text.js";
|
|
8
9
|
import { counts, result, verdict } from "./types.js";
|
|
9
10
|
export function exec(cmd, args, cwd) {
|
|
10
11
|
return new Promise((resolve) => {
|
|
11
|
-
const
|
|
12
|
+
const how = shellCommand(cmd, args);
|
|
13
|
+
const child = spawn(how.file, how.args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: how.shell });
|
|
12
14
|
let stdout = "";
|
|
13
15
|
let stderr = "";
|
|
14
16
|
child.stdout.on("data", (d) => (stdout += d));
|
|
@@ -165,6 +167,50 @@ export const policyGate = {
|
|
|
165
167
|
return result(this, verdict(findings), findings.length ? counts(findings) : "No policy violations", findings, t);
|
|
166
168
|
},
|
|
167
169
|
};
|
|
170
|
+
/** Roles named in a `requireRole(...)` call that not every signed-in person has. "member" is what a new user gets. */
|
|
171
|
+
function restrictedRoles(args) {
|
|
172
|
+
if (!/^\s*(["'`][\w-]+["'`]\s*,?\s*)+$/.test(args))
|
|
173
|
+
return false;
|
|
174
|
+
return ["'`]/g)].some((m) => m[1] === "member");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether the code at `index` sits in a route that only people with a given role can call: the route
|
|
178
|
+
* names `requireRole("admin")` itself, or its app does with `.use(...)` before the route is declared.
|
|
179
|
+
* Reading every user's rows is what such a route is for (an admin's list, a count across everyone), so
|
|
180
|
+
* a query there with no user filter is not reported. A route that asks only for the role every new
|
|
181
|
+
* user gets is open to everyone signed in and does not count. `text` must have its comments blanked.
|
|
182
|
+
*/
|
|
183
|
+
export function inRoleOnlyRoute(text, index) {
|
|
184
|
+
const routeRe = /\b(\w+)\.(get|post|put|patch|delete|all)\(\s*(["'`])(\/[^"'`\n]*|\*)\3/g;
|
|
185
|
+
let route = null;
|
|
186
|
+
for (let m = routeRe.exec(text); m && m.index < index; m = routeRe.exec(text))
|
|
187
|
+
route = { app: m[1], path: m[4], index: m.index, open: m.index + m[0].indexOf("(") };
|
|
188
|
+
if (!route)
|
|
189
|
+
return false;
|
|
190
|
+
// The code has to be inside that route's own call, not in a function written after it.
|
|
191
|
+
let depth = 0;
|
|
192
|
+
for (let i = route.open; i < index; i++) {
|
|
193
|
+
const ch = text.charCodeAt(i);
|
|
194
|
+
if (ch === 40)
|
|
195
|
+
depth++;
|
|
196
|
+
else if (ch === 41 && --depth === 0)
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const inline = /\brequireRole\(([^)]*)\)/.exec(text.slice(route.open, index));
|
|
200
|
+
if (inline && restrictedRoles(inline[1]))
|
|
201
|
+
return true;
|
|
202
|
+
const useRe = /\b(\w+)\.use\(\s*(?:(["'`])([^"'`\n]*)\2\s*,\s*)?requireRole\(([^)]*)\)/g;
|
|
203
|
+
for (let u = useRe.exec(text); u && u.index < route.index; u = useRe.exec(text)) {
|
|
204
|
+
if (u[1] !== route.app || !restrictedRoles(u[4]))
|
|
205
|
+
continue;
|
|
206
|
+
const pattern = u[3];
|
|
207
|
+
if (pattern === undefined || pattern === "*" || pattern === "/*" || pattern === route.path)
|
|
208
|
+
return true;
|
|
209
|
+
if (pattern.endsWith("/*") && (route.path === pattern.slice(0, -2) || route.path.startsWith(pattern.slice(0, -1))))
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
168
214
|
/** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
|
|
169
215
|
export const piiGate = {
|
|
170
216
|
name: "pii",
|
|
@@ -185,6 +231,7 @@ export const piiGate = {
|
|
|
185
231
|
for (const f of sourceFiles(ctx.root)) {
|
|
186
232
|
if (!/\.(ts|tsx|js|jsx|mjs)$/.test(f.rel) || !f.rel.startsWith("src/"))
|
|
187
233
|
continue;
|
|
234
|
+
let bare;
|
|
188
235
|
for (const c of checks) {
|
|
189
236
|
if (c.files && !c.files.test(f.rel))
|
|
190
237
|
continue;
|
|
@@ -193,6 +240,9 @@ export const piiGate = {
|
|
|
193
240
|
while ((m = c.re.exec(f.text))) {
|
|
194
241
|
if (c.rule === "pii/unscoped-user-table" && !/user_id|userId/.test(f.text))
|
|
195
242
|
continue;
|
|
243
|
+
// An admin's view across everyone is meant to have no user filter.
|
|
244
|
+
if (c.rule === "pii/unscoped-user-table" && inRoleOnlyRoute((bare ??= withoutComments(f.text)), m.index))
|
|
245
|
+
continue;
|
|
196
246
|
findings.push(finding(c.rule, { file: f.rel, line: lineOf(f.text, m.index) }));
|
|
197
247
|
}
|
|
198
248
|
}
|
package/dist/gates/design.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { lineOf, sourceFiles } from "./fs.js";
|
|
3
|
+
import { lineOf, sourceFiles, withoutComments } from "./fs.js";
|
|
4
4
|
import { finding } from "./secrets.js";
|
|
5
5
|
import { counts, result, verdict } from "./types.js";
|
|
6
6
|
/**
|
|
@@ -16,10 +16,6 @@ import { counts, result, verdict } from "./types.js";
|
|
|
16
16
|
*/
|
|
17
17
|
const CODE = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
18
18
|
const LONG_FILE_LINES = 400;
|
|
19
|
-
/** Blanks out comments, keeping every character position. */
|
|
20
|
-
function withoutComments(text) {
|
|
21
|
-
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
22
|
-
}
|
|
23
19
|
/** "/api/todos/:id" → a pattern; ":id" and "*" match one or more characters of a segment. */
|
|
24
20
|
function routePattern(p) {
|
|
25
21
|
return new RegExp(`^${p.split("/").map((seg) => (seg.startsWith(":") ? "[^/]+" : seg === "*" ? ".*" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("/")}/?$`);
|
package/dist/gates/fs.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export interface SourceFile {
|
|
|
4
4
|
}
|
|
5
5
|
/** Text files under root, skipping dependencies and build output. */
|
|
6
6
|
export declare function sourceFiles(root: string): SourceFile[];
|
|
7
|
+
/** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
|
|
8
|
+
export declare function withoutComments(text: string): string;
|
|
7
9
|
export declare function lineOf(text: string, index: number): number;
|
package/dist/gates/fs.js
CHANGED
|
@@ -26,6 +26,10 @@ export function sourceFiles(root) {
|
|
|
26
26
|
walk(root);
|
|
27
27
|
return out;
|
|
28
28
|
}
|
|
29
|
+
/** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
|
|
30
|
+
export function withoutComments(text) {
|
|
31
|
+
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
32
|
+
}
|
|
29
33
|
export function lineOf(text, index) {
|
|
30
34
|
let line = 1;
|
|
31
35
|
for (let i = 0; i < index && i < text.length; i++)
|
package/dist/gates/rules.js
CHANGED
|
@@ -87,7 +87,7 @@ export const RULES = {
|
|
|
87
87
|
"pii/log-request-body": { title: "Logging a request body", severity: "warn", audience: "agent", risk: "Request bodies often carry personal data; logging them keeps copies you did not mean to keep.", fix: "Log only the fields you need, never the whole body." },
|
|
88
88
|
"pii/email-in-query-string": { title: "Email address in a URL", severity: "warn", audience: "agent", risk: "URLs end up in browser history, logs, and referrer headers.", fix: "Send it in the request body or use an id." },
|
|
89
89
|
"pii/plaintext-password-column": { title: "A password column in the schema", severity: "warn", audience: "agent", risk: "Storing passwords yourself is unnecessary here and easy to get wrong.", fix: "Remove the column; the platform handles passwords. If you must store a secret, store a hash." },
|
|
90
|
-
"pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
|
|
90
|
+
"pii/unscoped-user-table": { title: "Query on user data without a user filter", severity: "warn", audience: "agent", risk: "One user could see or change another user's rows.", fix: "Add .where(eq(table.userId, c.var.user.id)) to every read and write on tables with a user_id column. A route that only a role such as admin can call (`requireRole(\"admin\")` on the route or on its app) is expected to read across users and is not reported.", example: { wrong: "db.select().from(schema.notes).all()", right: "db.select().from(schema.notes).where(eq(schema.notes.userId, c.var.user!.id)).all()" } },
|
|
91
91
|
"pii/wildcard-cors": { title: "Wildcard CORS with cookies", severity: "warn", audience: "agent", risk: "Any website could call your API with the visitor's cookies.", fix: "Remove the wildcard. Same-origin requests need no CORS headers." },
|
|
92
92
|
"suppressions/ignore-comment": { title: "A scanner ignore comment was added", severity: "block", audience: "human", risk: "Ignore comments turn off a check for that line. They are sometimes right and sometimes a way to hide a problem.", fix: "Review the line in the dashboard and approve the suppression if it is correct; the agent must not add or approve suppressions itself." },
|
|
93
93
|
"suppressions/ignore-file": { title: "A scanner ignore file was changed", severity: "block", audience: "human", risk: "Ignore files silence checks across the whole project.", fix: "Review the change in the dashboard and approve it if it is correct." },
|
package/dist/gates/static.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lineOf, sourceFiles } from "./fs.js";
|
|
1
|
+
import { lineOf, sourceFiles, withoutComments } from "./fs.js";
|
|
2
2
|
import { finding } from "./secrets.js";
|
|
3
3
|
import { counts, result, verdict } from "./types.js";
|
|
4
4
|
const SERVER = /^src\/server\//;
|
|
@@ -21,10 +21,6 @@ const CHECKS = [
|
|
|
21
21
|
// An unchecked body: c.req.json() used directly instead of readBody(c, schema).
|
|
22
22
|
{ rule: "code/unvalidated-body", re: /\bc\.req\.(json|parseBody|formData)\(\)/g, files: SERVER },
|
|
23
23
|
];
|
|
24
|
-
/** Blanks out comments, keeping every character position, so a pattern inside a comment is not a finding. */
|
|
25
|
-
function withoutComments(text) {
|
|
26
|
-
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:"'`\\])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " "));
|
|
27
|
-
}
|
|
28
24
|
export const staticGate = {
|
|
29
25
|
name: "static",
|
|
30
26
|
title: "Unsafe code patterns",
|
package/dist/util.d.ts
CHANGED
|
@@ -22,7 +22,23 @@ export interface RunOptions {
|
|
|
22
22
|
env?: NodeJS.ProcessEnv;
|
|
23
23
|
/** Send the child's stdout to our stderr, so stdout stays reserved for `--json` output. */
|
|
24
24
|
stdoutToStderr?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* With `stdoutToStderr`: each line the child prints is passed through this first. Return the line to
|
|
27
|
+
* print it, another line to print that instead, or null to print nothing.
|
|
28
|
+
*/
|
|
29
|
+
stdoutLine?: (line: string) => string | null;
|
|
25
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
|
|
33
|
+
* start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
|
|
34
|
+
* them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
|
|
35
|
+
* its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
|
|
36
|
+
*/
|
|
37
|
+
export declare function shellCommand(cmd: string, args: string[], platform?: NodeJS.Platform): {
|
|
38
|
+
file: string;
|
|
39
|
+
args: string[];
|
|
40
|
+
shell: boolean;
|
|
41
|
+
};
|
|
26
42
|
/** Runs a command, streaming output. Resolves with the exit code. */
|
|
27
43
|
export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<number>;
|
|
28
44
|
export declare function runOrThrow(cmd: string, args: string[], opts?: RunOptions): Promise<void>;
|
package/dist/util.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
4
5
|
export class CliError extends Error {
|
|
5
6
|
exitCode;
|
|
6
7
|
/** The API's error code (e.g. "not_found") when the error came from a response; callers branch on this, never on the message. */
|
|
@@ -22,13 +23,37 @@ export const log = {
|
|
|
22
23
|
ok: (msg) => console.error(`\x1b[32m✓\x1b[0m ${msg}`),
|
|
23
24
|
warn: (msg) => console.error(`\x1b[33m!\x1b[0m ${msg}`),
|
|
24
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* How a command is started. On Windows the tools an app uses (npx, pnpm) are `.cmd` files, which only
|
|
28
|
+
* start through the shell, and a shell takes one line. Handed a separate list of arguments, Node joins
|
|
29
|
+
* them with spaces and no quotes (and prints the DEP0190 warning about it), so a folder with a space in
|
|
30
|
+
* its name arrives as two arguments. The line is built here instead, with quotes round whatever needs them.
|
|
31
|
+
*/
|
|
32
|
+
export function shellCommand(cmd, args, platform = process.platform) {
|
|
33
|
+
if (platform !== "win32")
|
|
34
|
+
return { file: cmd, args, shell: false };
|
|
35
|
+
// Inside quotes, a program's own argument parser reads backslashes literally unless a quote follows them.
|
|
36
|
+
const quote = (a) => (/^[\w./:=@\\,+-]+$/.test(a) ? a : `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`);
|
|
37
|
+
return { file: [cmd, ...args].map(quote).join(" "), args: [], shell: true };
|
|
38
|
+
}
|
|
25
39
|
/** Runs a command, streaming output. Resolves with the exit code. */
|
|
26
40
|
export function run(cmd, args, opts = {}) {
|
|
27
41
|
return new Promise((resolve, reject) => {
|
|
28
|
-
const
|
|
29
|
-
child.
|
|
42
|
+
const how = shellCommand(cmd, args);
|
|
43
|
+
const child = spawn(how.file, how.args, { cwd: opts.cwd, env: { ...process.env, ...opts.env }, stdio: ["inherit", opts.stdoutToStderr ? "pipe" : "inherit", "inherit"], shell: how.shell });
|
|
44
|
+
const perLine = child.stdout && opts.stdoutLine ? opts.stdoutLine : null;
|
|
45
|
+
if (perLine) {
|
|
46
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
47
|
+
const out = perLine(line);
|
|
48
|
+
if (out !== null)
|
|
49
|
+
process.stderr.write(`${out}\n`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
child.stdout?.pipe(process.stderr);
|
|
30
54
|
child.on("error", reject);
|
|
31
|
-
|
|
55
|
+
// "close" comes after the child's output has ended, so every line has been through `stdoutLine` by then.
|
|
56
|
+
child.on(perLine ? "close" : "exit", (code) => resolve(code ?? 1));
|
|
32
57
|
});
|
|
33
58
|
}
|
|
34
59
|
export async function runOrThrow(cmd, args, opts = {}) {
|