Skip to content
SlashClone
Made by@shipcheck

Env secrets check

@shipcheck/env-secrets-check

Check your repo for leaked secrets before you push or launch: .env files git tracks or will pick up, .env files hiding in git history, server keys behind NEXT_PUBLIC_ or VITE_ prefixes that ship to the browser, provider key shapes (Stripe, AWS, GitHub, OpenAI, Anthropic, Supabase service_role), and passwords hardcoded in code. Reports names and redacted hints, never the values.

SkillDevOpsv1.0.0 (latest)Tested
Activity
New: be the first to try it
  1. 1#!/usr/bin/env node
  2. 2// Scan a project for committed secrets before you push or launch.
  3. 3// - .env files that git tracks (or would pick up with `git add .`)
  4. 4// - .env files that were committed at any point in git history
  5. 5// - browser-exposed env vars (NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_, ...) that hold server secrets
  6. 6// - provider key shapes (Stripe, AWS, GitHub, OpenAI, Anthropic, Slack, private keys,
  7. 7// database URLs with passwords, Supabase service_role JWTs)
  8. 8// - secret-named variables with a hardcoded value in source code
  9. 9// Values are never printed: reports show the variable name, file, line, and a redacted hint.
  10. 10// Node built-ins only. Uses git when it is available and falls back to .gitignore rules.
  11. 11//
  12. 12// Usage: node scripts/secrets-scan.mjs [project-dir] [--json out/secrets-report.json] [--md out/secrets-report.md]
  13. 14import { execFileSync } from "node:child_process";
  14. 15import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
  15. 16import { basename, dirname, join, relative, sep } from "node:path";
  16. 18const SEVERITY = ["high", "medium", "low"];
  17. 19const SKIP_DIRS = new Set([
  18. 20 ".git",
  19. 21 "node_modules",
  20. 22 ".next",
  21. 23 ".nuxt",
  22. 24 ".svelte-kit",
  23. 25 ".vercel",
  24. 26 ".netlify",
  25. 27 ".turbo",
  26. 28 ".cache",
  27. 29 "dist",
  28. 30 "build",
  29. 31 "coverage",
  30. 32 ".expo",
  31. 33 "vendor",
  32. 34 "out",
  33. 35]);
  34. 36// The kit's own folder is skipped so an installed kit never reports itself.
  35. 37const SKIP_FILES = /(?:^|\/)(?:env-secrets-check\/.*|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|.*\.min\.js|.*\.map)$/;
  36. 38const BINARY_EXT = /\.(?:png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|woff2?|ttf|eot|mp4|mov|mp3|wav|sqlite|db|wasm|lockb)$/i;
  37. 39const MAX_BYTES = 1024 * 1024;
  38. 41const ENV_FILE = /^\.env(?:\..+)?$/;
  39. 42const ENV_TEMPLATE = /^\.env\.(?:example|sample|template|defaults|dist)$/;
  40. 44const PUBLIC_PREFIX = /\b(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_|NUXT_PUBLIC_|GATSBY_|PUBLIC_)([A-Z0-9_]+)\b/g;
  41. 45const PUBLIC_OK =
  42. 46 /(?:ANON_KEY|PUBLISHABLE|PUBLIC_KEY|SITE_URL|APP_URL|BASE_URL|API_URL|SUPABASE_URL|POSTHOG|MAPBOX|SENTRY_DSN|GA_|GTM_|MEASUREMENT_ID|FIREBASE_|ALGOLIA_APP_ID|ALGOLIA_SEARCH|CLERK_PUBLISHABLE|PLAUSIBLE|AMPLITUDE|MIXPANEL|SEGMENT_WRITE_KEY|VAPID_PUBLIC)/;
  43. 47const PUBLIC_HIGH = /(?:SECRET|SERVICE_ROLE|SERVICE_KEY|PRIVATE|PASSWORD|PASSWD|DATABASE_URL|DB_URL|POSTGRES|MONGO|REDIS_URL|WEBHOOK|ADMIN)/;
  44. 48const SERVER_ONLY_PROVIDER =
  45. 49 /(?:OPENAI|ANTHROPIC|CLAUDE|GEMINI|GROQ|MISTRAL|REPLICATE|COHERE|RESEND|SENDGRID|POSTMARK|MAILGUN|TWILIO|GITHUB|AWS|STRIPE_(?:KEY|SK)|ELEVENLABS|PINECONE|UPSTASH|NOTION|AIRTABLE|SLACK)/;
  46. 51// Provider key shapes.
  47. 52const PROVIDERS = [
  48. 53 { rule: "stripe-live-key", severity: "high", re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g, what: "Stripe live secret key" },
  49. 54 { rule: "stripe-webhook-secret", severity: "high", re: /\bwhsec_[0-9a-zA-Z]{24,}\b/g, what: "Stripe webhook signing secret" },
  50. 55 { rule: "stripe-test-key", severity: "low", re: /\b(?:sk|rk)_test_[0-9a-zA-Z]{24,}\b/g, what: "Stripe test secret key" },
  51. 56 { rule: "aws-access-key", severity: "high", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, what: "AWS access key ID" },
  52. 57 { rule: "github-token", severity: "high", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g, what: "GitHub token" },
  53. 58 { rule: "anthropic-key", severity: "high", re: /\bsk-ant-[a-z]+\d*-[A-Za-z0-9_-]{30,}/g, what: "Anthropic API key" },
  54. 59 { rule: "openai-key", severity: "high", re: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{30,}/g, what: "OpenAI API key" },
  55. 60 { rule: "slack-token", severity: "high", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g, what: "Slack token" },
  56. 61 { rule: "google-api-key", severity: "medium", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, what: "Google API key (often public for Firebase; restrict it by domain)" },
  57. 62 { rule: "private-key", severity: "high", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g, what: "private key" },
  58. 63 {
  59. 64 rule: "database-url-password",
  60. 65 severity: "high",
  61. 66 re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?):\/\/[^\s:/@"'`]+:[^\s@/"'`]+@[^\s"'`]+/g,
  62. 67 what: "database URL with a password",
  63. 68 },
  64. 69];
  65. 70const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
  66. 72const SECRET_NAME = /(?:secret|password|passwd|pwd|api_?key|apikey|auth_?token|access_?token|private_?key|service_?role)/i;
  67. 73const ASSIGN =
  68. 74 /\b([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z<>[\]| ]+)?\s*[:=]\s*(["'`])([^"'`\s]{8,})\2/g;
  69. 75const PLACEHOLDER =
  70. 76 /^(?:your[-_]|<|\$\{|process\.env|import\.meta|xxx|\*\*\*|changeme|change[-_]me|replace[-_]?me|placeholder|example|dummy|todo|none|null|undefined|sk_test_x+|pk_)|example|placeholder|your[-_]|x{6,}/i;
  71. 78function parseArgs(argv) {
  72. 79 const args = { root: ".", json: null, md: null };
  73. 80 const rest = [];
  74. 81 for (let i = 0; i < argv.length; i++) {
  75. 82 const a = argv[i];
  76. 83 if (a === "--json") args.json = argv[++i];
  77. 84 else if (a === "--md") args.md = argv[++i];
  78. 85 else if (a === "-h" || a === "--help") {
  79. 86 console.log("Usage: node scripts/secrets-scan.mjs [project-dir] [--json file] [--md file]");
  80. 87 process.exit(0);
  81. 88 } else rest.push(a);
  82. 89 }
  83. 90 if (rest[0]) args.root = rest[0];
  84. 91 return args;
  85. 94function git(root, args) {
  86. 95 try {
  87. 96 return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
  88. 97 } catch {
  89. 98 return null;
  90. 99 }
  91. 102// Minimal .gitignore support for projects without git (or without git installed).
  92. 103function gitignoreMatcher(root) {
  93. 104 const rules = [];
  94. 105 const file = join(root, ".gitignore");
  95. 106 if (existsSync(file)) {
  96. 107 for (let line of readFileSync(file, "utf8").split(/\r?\n/)) {
  97. 108 line = line.trim();
  98. 109 if (!line || line.startsWith("#")) continue;
  99. 110 const negate = line.startsWith("!");
  100. 111 if (negate) line = line.slice(1);
  101. 112 const dirOnly = line.endsWith("/");
  102. 113 if (dirOnly) line = line.slice(0, -1);
  103. 114 const anchored = line.startsWith("/") || line.includes("/");
  104. 115 line = line.replace(/^\//, "");
  105. 116 let src = "";
  106. 117 for (let i = 0; i < line.length; i++) {
  107. 118 const c = line[i];
  108. 119 if (c === "*" && line[i + 1] === "*") {
  109. 120 src += ".*";
  110. 121 i++;
  111. 122 if (line[i + 1] === "/") i++;
  112. 123 } else if (c === "*") src += "[^/]*";
  113. 124 else if (c === "?") src += "[^/]";
  114. 125 else src += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
  115. 126 }
  116. 127 const re = anchored ? new RegExp(`^${src}(?:/.*)?$`) : new RegExp(`(?:^|/)${src}(?:/.*)?$`);
  117. 128 rules.push({ re, negate, dirOnly });
  118. 129 }
  119. 130 }
  120. 131 return {
  121. 132 hasRules: rules.length > 0,
  122. 133 ignored(path) {
  123. 134 let result = false;
  124. 135 for (const r of rules) if (r.re.test(path)) result = !r.negate;
  125. 136 return result;
  126. 137 },
  127. 138 };
  128. 141function walk(root, dir = root, out = []) {
  129. 142 for (const entry of readdirSync(dir, { withFileTypes: true })) {
  130. 143 const full = join(dir, entry.name);
  131. 144 if (entry.isDirectory()) {
  132. 145 if (!SKIP_DIRS.has(entry.name)) walk(root, full, out);
  133. 146 } else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
  134. 147 }
  135. 148 return out;
  136. 151function listFiles(root) {
  137. 152 const tracked = git(root, ["ls-files", "-z"]);
  138. 153 if (tracked !== null) {
  139. 154 const untracked = git(root, ["ls-files", "-z", "--others", "--exclude-standard"]) ?? "";
  140. 155 const ignoredEnv = git(root, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ".env*", "*/.env*"]) ?? "";
  141. 156 const history = git(root, ["log", "--all", "--diff-filter=A", "--name-only", "--relative", "--format=", "--", ".env", ".env.*", "*/.env", "*/.env.*"]) ?? "";
  142. 157 const split = (s) => s.split("\0").filter(Boolean);
  143. 158 return {
  144. 159 mode: "git",
  145. 160 tracked: new Set(split(tracked)),
  146. 161 untracked: new Set(split(untracked)),
  147. 162 ignoredEnv: new Set(split(ignoredEnv)),
  148. 163 history: new Set(history.split("\n").map((l) => l.trim()).filter(Boolean)),
  149. 164 hasGitignore: existsSync(join(root, ".gitignore")),
  150. 165 };
  151. 166 }
  152. 167 const matcher = gitignoreMatcher(root);
  153. 168 const all = walk(root);
  154. 169 return {
  155. 170 mode: "files",
  156. 171 tracked: new Set(),
  157. 172 untracked: new Set(all.filter((f) => !matcher.ignored(f))),
  158. 173 ignoredEnv: new Set(all.filter((f) => matcher.ignored(f) && ENV_FILE.test(basename(f)))),
  159. 174 history: new Set(),
  160. 175 hasGitignore: matcher.hasRules,
  161. 176 };
  162. 179function redact(value) {
  163. 180 const shown = value.slice(0, Math.min(6, Math.floor(value.length / 4)));
  164. 181 return `${shown}... (${value.length} chars)`;
  165. 184function jwtRole(token) {
  166. 185 try {
  167. 186 const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
  168. 187 return typeof payload.role === "string" ? payload.role : null;
  169. 188 } catch {
  170. 189 return null;
  171. 190 }
  172. 193export function scan(root) {
  173. 194 const files = listFiles(root);
  174. 195 const findings = [];
  175. 196 const add = (f) => findings.push(f);
  176. 197 const committed = (f) => files.tracked.has(f);
  177. 199 // 1. env files
  178. 200 const envSeen = new Set();
  179. 201 for (const f of [...files.tracked, ...files.untracked]) {
  180. 202 const name = basename(f);
  181. 203 if (!ENV_FILE.test(name) || ENV_TEMPLATE.test(name) || envSeen.has(f)) continue;
  182. 204 envSeen.add(f);
  183. 205 if (committed(f))
  184. 206 add({
  185. 207 severity: "high",
  186. 208 rule: "env-file-committed",
  187. 209 file: f,
  188. 210 line: 0,
  189. 211 message: "This env file is committed to git. Anyone with the repo has every value in it.",
  190. 212 fix: `git rm --cached ${f}, add it to .gitignore, then rotate every key in it.`,
  191. 213 });
  192. 214 else
  193. 215 add({
  194. 216 severity: "high",
  195. 217 rule: "env-file-not-ignored",
  196. 218 file: f,
  197. 219 line: 0,
  198. 220 message: "This env file is not in .gitignore, so the next `git add .` commits it.",
  199. 221 fix: "Add .env* (and !.env.example) to .gitignore.",
  200. 222 });
  201. 223 }
  202. 224 for (const f of files.history) {
  203. 225 const name = basename(f);
  204. 226 if (!ENV_FILE.test(name) || ENV_TEMPLATE.test(name)) continue;
  205. 227 if (committed(f)) continue;
  206. 228 add({
  207. 229 severity: "high",
  208. 230 rule: "env-file-in-history",
  209. 231 file: f,
  210. 232 line: 0,
  211. 233 message: "This env file was committed in the past. Deleting it later does not remove it from git history.",
  212. 234 fix: "Rotate every key that was in it. Rewriting history is optional; rotating is not.",
  213. 235 });
  214. 236 }
  215. 237 if (!files.hasGitignore)
  216. 238 add({
  217. 239 severity: "medium",
  218. 240 rule: "no-gitignore",
  219. 241 file: ".gitignore",
  220. 242 line: 0,
  221. 243 message: "No .gitignore with rules. Env files, build output, and node_modules can all end up committed.",
  222. 244 fix: "Create .gitignore with at least: .env*, !.env.example, node_modules, .next, .vercel",
  223. 245 });
  224. 247 // 2. file contents: tracked and about-to-be-tracked files, plus local env files for public-prefix checks only
  225. 248 const contentFiles = new Set([...files.tracked, ...files.untracked]);
  226. 249 const localEnv = new Set([...files.ignoredEnv]);
  227. 250 for (const f of [...contentFiles, ...localEnv]) {
  228. 251 if (SKIP_FILES.test(f) || BINARY_EXT.test(f)) continue;
  229. 252 const full = join(root, f);
  230. 253 let text;
  231. 254 try {
  232. 255 if (!statSync(full).isFile() || statSync(full).size > MAX_BYTES) continue;
  233. 256 text = readFileSync(full, "utf8");
  234. 257 } catch {
  235. 258 continue;
  236. 259 }
  237. 260 if (text.includes("\0")) continue;
  238. 261 const ignoredLocal = localEnv.has(f) && !contentFiles.has(f);
  239. 262 const isEnv = ENV_FILE.test(basename(f));
  240. 263 const isDoc = /\.(?:md|mdx|txt|rst)$/i.test(f);
  241. 264 const lines = text.split(/\r?\n/);
  242. 265 lines.forEach((line, i) => {
  243. 266 const lineNo = i + 1;
  244. 267 // Browser-exposed names: these leak at build time even from an ignored .env.local.
  245. 268 for (const m of isDoc ? [] : line.matchAll(PUBLIC_PREFIX)) {
  246. 269 const [full, prefix, rest] = m;
  247. 270 if (PUBLIC_OK.test(rest)) continue;
  248. 271 let severity = null;
  249. 272 if (PUBLIC_HIGH.test(rest)) severity = "high";
  250. 273 else if (SERVER_ONLY_PROVIDER.test(rest) && /(?:KEY|TOKEN|SECRET)/.test(rest)) severity = "high";
  251. 274 else if (/(?:API_KEY|_TOKEN|_KEY$)/.test(rest)) severity = "low";
  252. 275 if (!severity) continue;
  253. 276 if (findings.some((x) => x.rule === "public-env-secret" && x.target === full && x.file === f)) continue;
  254. 277 add({
  255. 278 severity,
  256. 279 rule: "public-env-secret",
  257. 280 target: full,
  258. 281 file: f,
  259. 282 line: lineNo,
  260. 283 message:
  261. 284 severity === "high"
  262. 285 ? `${full} is bundled into the browser (the ${prefix} prefix does that). Anyone can read it in the page source.`
  263. 286 : `${full} is bundled into the browser. Fine only if this key is meant to be public.`,
  264. 287 fix: `Rename to ${rest} (no ${prefix} prefix), read it only in server code, and rotate it if it was ever deployed.`,
  265. 288 });
  266. 289 }
  267. 290 if (ignoredLocal) return; // values in ignored local env files are where secrets belong
  268. 292 for (const p of PROVIDERS) {
  269. 293 for (const m of line.matchAll(p.re)) {
  270. 294 add({
  271. 295 severity: p.severity,
  272. 296 rule: p.rule,
  273. 297 file: f,
  274. 298 line: lineNo,
  275. 299 hint: redact(m[0]),
  276. 300 message:
  277. 301 p.severity === "low"
  278. 302 ? `Found a ${p.what}. It cannot move real money, but keys belong in env vars, not code.`
  279. 303 : `Found a ${p.what} ${committed(f) ? "in a committed file" : "in a file git will commit"}.`,
  280. 304 fix: "Move it to an env var, then rotate it in the provider's dashboard. Assume a committed key is already copied.",
  281. 305 });
  282. 306 }
  283. 307 }
  284. 308 for (const m of line.matchAll(JWT)) {
  285. 309 const role = jwtRole(m[0]);
  286. 310 if (role !== "service_role") continue;
  287. 311 add({
  288. 312 severity: "high",
  289. 313 rule: "supabase-service-role-jwt",
  290. 314 file: f,
  291. 315 line: lineNo,
  292. 316 hint: redact(m[0]),
  293. 317 message: "A Supabase service_role key. It bypasses every RLS policy.",
  294. 318 fix: "Keep it in a server-only env var (no public prefix) and rotate it.",
  295. 319 });
  296. 320 }
  297. 321 if (isEnv) {
  298. 322 const em = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["']?([^"'#\s]*)/.exec(line);
  299. 323 if (em && em[2] && SECRET_NAME.test(em[1]) && !PLACEHOLDER.test(em[2]) && !ENV_TEMPLATE.test(basename(f)))
  300. 324 add({
  301. 325 severity: committed(f) ? "high" : "medium",
  302. 326 rule: "env-value",
  303. 327 target: em[1],
  304. 328 file: f,
  305. 329 line: lineNo,
  306. 330 hint: redact(em[2]),
  307. 331 message: `${em[1]} has a value in an env file git ${committed(f) ? "tracks" : "will pick up"}.`,
  308. 332 fix: "Keep real values only in ignored files (.env.local) and in your host's env settings.",
  309. 333 });
  310. 334 else if (em && em[2] && SECRET_NAME.test(em[1]) && !PLACEHOLDER.test(em[2]) && ENV_TEMPLATE.test(basename(f)))
  311. 335 add({
  312. 336 severity: "medium",
  313. 337 rule: "template-has-value",
  314. 338 target: em[1],
  315. 339 file: f,
  316. 340 line: lineNo,
  317. 341 hint: redact(em[2]),
  318. 342 message: `${em[1]} has what looks like a real value in a template file. Templates should hold empty values or placeholders.`,
  319. 343 fix: `Set it to ${em[1]}= and rotate the value if it was real.`,
  320. 344 });
  321. 345 return;
  322. 346 }
  323. 347 for (const m of line.matchAll(ASSIGN)) {
  324. 348 const [, name, , value] = m;
  325. 349 if (!SECRET_NAME.test(name) || PLACEHOLDER.test(value)) continue;
  326. 350 if (PROVIDERS.some((p) => new RegExp(p.re.source).test(value))) continue; // already reported
  327. 351 if (/^(?:https?:|\/|\.\/|[A-Z_]+$)/.test(value)) continue; // URLs, paths, env var names
  328. 352 add({
  329. 353 severity: "medium",
  330. 354 rule: "hardcoded-secret",
  331. 355 target: name,
  332. 356 file: f,
  333. 357 line: lineNo,
  334. 358 hint: redact(value),
  335. 359 message: `${name} is set to a hardcoded value in code.`,
  336. 360 fix: `Read it from an env var instead, for example process.env.${name.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase()}.`,
  337. 361 });
  338. 362 }
  339. 363 });
  340. 364 }
  341. 366 findings.sort(
  342. 367 (a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || a.file.localeCompare(b.file) || a.line - b.line,
  343. 368 );
  344. 369 const counts = Object.fromEntries(SEVERITY.map((s) => [s, findings.filter((f) => f.severity === s).length]));
  345. 370 return {
  346. 371 summary: { mode: files.mode, files_scanned: contentFiles.size, ...counts },
  347. 372 findings,
  348. 373 };
  349. 376function toMarkdown(report) {
  350. 377 const s = report.summary;
  351. 378 const lines = [
  352. 379 "# Secrets check",
  353. 380 "",
  354. 381 `Scanned ${s.files_scanned} files (${s.mode === "git" ? "git-tracked and new files" : "no git found, used .gitignore rules"}).`,
  355. 382 `High: ${s.high} | Medium: ${s.medium} | Low: ${s.low}`,
  356. 383 "",
  357. 384 ];
  358. 385 for (const sev of SEVERITY) {
  359. 386 const list = report.findings.filter((f) => f.severity === sev);
  360. 387 if (!list.length) continue;
  361. 388 lines.push(`## ${sev[0].toUpperCase()}${sev.slice(1)}`, "");
  362. 389 for (const f of list) {
  363. 390 const where = f.line ? `${f.file}:${f.line}` : f.file;
  364. 391 lines.push(`- **${where}** (${f.rule}${f.hint ? `, ${f.hint}` : ""}): ${f.message}`);
  365. 392 if (f.fix) lines.push(` - Fix: ${f.fix}`);
  366. 393 }
  367. 394 lines.push("");
  368. 395 }
  369. 396 if (!report.findings.length) lines.push("No findings. Nothing secret-shaped is headed for git.", "");
  370. 397 return lines.join("\n");
  371. 400function write(path, text) {
  372. 401 mkdirSync(dirname(path), { recursive: true });
  373. 402 writeFileSync(path, text);
  374. 405function main() {
  375. 406 const args = parseArgs(process.argv.slice(2));
  376. 407 if (!existsSync(args.root) || !statSync(args.root).isDirectory()) {
  377. 408 console.error(`No folder at ${args.root}.`);
  378. 409 process.exit(2);
  379. 410 }
  380. 411 const report = scan(args.root);
  381. 412 const md = toMarkdown(report);
  382. 413 if (args.json) write(args.json, JSON.stringify(report, null, 2) + "\n");
  383. 414 if (args.md) write(args.md, md);
  384. 415 if (!args.json && !args.md) console.log(md);
  385. 416 else console.log(`High ${report.summary.high}, medium ${report.summary.medium}, low ${report.summary.low}.`);
  386. 419main();