nerdsystem 0.0.6 → 0.0.8
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/README.md +11 -0
- package/dist/api.js +3 -3
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +311 -0
- package/dist/commands/admin.d.ts +10 -0
- package/dist/commands/admin.js +36 -0
- package/dist/commands/check.js +3 -2
- package/dist/commands/db.d.ts +8 -0
- package/dist/commands/db.js +25 -0
- package/dist/commands/deploy.js +2 -2
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/init.js +55 -15
- package/dist/commands/org.js +1 -1
- package/dist/commands/trash.d.ts +17 -0
- package/dist/commands/trash.js +84 -0
- package/dist/commands/verify.js +3 -3
- package/dist/gates/code.d.ts +4 -1
- package/dist/gates/code.js +8 -5
- package/dist/gates/fs.js +1 -1
- package/dist/gates/index.d.ts +2 -1
- package/dist/gates/index.js +1 -0
- package/dist/gates/rules.js +6 -3
- package/dist/gates/secrets.d.ts +5 -0
- package/dist/gates/secrets.js +50 -10
- package/dist/gates/text.d.ts +39 -0
- package/dist/gates/text.js +82 -2
- package/dist/gates/types.d.ts +12 -0
- package/dist/gates/types.js +18 -0
- package/dist/index.js +2 -262
- package/dist/util.d.ts +10 -1
- package/dist/util.js +9 -1
- package/package.json +1 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +7 -2
- package/template/.cursor/rules/nerdsystem.mdc +8 -3
- package/template/.github/copilot-instructions.md +8 -3
- package/template/AGENTS.md +8 -3
- package/template/GEMINI.md +8 -3
- package/template/package.json +6 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ApiClient } from "../api.js";
|
|
2
|
+
import { findProjectRoot, readNerdJson } from "../project.js";
|
|
3
|
+
import { CliError, log, printJson, table } from "../util.js";
|
|
4
|
+
/** The project named on the command line, or the one this directory is linked to. */
|
|
5
|
+
async function target(opts) {
|
|
6
|
+
if (opts.slug) {
|
|
7
|
+
if (opts.org)
|
|
8
|
+
return { org: opts.org, project: opts.slug };
|
|
9
|
+
try {
|
|
10
|
+
return { org: readNerdJson(findProjectRoot()).org, project: opts.slug };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
const me = await ApiClient.fromConfig().get("/v1/me");
|
|
14
|
+
if (me.orgs.length === 1)
|
|
15
|
+
return { org: me.orgs[0].slug, project: opts.slug };
|
|
16
|
+
throw new CliError(`Pass --org too. Yours: ${me.orgs.map((o) => o.slug).join(", ")}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
20
|
+
return { org: opts.org ?? nerd.org, project: nerd.project };
|
|
21
|
+
}
|
|
22
|
+
async function confirm(question) {
|
|
23
|
+
if (!process.stdin.isTTY || process.env.CI)
|
|
24
|
+
return false;
|
|
25
|
+
const { createInterface } = await import("node:readline/promises");
|
|
26
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
27
|
+
try {
|
|
28
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
29
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** `nerd delete`: the app goes offline now; the API keeps the data for a grace period and can restore it. */
|
|
36
|
+
export async function deleteProject(opts) {
|
|
37
|
+
const t = await target(opts);
|
|
38
|
+
const name = `${t.org}/${t.project}`;
|
|
39
|
+
if (!opts.yes) {
|
|
40
|
+
if (opts.json)
|
|
41
|
+
throw new CliError(`Refusing to delete ${name} without --yes.`);
|
|
42
|
+
const ok = await confirm(`Delete ${name}? The app goes offline now; its database and files are kept for a week and can be restored with \`nerd trash restore ${t.project}\`.`);
|
|
43
|
+
if (!ok)
|
|
44
|
+
throw new CliError(process.stdin.isTTY ? "Not deleted. Pass --yes to skip the question." : "Not deleted. Pass --yes to confirm.");
|
|
45
|
+
}
|
|
46
|
+
const r = await ApiClient.fromConfig().delete(`/v1/orgs/${t.org}/projects/${t.project}`);
|
|
47
|
+
if (opts.json)
|
|
48
|
+
return printJson({ ...r, org: t.org, project: t.project });
|
|
49
|
+
log.ok(`Deleted ${name}. The app is offline; its data is destroyed after ${r.purgeAfter.slice(0, 10)}.`);
|
|
50
|
+
log.info(`Changed your mind? \`nerd trash restore ${t.project}${opts.org ? ` --org ${t.org}` : ""}\` before then (API: ${r.restore}).`);
|
|
51
|
+
}
|
|
52
|
+
/** `nerd trash`: deleted projects still inside their grace period. */
|
|
53
|
+
export async function trashList(opts) {
|
|
54
|
+
let org = opts.org;
|
|
55
|
+
if (!org) {
|
|
56
|
+
try {
|
|
57
|
+
org = readNerdJson(findProjectRoot()).org;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
const me = await ApiClient.fromConfig().get("/v1/me");
|
|
61
|
+
if (me.orgs.length !== 1)
|
|
62
|
+
throw new CliError(`Pass --org. Yours: ${me.orgs.map((o) => o.slug).join(", ")}`);
|
|
63
|
+
org = me.orgs[0].slug;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const r = await ApiClient.fromConfig().get(`/v1/orgs/${org}/trash`);
|
|
67
|
+
if (opts.json)
|
|
68
|
+
return printJson(r);
|
|
69
|
+
if (!r.projects.length)
|
|
70
|
+
return log.info(`Nothing in the trash for ${org}.`);
|
|
71
|
+
table(r.projects.map((p) => ({ slug: p.slug, name: p.name, purgeAfter: p.purgeAfter.slice(0, 10) })), ["slug", "name", "purgeAfter"]);
|
|
72
|
+
log.info(`Restore one with: nerd trash restore <slug>${opts.org ? ` --org ${org}` : ""}`);
|
|
73
|
+
}
|
|
74
|
+
/** `nerd trash restore <slug>`: undo a delete. The app stays offline until the next deploy or rollback. */
|
|
75
|
+
export async function trashRestore(slug, opts) {
|
|
76
|
+
if (!slug)
|
|
77
|
+
throw new CliError("Usage: nerd trash restore <slug> [--org <org>]");
|
|
78
|
+
const t = await target({ org: opts.org, slug });
|
|
79
|
+
const r = await ApiClient.fromConfig().post(`/v1/orgs/${t.org}/trash/${t.project}`, {});
|
|
80
|
+
if (opts.json)
|
|
81
|
+
return printJson(r);
|
|
82
|
+
log.ok(`Restored ${t.org}/${r.project.slug}.`);
|
|
83
|
+
log.info(r.next);
|
|
84
|
+
}
|
package/dist/commands/verify.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ApiClient } from "../api.js";
|
|
4
|
-
import { BUILD_GATES, knownSecretsFrom, runGates, SOURCE_GATES } from "../gates/index.js";
|
|
4
|
+
import { BUILD_GATES, gateSummary, knownSecretsFrom, runGates, skippedGates, SOURCE_GATES } from "../gates/index.js";
|
|
5
5
|
import { findProjectRoot, projectPath, readDevVars, readNerdJson } from "../project.js";
|
|
6
6
|
import { CliError, log } from "../util.js";
|
|
7
7
|
import { readBuildOutput } from "./deploy.js";
|
|
@@ -39,9 +39,9 @@ export async function verify(opts) {
|
|
|
39
39
|
const root = findProjectRoot();
|
|
40
40
|
const out = await runVerify(root, { withBuild: !opts["no-build"], stopOnFail: false, quiet: opts.json, onlyRules: opts.rule ? [opts.rule] : undefined });
|
|
41
41
|
if (opts.json)
|
|
42
|
-
process.stdout.write(`${JSON.stringify({ ok: out.ok, failures: out.failures, stop: out.stop }, null, 2)}\n`);
|
|
42
|
+
process.stdout.write(`${JSON.stringify({ ok: out.ok, failures: out.failures, stop: out.stop, skipped: skippedGates(out.results) }, null, 2)}\n`);
|
|
43
43
|
else if (out.ok)
|
|
44
|
-
log.ok(
|
|
44
|
+
log.ok(gateSummary(out.results));
|
|
45
45
|
const human = out.failures.filter((f) => f.audience === "human" && f.severity === "block");
|
|
46
46
|
if (!opts.json && human.length)
|
|
47
47
|
log.warn(`${human.length} finding(s) need a person, not the agent: ${human.map((f) => f.rule_id).join(", ")}`);
|
package/dist/gates/code.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AdvisorySeverity } from "./text.js";
|
|
1
2
|
import { type Gate } from "./types.js";
|
|
2
3
|
export declare function exec(cmd: string, args: string[], cwd: string): Promise<{
|
|
3
4
|
code: number;
|
|
@@ -8,7 +9,9 @@ export declare function hasTool(name: string): Promise<boolean>;
|
|
|
8
9
|
/** Platform rules, as `nerd check` enforces them. The rule text is ours, so it is safe to show verbatim. */
|
|
9
10
|
export declare const rulesGate: Gate;
|
|
10
11
|
export declare const typecheckGate: Gate;
|
|
11
|
-
/**
|
|
12
|
+
/** The catalog rule for an advisory severity. Critical and high block, as the homepage promises; moderate warns; low and unrated inform. */
|
|
13
|
+
export declare function depsRuleFor(severity: AdvisorySeverity): string;
|
|
14
|
+
/** Known vulnerabilities in dependencies. Critical and high block; moderate warns. Package names come from the registry, not the repo. */
|
|
12
15
|
export declare const depsGate: Gate;
|
|
13
16
|
export declare const policyGate: Gate;
|
|
14
17
|
/** Heuristics for careless handling of personal data. Advisory: warns, never blocks. */
|
package/dist/gates/code.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { staticChecks } from "../commands/check.js";
|
|
5
5
|
import { lineOf, sourceFiles } from "./fs.js";
|
|
6
6
|
import { finding } from "./secrets.js";
|
|
7
|
+
import { BLOCKED_PACKAGES, MINING_POOLS, normalizeSeverity, osvSeverity } from "./text.js";
|
|
7
8
|
import { counts, result, verdict } from "./types.js";
|
|
8
9
|
export function exec(cmd, args, cwd) {
|
|
9
10
|
return new Promise((resolve) => {
|
|
@@ -66,7 +67,11 @@ function depsVerdictSummary(findings) {
|
|
|
66
67
|
return "No lockfile; commit one so installs are reproducible";
|
|
67
68
|
return findings.length ? counts(findings) : "No known vulnerabilities";
|
|
68
69
|
}
|
|
69
|
-
/**
|
|
70
|
+
/** The catalog rule for an advisory severity. Critical and high block, as the homepage promises; moderate warns; low and unrated inform. */
|
|
71
|
+
export function depsRuleFor(severity) {
|
|
72
|
+
return severity === "critical" ? "deps/critical-vulnerability" : severity === "high" ? "deps/high-vulnerability" : severity === "moderate" ? "deps/moderate-vulnerability" : severity === "low" ? "deps/low-vulnerability" : "deps/advisory";
|
|
73
|
+
}
|
|
74
|
+
/** Known vulnerabilities in dependencies. Critical and high block; moderate warns. Package names come from the registry, not the repo. */
|
|
70
75
|
export const depsGate = {
|
|
71
76
|
name: "dependencies",
|
|
72
77
|
title: "Dependency vulnerabilities",
|
|
@@ -78,7 +83,6 @@ export const depsGate = {
|
|
|
78
83
|
const lock = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"].find((f) => existsSync(path.join(ctx.root, f)));
|
|
79
84
|
if (!lock)
|
|
80
85
|
findings.push(finding("deps/lockfile-required", {}));
|
|
81
|
-
const sev = (s) => (s.toUpperCase() === "CRITICAL" ? "deps/critical-vulnerability" : s.toUpperCase() === "HIGH" ? "deps/high-vulnerability" : "deps/advisory");
|
|
82
86
|
if (await hasTool("osv-scanner")) {
|
|
83
87
|
const r = await exec("osv-scanner", ["scan", "source", "--format", "json", "--lockfile", lock ?? "pnpm-lock.yaml"], ctx.root);
|
|
84
88
|
try {
|
|
@@ -86,7 +90,7 @@ export const depsGate = {
|
|
|
86
90
|
for (const res of json.results ?? [])
|
|
87
91
|
for (const p of res.packages ?? [])
|
|
88
92
|
for (const v of p.vulnerabilities) {
|
|
89
|
-
const id =
|
|
93
|
+
const id = depsRuleFor(osvSeverity(v));
|
|
90
94
|
findings.push(finding(id, { file: lock, fix: `Update ${p.package.name} from ${p.package.version} to a version without ${v.id} (https://osv.dev/vulnerability/${v.id}); \`pnpm update ${p.package.name}\`.`, fingerprint: `${v.id}:${p.package.name}` }));
|
|
91
95
|
}
|
|
92
96
|
return result(this, verdict(findings), depsVerdictSummary(findings), findings, t, "osv-scanner");
|
|
@@ -100,7 +104,7 @@ export const depsGate = {
|
|
|
100
104
|
try {
|
|
101
105
|
const json = JSON.parse(r.stdout);
|
|
102
106
|
for (const a of Object.values(json.advisories ?? {})) {
|
|
103
|
-
findings.push(finding(
|
|
107
|
+
findings.push(finding(depsRuleFor(normalizeSeverity(a.severity)), { file: lock, fix: `Update ${a.module_name} to a fixed version${a.url ? ` (${a.url})` : ""}; \`${pm} update ${a.module_name}\`.`, fingerprint: `${a.url ?? a.module_name}` }));
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
110
|
catch {
|
|
@@ -111,7 +115,6 @@ export const depsGate = {
|
|
|
111
115
|
},
|
|
112
116
|
};
|
|
113
117
|
/** Packages that have no place in a hosted web app, and size caps. Deterministic policy only. */
|
|
114
|
-
import { BLOCKED_PACKAGES, MINING_POOLS } from "./text.js";
|
|
115
118
|
const MAX_WORKER_BYTES = 10 * 1024 * 1024;
|
|
116
119
|
const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
117
120
|
export const policyGate = {
|
package/dist/gates/fs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", ".wrangler", ".vite", "coverage"]);
|
|
4
|
-
const TEXT_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".md", ".mdc", ".yml", ".yaml", ".toml", ".env", ".txt", ".html", ".css", ".sql", ".example"]);
|
|
4
|
+
const TEXT_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".md", ".mdc", ".yml", ".yaml", ".toml", ".env", ".txt", ".html", ".css", ".sql", ".example", ".sample", ".template"]);
|
|
5
5
|
const MAX_BYTES = 2 * 1024 * 1024;
|
|
6
6
|
/** Text files under root, skipping dependencies and build output. */
|
|
7
7
|
export function sourceFiles(root) {
|
package/dist/gates/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Finding, Gate, GateContext, GateResult } from "./types.js";
|
|
2
|
-
export type { Audience, Finding, Gate, GateContext, GateResult, GateStatus, Severity } from "./types.js";
|
|
2
|
+
export type { Audience, Finding, Gate, GateContext, GateResult, GateStatus, Severity, SkippedGate } from "./types.js";
|
|
3
|
+
export { gateSummary, skippedGates } from "./types.js";
|
|
3
4
|
export { RULES, ruleFor, type RuleDef } from "./rules.js";
|
|
4
5
|
export { mask } from "./secrets.js";
|
|
5
6
|
export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
|
package/dist/gates/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { depsGate, piiGate, policyGate, rulesGate, typecheckGate } from "./code.
|
|
|
3
3
|
import { gitleaksGate, semgrepGate } from "./tools.js";
|
|
4
4
|
import { suppressionsGate } from "./suppressions.js";
|
|
5
5
|
import { applyAttempts, MAX_ATTEMPTS } from "./attempts.js";
|
|
6
|
+
export { gateSummary, skippedGates } from "./types.js";
|
|
6
7
|
export { RULES, ruleFor } from "./rules.js";
|
|
7
8
|
export { mask } from "./secrets.js";
|
|
8
9
|
export { MAX_ATTEMPTS, readAttempts } from "./attempts.js";
|
package/dist/gates/rules.js
CHANGED
|
@@ -23,11 +23,14 @@ export const RULES = {
|
|
|
23
23
|
"secrets/rotate-committed-secret": { title: "A committed secret must be rotated", severity: "block", audience: "human", risk: "Removing a secret from the code does not remove it from git history; anyone who fetched the repository still has it.", fix: "Rotate the credential at the provider that issued it (create a new one, update the platform secret, revoke the old one). Then the agent can remove it from the code.", example: { wrong: "Delete the line and commit", right: "Rotate at the provider first, then delete the line" } },
|
|
24
24
|
"bundle/known-secret-in-client": { title: "A server secret is in the client bundle", severity: "block", audience: "agent", risk: "A value from .dev.vars is compiled into JavaScript that every visitor downloads.", fix: "Only VITE_-prefixed variables belong in the client, and none of them may be secrets. Move the call that needs the secret to a server route.", example: { wrong: "fetch(api, { headers: { Authorization: import.meta.env.VITE_API_SECRET } })", right: "app.post('/api/thing', async (c) => fetch(api, { headers: { Authorization: c.env.API_SECRET } }))" } },
|
|
25
25
|
"bundle/known-secret-in-server": { title: "A local secret value is in the server bundle", severity: "block", audience: "agent", risk: "A value that should come from platform secrets is hard-coded in the built server.", fix: "Read it from c.env instead of embedding it." },
|
|
26
|
+
"bundle/local-secrets-in-dist": { title: "Local secrets file copied into build output", severity: "warn", audience: "agent", risk: "A local secrets file (.dev.vars or .env*) was copied into dist/ by the build. It is not uploaded, but anyone who zips or shares dist/ ships the secrets with it.", fix: "Nothing to change in the code: do not zip or share dist/. Delete the copy with `rm dist/*/.dev.vars` before handing the folder to anyone, and keep .dev.vars and .env* in .gitignore." },
|
|
26
27
|
"bundle/vite-secret-exposed": { title: "A VITE_ variable looks like a secret", severity: "block", audience: "agent", risk: "VITE_ variables are public: this one is compiled into the client where anyone can read it.", fix: "Rename it without the VITE_ prefix, set it as a server secret, and use it only in server code.", example: { wrong: "VITE_STRIPE_SECRET_KEY=sk_live_...", right: "STRIPE_SECRET_KEY as a platform secret, read on the server" } },
|
|
27
28
|
"deps/lockfile-required": { title: "No lockfile", severity: "block", audience: "agent", risk: "Without a lockfile every install can pull different versions, so what passed checks is not what deploys.", fix: "Run `pnpm install` and commit pnpm-lock.yaml." },
|
|
28
|
-
"deps/critical-vulnerability": { title: "Dependency with a critical vulnerability", severity: "block", audience: "agent", risk: "A known, exploitable bug
|
|
29
|
-
"deps/high-vulnerability": { title: "Dependency with a high-severity
|
|
30
|
-
"deps/
|
|
29
|
+
"deps/critical-vulnerability": { title: "Dependency with a critical vulnerability", severity: "block", audience: "agent", risk: "A package your app ships has a critical, known, exploitable bug; attackers scan for exactly these.", fix: "Update the package to a fixed version (`pnpm update <name>`), or replace it if no fix exists. Dependabot pull requests do this automatically.", example: { wrong: '"lodash": "4.17.15"', right: '"lodash": "^4.17.21"' } },
|
|
30
|
+
"deps/high-vulnerability": { title: "Dependency with a high-severity vulnerability", severity: "block", audience: "agent", risk: "A package your app ships has a high-severity known bug that is exploitable in common setups.", fix: "Update the package to a fixed version (`pnpm update <name>`), or replace it if no fix exists.", example: { wrong: '"lodash": "4.17.15"', right: '"lodash": "^4.17.21"' } },
|
|
31
|
+
"deps/moderate-vulnerability": { title: "Dependency with a moderate-severity advisory", severity: "warn", audience: "agent", risk: "A package your app ships has a known bug of moderate severity; exploitable only in some setups, but still worth fixing before it grows.", fix: "Update the package when a fixed version exists (`pnpm update <name>`)." },
|
|
32
|
+
"deps/low-vulnerability": { title: "Dependency with a low-severity advisory", severity: "info", audience: "agent", risk: "A package your app ships has a known issue of low severity.", fix: "Update when convenient." },
|
|
33
|
+
"deps/advisory": { title: "Dependency advisory", severity: "info", audience: "agent", risk: "A package your app ships has a published advisory whose severity is not rated yet.", fix: "Read the advisory at the link in the finding and update if it applies to how you use the package." },
|
|
31
34
|
"deps/audit-unavailable": { title: "Dependency audit could not run", severity: "warn", audience: "human", risk: "Vulnerable packages would not be detected this run.", fix: "Check network access and the package manager, then run `nerd verify` again." },
|
|
32
35
|
"rules/platform": { title: "Platform rule", severity: "block", audience: "agent", risk: "The app would not run correctly on the platform.", fix: "Follow the instruction in the finding; the rules are listed in the nerdsystem skill." },
|
|
33
36
|
"rules/advisory": { title: "Platform advisory", severity: "warn", audience: "agent", risk: "Not blocking, but the project is missing something the platform expects.", fix: "Follow the instruction in the finding." },
|
package/dist/gates/secrets.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export declare function finding(ruleId: string, extra: Partial<Finding> & {
|
|
|
5
5
|
file?: string | undefined;
|
|
6
6
|
line?: number | undefined;
|
|
7
7
|
}): Finding;
|
|
8
|
+
/**
|
|
9
|
+
* Findings for every credential-shaped value in a text. A hit in an example file or with an obvious
|
|
10
|
+
* placeholder value is reported as a warning with the reason in its risk text, so it is seen but
|
|
11
|
+
* never blocks a deploy and never asks a person to rotate anything.
|
|
12
|
+
*/
|
|
8
13
|
export declare function scanText(text: string, file: string): Finding[];
|
|
9
14
|
export declare const secretsGate: Gate;
|
|
10
15
|
/** Built output must not carry server secrets or anything that looks like one. */
|
package/dist/gates/secrets.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { exec } from "./code.js";
|
|
5
5
|
import { sourceFiles } from "./fs.js";
|
|
6
|
-
import { findSecrets, mask } from "./text.js";
|
|
6
|
+
import { ENV_FILES, findSecrets, isExampleFile, mask } from "./text.js";
|
|
7
7
|
import { RULES } from "./rules.js";
|
|
8
8
|
import { counts, result, verdict } from "./types.js";
|
|
9
9
|
export { mask } from "./text.js";
|
|
@@ -26,8 +26,19 @@ export function finding(ruleId, extra) {
|
|
|
26
26
|
fingerprint: extra.fingerprint ?? fingerprint(ruleId, extra.file, extra.line),
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Findings for every credential-shaped value in a text. A hit in an example file or with an obvious
|
|
31
|
+
* placeholder value is reported as a warning with the reason in its risk text, so it is seen but
|
|
32
|
+
* never blocks a deploy and never asks a person to rotate anything.
|
|
33
|
+
*/
|
|
29
34
|
export function scanText(text, file) {
|
|
30
|
-
return findSecrets(text, file).map((h) =>
|
|
35
|
+
return findSecrets(text, file).map((h) => {
|
|
36
|
+
const f = finding(`secrets/${h.rule}`, { file: h.file, line: h.line, masked: h.masked });
|
|
37
|
+
if (!h.downgrade)
|
|
38
|
+
return f;
|
|
39
|
+
const note = h.downgrade === "example file" ? "This is an example file, so it is shown as a warning; keep only placeholder values in it." : "The value looks like a placeholder (default password, local host, or stand-in text), so this is a warning, not a block; replace it with an environment reference anyway.";
|
|
40
|
+
return { ...f, severity: f.severity === "block" ? "warn" : f.severity, risk: `${f.risk} (${h.downgrade}) ${note}` };
|
|
41
|
+
});
|
|
31
42
|
}
|
|
32
43
|
async function isTracked(root, file) {
|
|
33
44
|
if (!existsSync(path.join(root, ".git")))
|
|
@@ -44,28 +55,39 @@ export const secretsGate = {
|
|
|
44
55
|
const t = Date.now();
|
|
45
56
|
const findings = [];
|
|
46
57
|
for (const f of sourceFiles(ctx.root)) {
|
|
47
|
-
if (/(^|\/)\.env(\.|$)/.test(f.rel) && !
|
|
58
|
+
if (/(^|\/)\.env(\.|$)/.test(f.rel) && !isExampleFile(f.rel))
|
|
48
59
|
findings.push(finding("secrets/env-file-committed", { file: f.rel }));
|
|
49
60
|
if (/(^|\/)\.dev\.vars$/.test(f.rel))
|
|
50
61
|
findings.push(finding("secrets/dev-vars-committed", { file: f.rel }));
|
|
51
|
-
if (f.rel.endsWith(".example"))
|
|
52
|
-
continue;
|
|
53
62
|
findings.push(...scanText(f.text, f.rel));
|
|
54
63
|
}
|
|
55
64
|
const gitignore = path.join(ctx.root, ".gitignore");
|
|
56
65
|
if (!existsSync(gitignore) || !/\.dev\.vars/.test(readFileSync(gitignore, "utf8")))
|
|
57
66
|
findings.push(finding("secrets/gitignore-dev-vars", { file: ".gitignore" }));
|
|
58
67
|
// A secret in a committed file is already in history: removing it is not enough, a person must rotate it.
|
|
59
|
-
|
|
68
|
+
// Only a blocking finding counts: example files and placeholder values are warnings and need no rotation.
|
|
69
|
+
const credentialFiles = Array.from(new Set(findings.filter((f) => f.file && f.masked && f.severity === "block").map((f) => f.file)));
|
|
60
70
|
for (const file of credentialFiles) {
|
|
61
71
|
if (ctx.committed || (await isTracked(ctx.root, file))) {
|
|
62
|
-
const first = findings.find((f) => f.file === file && f.masked);
|
|
72
|
+
const first = findings.find((f) => f.file === file && f.masked && f.severity === "block");
|
|
63
73
|
findings.push(finding("secrets/rotate-committed-secret", { file, line: first.line, masked: first.masked }));
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
return result(this, verdict(findings), findings.length ? counts(findings) : "No credentials found in source", findings, t);
|
|
67
77
|
},
|
|
68
78
|
};
|
|
79
|
+
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
|
|
80
|
+
function walkFiles(dir) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const entry of readdirSync(dir)) {
|
|
83
|
+
const full = path.join(dir, entry);
|
|
84
|
+
if (statSync(full).isDirectory())
|
|
85
|
+
out.push(...walkFiles(full));
|
|
86
|
+
else
|
|
87
|
+
out.push(full);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
69
91
|
/** Built output must not carry server secrets or anything that looks like one. */
|
|
70
92
|
export const bundleGate = {
|
|
71
93
|
name: "bundle",
|
|
@@ -86,12 +108,30 @@ export const bundleGate = {
|
|
|
86
108
|
if (isClient && /VITE_[A-Z0-9_]*(SECRET|PRIVATE|PASSWORD|TOKEN|API_KEY)[A-Z0-9_]*/.test(text))
|
|
87
109
|
findings.push(finding("bundle/vite-secret-exposed", { file: rel }));
|
|
88
110
|
};
|
|
111
|
+
// The whole dist/ tree, not only what is uploaded: the Vite plugin copies .dev.vars next to the
|
|
112
|
+
// Worker, and anything else that lands here travels with the folder when someone zips it.
|
|
113
|
+
const dist = path.join(ctx.root, "dist");
|
|
114
|
+
const assetsDir = ctx.build.assetsDir ? path.resolve(ctx.build.assetsDir) : undefined;
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const full of existsSync(dist) ? walkFiles(dist) : []) {
|
|
117
|
+
const rel = path.relative(ctx.root, full).split(path.sep).join("/");
|
|
118
|
+
seen.add(path.resolve(full));
|
|
119
|
+
if (ENV_FILES.test(rel)) {
|
|
120
|
+
findings.push(finding("bundle/local-secrets-in-dist", { file: rel }));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!/\.(js|mjs|html|css|json|map|txt)$/.test(rel) || statSync(full).size > MAX_SCAN_BYTES)
|
|
124
|
+
continue;
|
|
125
|
+
const isClient = assetsDir ? path.resolve(full).startsWith(`${assetsDir}${path.sep}`) : rel.startsWith("dist/client/");
|
|
126
|
+
scan(rel, readFileSync(full, "utf8"), isClient);
|
|
127
|
+
}
|
|
128
|
+
// Build output that lives outside dist/ (tests, custom layouts): scan what the upload will carry.
|
|
89
129
|
for (const m of ctx.build.modules)
|
|
90
|
-
if (/\.(js|mjs)$/.test(m))
|
|
130
|
+
if (/\.(js|mjs)$/.test(m) && !seen.has(path.resolve(ctx.build.workerDir, m)))
|
|
91
131
|
scan(`dist/worker/${m}`, readFileSync(path.join(ctx.build.workerDir, m), "utf8"), false);
|
|
92
132
|
if (ctx.build.assetsDir)
|
|
93
133
|
for (const a of ctx.build.assets)
|
|
94
|
-
if (/\.(js|mjs|html|css|json|map)$/.test(a))
|
|
134
|
+
if (/\.(js|mjs|html|css|json|map)$/.test(a) && !seen.has(path.resolve(ctx.build.assetsDir, a)))
|
|
95
135
|
scan(`dist/client/${a}`, readFileSync(path.join(ctx.build.assetsDir, a), "utf8"), true);
|
|
96
136
|
return result(this, verdict(findings), findings.length ? counts(findings) : "Built output is clean", findings, t);
|
|
97
137
|
},
|
package/dist/gates/text.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ export declare const SECRET_PATTERNS: {
|
|
|
9
9
|
re: RegExp;
|
|
10
10
|
}[];
|
|
11
11
|
export declare function isAllowedLine(line: string): boolean;
|
|
12
|
+
/** Files that exist to show the shape of a secret, not to hold one. Findings in them warn instead of block. */
|
|
13
|
+
export declare const EXAMPLE_FILE: RegExp;
|
|
14
|
+
export declare function isExampleFile(file: string): boolean;
|
|
15
|
+
/** True when a matched credential is clearly a stand-in: a placeholder token, or a database URL with a default password or a local host. */
|
|
16
|
+
export declare function isPlaceholderValue(rule: string, value: string): boolean;
|
|
12
17
|
/** Type and last four characters only: "sk_live_****4f2a". Never the value. */
|
|
13
18
|
export declare function mask(match: string): string;
|
|
14
19
|
export declare function lineAt(text: string, index: number): number;
|
|
@@ -18,6 +23,12 @@ export interface SecretHit {
|
|
|
18
23
|
file: string;
|
|
19
24
|
line: number;
|
|
20
25
|
masked: string;
|
|
26
|
+
/**
|
|
27
|
+
* Why this hit should warn rather than block: the file is an example (`.env.example`, `*.sample`,
|
|
28
|
+
* `*.template`) or the value is an obvious stand-in (default password, local host, `xxx`, `your_`).
|
|
29
|
+
* Absent for a real-looking credential.
|
|
30
|
+
*/
|
|
31
|
+
downgrade?: "example file" | "placeholder value";
|
|
21
32
|
}
|
|
22
33
|
/** Every credential-shaped value in a text, one hit per line, masked. */
|
|
23
34
|
export declare function findSecrets(text: string, file: string, limit?: number): SecretHit[];
|
|
@@ -29,3 +40,31 @@ export declare const ENV_FILES: RegExp;
|
|
|
29
40
|
/** Source files worth scanning for text patterns; everything else is skipped as binary or generated. */
|
|
30
41
|
export declare const TEXT_FILE: RegExp;
|
|
31
42
|
export declare const SKIP_DIR: RegExp;
|
|
43
|
+
/** Advisory severity as the catalog names it. "unknown" when the database published none. */
|
|
44
|
+
export type AdvisorySeverity = "critical" | "high" | "moderate" | "low" | "unknown";
|
|
45
|
+
export declare function normalizeSeverity(raw: string | undefined | null): AdvisorySeverity;
|
|
46
|
+
/** The parts of an OSV vulnerability record that carry a severity. Matches osv-scanner's JSON and GET /v1/vulns/{id}. */
|
|
47
|
+
export interface OsvVulnerability {
|
|
48
|
+
id?: string;
|
|
49
|
+
database_specific?: {
|
|
50
|
+
severity?: string;
|
|
51
|
+
} | undefined;
|
|
52
|
+
severity?: {
|
|
53
|
+
type?: string;
|
|
54
|
+
score?: string;
|
|
55
|
+
}[] | undefined;
|
|
56
|
+
affected?: {
|
|
57
|
+
ecosystem_specific?: {
|
|
58
|
+
severity?: string;
|
|
59
|
+
};
|
|
60
|
+
}[] | undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Severity of one OSV record: `database_specific.severity` (what GHSA publishes: CRITICAL, HIGH,
|
|
64
|
+
* MODERATE, LOW), else any `affected[].ecosystem_specific.severity`, else the CVSS 3.x base score
|
|
65
|
+
* computed from the vector in `severity[]`. The OSV batch query (`/v1/querybatch`) returns only ids;
|
|
66
|
+
* fetch `/v1/vulns/{id}` to get these fields.
|
|
67
|
+
*/
|
|
68
|
+
export declare function osvSeverity(v: OsvVulnerability): AdvisorySeverity;
|
|
69
|
+
/** CVSS v3.0/3.1 base score from a vector string, or null when the vector is malformed. */
|
|
70
|
+
export declare function cvss3BaseScore(vector: string): number | null;
|
package/dist/gates/text.js
CHANGED
|
@@ -19,13 +19,34 @@ export const SECRET_PATTERNS = [
|
|
|
19
19
|
{ rule: "private-key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/g },
|
|
20
20
|
{ rule: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
21
21
|
{ rule: "nerdsystem-token", re: /\bn(sk|as|bt|rt|ss|pr|iv)_[a-z0-9]{40}\b/g },
|
|
22
|
-
{ rule: "db-url-with-password", re: /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s/]{4,}
|
|
22
|
+
{ rule: "db-url-with-password", re: /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s/]{4,}@[^/\s:?"'`]+/g },
|
|
23
23
|
{ rule: "generic-assignment", re: /\b(api[_-]?key|secret|password|passwd|token|client[_-]?secret)\b\s*[:=]\s*["'`]([A-Za-z0-9+/_=.-]{24,})["'`]/gi },
|
|
24
24
|
];
|
|
25
25
|
const ALLOW = [/example/i, /placeholder/i, /your[-_ ]?key/i, /xxxx/i, /\$\{/, /process\.env/, /c\.env\./, /import\.meta\.env/, /nsk_\.\.\./];
|
|
26
26
|
export function isAllowedLine(line) {
|
|
27
27
|
return ALLOW.some((a) => a.test(line));
|
|
28
28
|
}
|
|
29
|
+
/** Files that exist to show the shape of a secret, not to hold one. Findings in them warn instead of block. */
|
|
30
|
+
export const EXAMPLE_FILE = /(\.(example|sample|template)|(^|\/)\.env\.(example|sample))$/i;
|
|
31
|
+
export function isExampleFile(file) {
|
|
32
|
+
return EXAMPLE_FILE.test(file);
|
|
33
|
+
}
|
|
34
|
+
/** Passwords nobody would use for real: the defaults every tutorial and docker-compose file carries. */
|
|
35
|
+
const PLACEHOLDER_PASSWORDS = new Set(["root", "password", "pass", "secret", "changeme", "example", "test", "your-password", "your_password", "yourpassword", "postgres", "mysql", "admin"]);
|
|
36
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", "host.docker.internal"]);
|
|
37
|
+
const PLACEHOLDER_VALUE = /xxx|your[_-]|example|changeme|placeholder|<|>|\.\.\./i;
|
|
38
|
+
/** True when a matched credential is clearly a stand-in: a placeholder token, or a database URL with a default password or a local host. */
|
|
39
|
+
export function isPlaceholderValue(rule, value) {
|
|
40
|
+
if (rule === "db-url-with-password") {
|
|
41
|
+
const m = /:\/\/([^:]+):([^@]+)@([^/\s:?"'`]+)/.exec(value);
|
|
42
|
+
if (!m)
|
|
43
|
+
return false;
|
|
44
|
+
const password = m[2].toLowerCase();
|
|
45
|
+
const host = m[3].toLowerCase();
|
|
46
|
+
return PLACEHOLDER_PASSWORDS.has(password) || LOCAL_HOSTS.has(host) || PLACEHOLDER_VALUE.test(password);
|
|
47
|
+
}
|
|
48
|
+
return PLACEHOLDER_VALUE.test(value);
|
|
49
|
+
}
|
|
29
50
|
/** Type and last four characters only: "sk_live_****4f2a". Never the value. */
|
|
30
51
|
export function mask(match) {
|
|
31
52
|
const compact = match.replace(/\s+/g, "");
|
|
@@ -55,7 +76,8 @@ export function findSecrets(text, file, limit = 50) {
|
|
|
55
76
|
continue;
|
|
56
77
|
hitLines.add(line);
|
|
57
78
|
const matched = p.rule === "generic-assignment" ? (m[2] ?? m[0]) : m[0];
|
|
58
|
-
|
|
79
|
+
const downgrade = isExampleFile(file) ? "example file" : p.rule !== "private-key" && isPlaceholderValue(p.rule, matched) ? "placeholder value" : undefined;
|
|
80
|
+
hits.push({ rule: p.rule, file, line, masked: p.rule === "private-key" ? "private key" : mask(matched), ...(downgrade ? { downgrade } : {}) });
|
|
59
81
|
if (hits.length >= limit)
|
|
60
82
|
return hits;
|
|
61
83
|
}
|
|
@@ -70,3 +92,61 @@ export const ENV_FILES = /(^|\/)(\.env(\.[A-Za-z0-9_-]+)?|\.dev\.vars(\.[A-Za-z0
|
|
|
70
92
|
/** Source files worth scanning for text patterns; everything else is skipped as binary or generated. */
|
|
71
93
|
export const TEXT_FILE = /(\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|toml|yaml|yml|env|vars|md|html|css|sql|py|rb|go|rs|java|kt|swift|php|sh|txt|cfg|ini|conf|xml)|(^|\/)(README|LICENSE|Dockerfile|Makefile|Procfile|\.gitignore|\.npmrc))$/i;
|
|
72
94
|
export const SKIP_DIR = /(^|\/)(node_modules|dist|build|\.git|\.wrangler|\.next|\.nuxt|coverage|vendor|target)(\/|$)/;
|
|
95
|
+
export function normalizeSeverity(raw) {
|
|
96
|
+
const s = (raw ?? "").trim().toLowerCase();
|
|
97
|
+
if (s === "critical")
|
|
98
|
+
return "critical";
|
|
99
|
+
if (s === "high")
|
|
100
|
+
return "high";
|
|
101
|
+
if (s === "moderate" || s === "medium")
|
|
102
|
+
return "moderate";
|
|
103
|
+
if (s === "low" || s === "info" || s === "informational")
|
|
104
|
+
return "low";
|
|
105
|
+
return "unknown";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Severity of one OSV record: `database_specific.severity` (what GHSA publishes: CRITICAL, HIGH,
|
|
109
|
+
* MODERATE, LOW), else any `affected[].ecosystem_specific.severity`, else the CVSS 3.x base score
|
|
110
|
+
* computed from the vector in `severity[]`. The OSV batch query (`/v1/querybatch`) returns only ids;
|
|
111
|
+
* fetch `/v1/vulns/{id}` to get these fields.
|
|
112
|
+
*/
|
|
113
|
+
export function osvSeverity(v) {
|
|
114
|
+
const direct = normalizeSeverity(v.database_specific?.severity);
|
|
115
|
+
if (direct !== "unknown")
|
|
116
|
+
return direct;
|
|
117
|
+
for (const a of v.affected ?? []) {
|
|
118
|
+
const s = normalizeSeverity(a.ecosystem_specific?.severity);
|
|
119
|
+
if (s !== "unknown")
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
for (const s of v.severity ?? []) {
|
|
123
|
+
if (/^CVSS_V3/i.test(s.type ?? "") && s.score) {
|
|
124
|
+
const score = cvss3BaseScore(s.score);
|
|
125
|
+
if (score !== null)
|
|
126
|
+
return score >= 9 ? "critical" : score >= 7 ? "high" : score >= 4 ? "moderate" : score > 0 ? "low" : "unknown";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return "unknown";
|
|
130
|
+
}
|
|
131
|
+
/** CVSS v3.0/3.1 base score from a vector string, or null when the vector is malformed. */
|
|
132
|
+
export function cvss3BaseScore(vector) {
|
|
133
|
+
const parts = Object.fromEntries(vector.split("/").slice(1).map((kv) => kv.split(":")));
|
|
134
|
+
const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[parts.AV ?? ""];
|
|
135
|
+
const AC = { L: 0.77, H: 0.44 }[parts.AC ?? ""];
|
|
136
|
+
const UI = { N: 0.85, R: 0.62 }[parts.UI ?? ""];
|
|
137
|
+
const CIA = { H: 0.56, L: 0.22, N: 0 };
|
|
138
|
+
const C = CIA[(parts.C ?? "")];
|
|
139
|
+
const I = CIA[(parts.I ?? "")];
|
|
140
|
+
const A = CIA[(parts.A ?? "")];
|
|
141
|
+
const scopeChanged = parts.S === "C";
|
|
142
|
+
const PR = scopeChanged ? { N: 0.85, L: 0.68, H: 0.5 }[parts.PR ?? ""] : { N: 0.85, L: 0.62, H: 0.27 }[parts.PR ?? ""];
|
|
143
|
+
if ([AV, AC, UI, C, I, A, PR].some((x) => x === undefined) || (parts.S !== "C" && parts.S !== "U"))
|
|
144
|
+
return null;
|
|
145
|
+
const iss = 1 - (1 - C) * (1 - I) * (1 - A);
|
|
146
|
+
const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
|
|
147
|
+
const exploitability = 8.22 * AV * AC * PR * UI;
|
|
148
|
+
if (impact <= 0)
|
|
149
|
+
return 0;
|
|
150
|
+
const roundUp = (x) => Math.ceil(x * 10 - 1e-9) / 10;
|
|
151
|
+
return scopeChanged ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) : roundUp(Math.min(impact + exploitability, 10));
|
|
152
|
+
}
|
package/dist/gates/types.d.ts
CHANGED
|
@@ -70,3 +70,15 @@ export declare function result(gate: Gate, status: GateStatus, summary: string,
|
|
|
70
70
|
/** Status from findings: any block = fail, any warn = warn, else pass. */
|
|
71
71
|
export declare function verdict(findings: Finding[]): GateStatus;
|
|
72
72
|
export declare function counts(findings: Finding[]): string;
|
|
73
|
+
export interface SkippedGate {
|
|
74
|
+
name: string;
|
|
75
|
+
title: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
}
|
|
78
|
+
/** Gates that did not run this time (a scanner is not installed, no build output), so "passed" is never claimed for them. */
|
|
79
|
+
export declare function skippedGates(results: GateResult[]): SkippedGate[];
|
|
80
|
+
/**
|
|
81
|
+
* One honest line: "7 gates passed, 2 skipped (semgrep, gitleaks not installed; install them or deploy from git so the builder runs them)".
|
|
82
|
+
* Never "All gates passed" when something did not run.
|
|
83
|
+
*/
|
|
84
|
+
export declare function gateSummary(results: GateResult[]): string;
|
package/dist/gates/types.js
CHANGED
|
@@ -16,3 +16,21 @@ export function counts(findings) {
|
|
|
16
16
|
return "No findings";
|
|
17
17
|
return [b ? `${b} blocking` : "", w ? `${w} advisory` : "", findings.length - b - w ? `${findings.length - b - w} informational` : ""].filter(Boolean).join(", ");
|
|
18
18
|
}
|
|
19
|
+
/** Gates that did not run this time (a scanner is not installed, no build output), so "passed" is never claimed for them. */
|
|
20
|
+
export function skippedGates(results) {
|
|
21
|
+
return results.filter((r) => r.status === "skip").map((r) => ({ name: r.name, title: r.title, reason: r.summary }));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One honest line: "7 gates passed, 2 skipped (semgrep, gitleaks not installed; install them or deploy from git so the builder runs them)".
|
|
25
|
+
* Never "All gates passed" when something did not run.
|
|
26
|
+
*/
|
|
27
|
+
export function gateSummary(results) {
|
|
28
|
+
const skipped = skippedGates(results);
|
|
29
|
+
const passed = results.filter((r) => r.status === "pass" || r.status === "warn").length;
|
|
30
|
+
if (!skipped.length)
|
|
31
|
+
return `All ${passed} gates passed`;
|
|
32
|
+
const tools = skipped.filter((s) => /not installed/.test(s.reason)).map((s) => s.name);
|
|
33
|
+
const other = skipped.filter((s) => !/not installed/.test(s.reason)).map((s) => `${s.name}: ${s.reason}`);
|
|
34
|
+
const why = [tools.length ? `${tools.join(", ")} not installed; install them or deploy from git so the builder runs them` : "", ...other].filter(Boolean).join("; ");
|
|
35
|
+
return `${passed} gate${passed === 1 ? "" : "s"} passed, ${skipped.length} skipped (${why})`;
|
|
36
|
+
}
|