bastion-scan 0.1.3 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +45 -16
  2. package/dist/index.js +847 -51
  3. package/package.json +9 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <p align="center">
2
2
  <strong>BASTION</strong><br/>
3
- <em>Security scanner for web projects. Runs locally. Explains what it finds.</em>
3
+ <em>Security scanner for Cursor-generated code. Runs locally. Explains what it finds.</em>
4
4
  </p>
5
5
 
6
6
  <p align="center">
@@ -16,7 +16,7 @@
16
16
 
17
17
  Bastion scans your code for security issues and tells you how to fix them. It runs on your machine, never uploads your code, and works with any Node.js project.
18
18
 
19
- AI tools help you build fast, but they regularly ship hardcoded secrets, missing headers, and injection vectors. Enterprise scanners cost £300+/mo and drown you in jargon. Bastion is the middle ground: it catches the stuff that actually matters and explains it in plain English.
19
+ Cursor (and other AI coding tools) help you build fast, but they regularly ship hardcoded secrets, missing headers, and injection vectors. Enterprise scanners cost £300+/mo and drown you in jargon. Bastion is the middle ground: it catches the stuff that actually matters and explains it in plain English.
20
20
 
21
21
  Every finding comes with a prompt you can paste into Claude, ChatGPT, or Copilot to get a fix tailored to your stack.
22
22
 
@@ -25,10 +25,7 @@ Every finding comes with a prompt you can paste into Claude, ChatGPT, or Copilot
25
25
  ## Quick Start
26
26
 
27
27
  ```bash
28
- # Install globally
29
- npm install -g bastion-scan
30
-
31
- # Scan your project
28
+ # Scan your project (no install required)
32
29
  npx bastion-scan scan
33
30
 
34
31
  # Scan a live URL (headers, SSL, security.txt)
@@ -41,6 +38,35 @@ npx bastion-scan scan --format json
41
38
  npx bastion-scan scan --generate-configs
42
39
  ```
43
40
 
41
+ If you run scans frequently, install globally:
42
+
43
+ ```bash
44
+ npm install -g bastion-scan
45
+ bastion scan
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Tuned for Cursor users
51
+
52
+ Bastion includes detection rules tuned for patterns that Cursor's AI tends to produce: env vars exposed in client components, missing helmet middleware, Supabase service role keys leaked client-side, placeholder auth that looks real but verifies nothing. Other scanners use generic OWASP rules. Bastion knows what Cursor writes.
53
+
54
+ Works with Lovable, Replit, Bolt, v0, and any other AI coding tool too — the checks are universal.
55
+
56
+ ### Cursor users: add this to `.cursorignore` first
57
+
58
+ `.gitignore` stops secrets from being committed, but Cursor's codebase indexer still reads `.gitignore`d files by default. Add a `.cursorignore` file to prevent Cursor from ever seeing your secrets:
59
+
60
+ ```
61
+ .env
62
+ .env.*
63
+ *.pem
64
+ *.key
65
+ secrets/
66
+ ```
67
+
68
+ This single step prevents your keys from leaking into Cursor's context window — and into any AI-generated code that references them.
69
+
44
70
  ---
45
71
 
46
72
  ## What it checks
@@ -59,6 +85,9 @@ npx bastion-scan scan --generate-configs
59
85
  | Rate limiting | Looks for `express-rate-limit`, `@upstash/ratelimit`, etc. |
60
86
  | Auth method | Flags hand-rolled auth, suggests Clerk/Supabase/NextAuth |
61
87
  | `security.txt` URL | Fetches and validates the remote file |
88
+ | Cookie security | Checks `Set-Cookie` flags: `HttpOnly`, `Secure`, `SameSite` |
89
+ | Server disclosure | Flags `Server` headers that leak software versions |
90
+ | DMARC record | Verifies email authentication policy via DNS |
62
91
 
63
92
  ### Stack detection
64
93
 
@@ -124,16 +153,16 @@ The web dashboard lives at [bastion.wiki](https://bastion.wiki).
124
153
  | | Free | Pro | Team |
125
154
  |---|---|---|---|
126
155
  | **Price** | £0 | £4/mo or £39/yr | £15/mo or £119/yr |
127
- | CLI checks | 5 | All 12 | All 12 |
128
- | URL scans | 1/day | Unlimited | Unlimited |
129
- | AI prompts | 3/scan | Unlimited | Unlimited |
130
- | Config generators | | Yes | Yes |
131
- | Security badge | | Yes | Yes |
132
- | GitHub Action | | Public repos | All repos |
156
+ | URL scans | Unlimited | Unlimited | Unlimited |
157
+ | CLI scans | 10/month | Unlimited | Unlimited |
158
+ | AI fix prompts | | Unlimited | Unlimited |
159
+ | Config generators | | Yes | Yes |
160
+ | Security badge | | Yes | Yes |
161
+ | GitHub Action | | Public repos | All repos |
133
162
  | Projects | 1 | 3 | Unlimited |
134
- | Compliance reports | | | Yes |
135
- | CVE alerts | | | Yes |
136
- | Score history | | | Yes |
163
+ | Compliance reports | | | Yes |
164
+ | CVE alerts | | | Yes |
165
+ | Score history | | | Yes |
137
166
 
138
167
  Annual plans save 2 months. All plans come with a 14-day free trial.
139
168
 
@@ -193,7 +222,7 @@ Floor is 0. Only `fail` results deduct. `warn`, `skip`, and `pass` don't affect
193
222
  ```
194
223
  bastion/
195
224
  ├── packages/
196
- │ ├── cli/ # npx bastion-scan scan, 12 checks, 3 reporters
225
+ │ ├── cli/ # npx bastion-scan scan, 15 checks, 3 reporters
197
226
  │ ├── shared/ # Types, checklist data, OWASP data, tools
198
227
  │ └── web/ # Next.js 14 dashboard
199
228
  └── docs/playbooks/ # Stack-specific security guides
package/dist/index.js CHANGED
@@ -5,13 +5,15 @@ import { createRequire } from "module";
5
5
 
6
6
  // src/cli.ts
7
7
  import { Command, Option } from "commander";
8
- import chalk2 from "chalk";
8
+ import chalk3 from "chalk";
9
9
  import ora from "ora";
10
- import { OUTPUT_FORMATS } from "bastion-shared";
10
+
11
+ // ../shared/dist/types.js
12
+ var OUTPUT_FORMATS = ["terminal", "json", "markdown"];
11
13
 
12
14
  // src/scanner.ts
13
- import { readdir, readFile as readFile9, stat } from "fs/promises";
14
- import { resolve, join as join10, relative } from "path";
15
+ import { readdir as readdir3, readFile as readFile13, stat } from "fs/promises";
16
+ import { resolve, join as join14, relative } from "path";
15
17
 
16
18
  // src/checks/gitignore.ts
17
19
  import { readFile } from "fs/promises";
@@ -196,7 +198,7 @@ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
196
198
  ]);
197
199
  var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "tests", "__tests__", "test", "fixtures"]);
198
200
  var SECRET_PATTERNS = [
199
- // ── OpenAI ──────────────────────────────────────────────────────────────
201
+ // -- OpenAI ------------------------------------------------------------------
200
202
  {
201
203
  name: "OpenAI API key (project)",
202
204
  regex: /sk-proj-[a-zA-Z0-9_-]{20,}/,
@@ -215,7 +217,7 @@ var SECRET_PATTERNS = [
215
217
  description: "OpenAI API key detected",
216
218
  severity: "critical"
217
219
  },
218
- // ── Anthropic ───────────────────────────────────────────────────────────
220
+ // -- Anthropic ---------------------------------------------------------------
219
221
  {
220
222
  name: "Anthropic API key",
221
223
  regex: /sk-ant-api[0-9]{2}-[a-zA-Z0-9_-]{40,}/,
@@ -228,7 +230,7 @@ var SECRET_PATTERNS = [
228
230
  description: "Anthropic admin API key detected",
229
231
  severity: "critical"
230
232
  },
231
- // ── GitHub ──────────────────────────────────────────────────────────────
233
+ // -- GitHub ------------------------------------------------------------------
232
234
  {
233
235
  name: "GitHub PAT (classic)",
234
236
  regex: /ghp_[a-zA-Z0-9]{36}/,
@@ -259,7 +261,7 @@ var SECRET_PATTERNS = [
259
261
  description: "GitHub refresh token detected",
260
262
  severity: "critical"
261
263
  },
262
- // ── Stripe ──────────────────────────────────────────────────────────────
264
+ // -- Stripe ------------------------------------------------------------------
263
265
  {
264
266
  name: "Stripe secret key",
265
267
  regex: /sk_live_[a-zA-Z0-9]{24,}/,
@@ -284,21 +286,21 @@ var SECRET_PATTERNS = [
284
286
  description: "Stripe test secret key detected",
285
287
  severity: "high"
286
288
  },
287
- // ── AWS ─────────────────────────────────────────────────────────────────
289
+ // -- AWS ---------------------------------------------------------------------
288
290
  {
289
291
  name: "AWS access key",
290
292
  regex: /AKIA[0-9A-Z]{16}/,
291
293
  description: "AWS access key ID detected",
292
294
  severity: "critical"
293
295
  },
294
- // ── Google ──────────────────────────────────────────────────────────────
296
+ // -- Google ------------------------------------------------------------------
295
297
  {
296
298
  name: "Google API key",
297
299
  regex: /AIza[a-zA-Z0-9_-]{35}/,
298
300
  description: "Google API key detected",
299
301
  severity: "critical"
300
302
  },
301
- // ── Slack ───────────────────────────────────────────────────────────────
303
+ // -- Slack -------------------------------------------------------------------
302
304
  {
303
305
  name: "Slack bot token",
304
306
  regex: /xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,}/,
@@ -317,7 +319,7 @@ var SECRET_PATTERNS = [
317
319
  description: "Slack incoming webhook URL detected",
318
320
  severity: "high"
319
321
  },
320
- // ── Generic ─────────────────────────────────────────────────────────────
322
+ // -- Generic -----------------------------------------------------------------
321
323
  {
322
324
  name: "Generic API key assignment",
323
325
  regex: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"][A-Za-z0-9_\-/.]{8,}['"]/i,
@@ -856,7 +858,7 @@ function isConnectionError(error) {
856
858
  return CONNECTION_ERROR_PATTERNS.some((code) => combined.includes(code));
857
859
  }
858
860
  function delay(ms) {
859
- return new Promise((resolve2) => setTimeout(resolve2, ms));
861
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
860
862
  }
861
863
  async function fetchWithRetry(url, init) {
862
864
  for (let attempt = 1; attempt <= 2; attempt++) {
@@ -1154,7 +1156,7 @@ var CONNECTION_ERRORS = /* @__PURE__ */ new Set([
1154
1156
  "EHOSTUNREACH"
1155
1157
  ]);
1156
1158
  function verifyCertificate(hostname, port) {
1157
- return new Promise((resolve2, reject) => {
1159
+ return new Promise((resolve3, reject) => {
1158
1160
  let settled = false;
1159
1161
  const socket = tlsConnect(
1160
1162
  { host: hostname, port, servername: hostname, rejectUnauthorized: true },
@@ -1162,7 +1164,7 @@ function verifyCertificate(hostname, port) {
1162
1164
  if (settled) return;
1163
1165
  settled = true;
1164
1166
  socket.destroy();
1165
- resolve2();
1167
+ resolve3();
1166
1168
  }
1167
1169
  );
1168
1170
  socket.setTimeout(TIMEOUT_MS);
@@ -1181,7 +1183,7 @@ function verifyCertificate(hostname, port) {
1181
1183
  });
1182
1184
  }
1183
1185
  function checkHttpsRedirect(hostname) {
1184
- return new Promise((resolve2) => {
1186
+ return new Promise((resolve3) => {
1185
1187
  let settled = false;
1186
1188
  const req = httpRequest(
1187
1189
  { hostname, port: 80, method: "HEAD", path: "/" },
@@ -1191,27 +1193,27 @@ function checkHttpsRedirect(hostname) {
1191
1193
  const status = res.statusCode ?? 0;
1192
1194
  const location = res.headers.location ?? "";
1193
1195
  const isRedirect = status >= 300 && status < 400;
1194
- resolve2(isRedirect && location.startsWith("https://"));
1196
+ resolve3(isRedirect && location.startsWith("https://"));
1195
1197
  }
1196
1198
  );
1197
1199
  req.setTimeout(TIMEOUT_MS);
1198
1200
  req.on("timeout", () => {
1199
1201
  if (settled) return;
1200
1202
  settled = true;
1201
- resolve2(false);
1203
+ resolve3(false);
1202
1204
  req.destroy();
1203
1205
  });
1204
1206
  req.on("error", () => {
1205
1207
  if (settled) return;
1206
1208
  settled = true;
1207
- resolve2(false);
1209
+ resolve3(false);
1208
1210
  });
1209
1211
  req.end();
1210
1212
  });
1211
1213
  }
1212
1214
  var RETRY_DELAY_MS2 = 2e3;
1213
1215
  function delay2(ms) {
1214
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1216
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1215
1217
  }
1216
1218
  async function verifyCertificateWithRetry(hostname, port) {
1217
1219
  try {
@@ -1983,7 +1985,7 @@ var auth_default = authCheck;
1983
1985
  var CHECK_ID8 = "cookies";
1984
1986
  var CATEGORY4 = "cookies";
1985
1987
  function parseCookie(raw) {
1986
- const name = raw.split("=")[0].trim();
1988
+ const name = (raw.split("=")[0] ?? "").trim();
1987
1989
  const lower = raw.toLowerCase();
1988
1990
  return {
1989
1991
  name,
@@ -2236,7 +2238,7 @@ var dmarcCheck = async (context) => {
2236
2238
  }];
2237
2239
  }
2238
2240
  const policyMatch = dmarc.match(/;\s*p\s*=\s*(reject|quarantine|none)/i);
2239
- const policy = policyMatch ? policyMatch[1].toLowerCase() : "unknown";
2241
+ const policy = policyMatch?.[1]?.toLowerCase() ?? "unknown";
2240
2242
  if (policy === "reject") {
2241
2243
  return [{
2242
2244
  id: CHECK_ID10,
@@ -2272,6 +2274,593 @@ var dmarcCheck = async (context) => {
2272
2274
  };
2273
2275
  var dmarc_default = dmarcCheck;
2274
2276
 
2277
+ // src/checks/supply-chain/ignore-scripts.ts
2278
+ import { readFile as readFile9 } from "fs/promises";
2279
+ import { join as join10 } from "path";
2280
+ var CHECK_ID11 = "ignore-scripts";
2281
+ var CHECK_NAME4 = "ignore-scripts protection";
2282
+ var CATEGORY7 = "supply-chain";
2283
+ function parseNpmrc(content) {
2284
+ const result = {};
2285
+ for (const line of content.split(/\r?\n/)) {
2286
+ const trimmed = line.trim();
2287
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
2288
+ continue;
2289
+ }
2290
+ const eqIndex = trimmed.indexOf("=");
2291
+ if (eqIndex === -1) continue;
2292
+ const key = trimmed.slice(0, eqIndex).trim();
2293
+ const value = trimmed.slice(eqIndex + 1).trim();
2294
+ if (key) {
2295
+ result[key] = value;
2296
+ }
2297
+ }
2298
+ return result;
2299
+ }
2300
+ var ignoreScriptsCheck = async (context) => {
2301
+ let content;
2302
+ try {
2303
+ content = await readFile9(join10(context.projectPath, ".npmrc"), "utf-8");
2304
+ } catch (error) {
2305
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2306
+ if (isNotFound) {
2307
+ return [
2308
+ {
2309
+ id: CHECK_ID11,
2310
+ name: CHECK_NAME4,
2311
+ status: "warn",
2312
+ severity: "medium",
2313
+ category: CATEGORY7,
2314
+ location: ".npmrc",
2315
+ description: "No .npmrc file found. Without ignore-scripts=true, malicious postinstall scripts in dependencies will execute during npm install. This was the primary attack vector for Shai-Hulud and similar worm-style attacks.",
2316
+ fix: "Create .npmrc in the project root and add:\n ignore-scripts=true",
2317
+ aiPrompt: "My project has no .npmrc file. Create one with ignore-scripts=true to prevent malicious postinstall scripts from running during npm install. Explain what legitimate postinstall scripts I might need to run manually after installing with this setting enabled."
2318
+ }
2319
+ ];
2320
+ }
2321
+ return [
2322
+ {
2323
+ id: CHECK_ID11,
2324
+ name: CHECK_NAME4,
2325
+ status: "skip",
2326
+ severity: "info",
2327
+ category: CATEGORY7,
2328
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2329
+ }
2330
+ ];
2331
+ }
2332
+ const config = parseNpmrc(content);
2333
+ if (config["ignore-scripts"] === "true") {
2334
+ return [
2335
+ {
2336
+ id: CHECK_ID11,
2337
+ name: CHECK_NAME4,
2338
+ status: "pass",
2339
+ severity: "info",
2340
+ category: CATEGORY7,
2341
+ location: ".npmrc",
2342
+ description: "ignore-scripts=true is configured \u2014 postinstall scripts are blocked by default"
2343
+ }
2344
+ ];
2345
+ }
2346
+ return [
2347
+ {
2348
+ id: CHECK_ID11,
2349
+ name: CHECK_NAME4,
2350
+ status: "warn",
2351
+ severity: "medium",
2352
+ category: CATEGORY7,
2353
+ location: ".npmrc",
2354
+ description: "Project does not have ignore-scripts=true configured. Without this setting, malicious postinstall scripts in dependencies will execute during npm install. This was the primary attack vector for Shai-Hulud and similar worm-style attacks.",
2355
+ fix: "Edit .npmrc in the project root and add:\n ignore-scripts=true",
2356
+ aiPrompt: "My project .npmrc does not have ignore-scripts=true. Update it to add this setting. Explain what legitimate postinstall scripts I might need to run manually after installing with this setting enabled, and how to allowlist specific packages if needed."
2357
+ }
2358
+ ];
2359
+ };
2360
+ var ignore_scripts_default = ignoreScriptsCheck;
2361
+
2362
+ // src/checks/supply-chain/compromised-deps.ts
2363
+ import { readFile as readFile10 } from "fs/promises";
2364
+ import { join as join11 } from "path";
2365
+ import { satisfies } from "semver";
2366
+
2367
+ // src/data/compromised-packages.ts
2368
+ var COMPROMISED_PACKAGES = [
2369
+ // 1. event-stream -- cryptocurrency wallet theft (2018)
2370
+ {
2371
+ name: "event-stream",
2372
+ versionRange: "=3.3.6",
2373
+ advisoryId: "GHSA-mh6f-8j2x-4483",
2374
+ source: "manual",
2375
+ dateAdded: "2026-05-23",
2376
+ description: "Backdoor injected via flatmap-stream dependency targeting Copay bitcoin wallet private keys."
2377
+ },
2378
+ // 2. ua-parser-js -- account takeover, cryptominer (2021)
2379
+ {
2380
+ name: "ua-parser-js",
2381
+ versionRange: "=0.7.29 || =0.8.0 || =1.0.0",
2382
+ advisoryId: "GHSA-pjwm-rvh2-c87w",
2383
+ source: "manual",
2384
+ dateAdded: "2026-05-23",
2385
+ description: "npm account compromised; three versions published with cryptominer and password stealer."
2386
+ },
2387
+ // 3. coa -- credential harvesting (2021)
2388
+ {
2389
+ name: "coa",
2390
+ versionRange: "=2.0.3 || =2.0.4 || =2.1.1 || =2.1.3 || =3.0.1 || =3.1.3",
2391
+ advisoryId: "GHSA-73qr-pfmq-6rp8",
2392
+ source: "manual",
2393
+ dateAdded: "2026-05-23",
2394
+ description: "npm account compromised; six malicious versions published with credential-harvesting payload."
2395
+ },
2396
+ // 4. rc -- credential harvesting (2021, same campaign as coa)
2397
+ {
2398
+ name: "rc",
2399
+ versionRange: "=1.2.9 || =1.3.9 || =2.3.9",
2400
+ advisoryId: "GHSA-g2q5-5433-rhrf",
2401
+ source: "manual",
2402
+ dateAdded: "2026-05-23",
2403
+ description: "npm account compromised in the same campaign as coa; three malicious versions with credential-harvesting payload."
2404
+ },
2405
+ // 5. node-ipc -- destructive protestware (2022)
2406
+ {
2407
+ name: "node-ipc",
2408
+ versionRange: ">=10.1.1 <10.1.3",
2409
+ advisoryId: "GHSA-97m3-w2cp-4xx6",
2410
+ source: "manual",
2411
+ dateAdded: "2026-05-23",
2412
+ description: "Maintainer added code to overwrite files with heart emojis on systems with Russian or Belarusian IP addresses."
2413
+ },
2414
+ // 6. node-ipc -- hidden functionality (2022, same maintainer)
2415
+ {
2416
+ name: "node-ipc",
2417
+ versionRange: "=9.2.2",
2418
+ advisoryId: "GHSA-8gr3-2gjw-jj7g",
2419
+ source: "manual",
2420
+ dateAdded: "2026-05-23",
2421
+ description: "Hidden functionality added by maintainer in a separate version line from the destructive 10.1.x protestware."
2422
+ },
2423
+ // 7. faker -- sabotaged by maintainer (2022)
2424
+ {
2425
+ name: "faker",
2426
+ versionRange: "=6.6.6",
2427
+ advisoryId: "GHSA-5w9c-rv96-fr7g",
2428
+ source: "manual",
2429
+ dateAdded: "2026-05-23",
2430
+ description: "Maintainer replaced all functional code with empty exports as a protest. Still live on npm as latest. Use @faker-js/faker instead."
2431
+ }
2432
+ ];
2433
+
2434
+ // src/checks/supply-chain/compromised-deps.ts
2435
+ var CHECK_ID12 = "compromised-deps";
2436
+ var CHECK_NAME5 = "Compromised package detection";
2437
+ var CATEGORY8 = "supply-chain";
2438
+ function getDirectDeps(packageJson) {
2439
+ const deps = packageJson["dependencies"] ?? {};
2440
+ const devDeps = packageJson["devDependencies"] ?? {};
2441
+ return [...Object.keys(deps), ...Object.keys(devDeps)];
2442
+ }
2443
+ function resolveVersionFromLockfile(lockfile, packageName) {
2444
+ const packages = lockfile["packages"];
2445
+ if (!packages) return void 0;
2446
+ const entry = packages[`node_modules/${packageName}`];
2447
+ if (!entry) return void 0;
2448
+ const version = entry["version"];
2449
+ return typeof version === "string" ? version : void 0;
2450
+ }
2451
+ function findCompromisedMatch(packageName, installedVersion, list = COMPROMISED_PACKAGES) {
2452
+ return list.find(
2453
+ (entry) => entry.name === packageName && satisfies(installedVersion, entry.versionRange)
2454
+ );
2455
+ }
2456
+ var compromisedDepsCheck = async (context) => {
2457
+ if (!context.packageJson) {
2458
+ return [
2459
+ {
2460
+ id: CHECK_ID12,
2461
+ name: CHECK_NAME5,
2462
+ status: "skip",
2463
+ severity: "info",
2464
+ category: CATEGORY8,
2465
+ description: "No package.json found \u2014 skipping compromised dependency check."
2466
+ }
2467
+ ];
2468
+ }
2469
+ let lockfileContent;
2470
+ try {
2471
+ lockfileContent = await readFile10(join11(context.projectPath, "package-lock.json"), "utf-8");
2472
+ } catch (error) {
2473
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2474
+ if (isNotFound) {
2475
+ return [
2476
+ {
2477
+ id: CHECK_ID12,
2478
+ name: CHECK_NAME5,
2479
+ status: "skip",
2480
+ severity: "info",
2481
+ category: CATEGORY8,
2482
+ description: "No package-lock.json found \u2014 skipping compromised dependency check. This check requires an npm lockfile to resolve installed versions."
2483
+ }
2484
+ ];
2485
+ }
2486
+ return [
2487
+ {
2488
+ id: CHECK_ID12,
2489
+ name: CHECK_NAME5,
2490
+ status: "skip",
2491
+ severity: "info",
2492
+ category: CATEGORY8,
2493
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2494
+ }
2495
+ ];
2496
+ }
2497
+ let lockfile;
2498
+ try {
2499
+ lockfile = JSON.parse(lockfileContent);
2500
+ } catch {
2501
+ return [
2502
+ {
2503
+ id: CHECK_ID12,
2504
+ name: CHECK_NAME5,
2505
+ status: "skip",
2506
+ severity: "info",
2507
+ category: CATEGORY8,
2508
+ description: "package-lock.json contains malformed JSON \u2014 cannot check dependencies."
2509
+ }
2510
+ ];
2511
+ }
2512
+ if (COMPROMISED_PACKAGES.length === 0) {
2513
+ return [
2514
+ {
2515
+ id: CHECK_ID12,
2516
+ name: CHECK_NAME5,
2517
+ status: "pass",
2518
+ severity: "info",
2519
+ category: CATEGORY8,
2520
+ description: "Compromised package list is empty \u2014 no packages to check against."
2521
+ }
2522
+ ];
2523
+ }
2524
+ const directDeps = getDirectDeps(context.packageJson);
2525
+ const findings = [];
2526
+ for (const depName of directDeps) {
2527
+ const installedVersion = resolveVersionFromLockfile(lockfile, depName);
2528
+ if (!installedVersion) continue;
2529
+ const match = findCompromisedMatch(depName, installedVersion);
2530
+ if (match) {
2531
+ findings.push({
2532
+ id: CHECK_ID12,
2533
+ name: CHECK_NAME5,
2534
+ status: "fail",
2535
+ severity: "high",
2536
+ category: CATEGORY8,
2537
+ location: `package-lock.json (${depName}@${installedVersion})`,
2538
+ description: `Compromised package detected: ${depName}@${installedVersion}. Match: ${match.versionRange} (${match.source}, ${match.advisoryId}). Date added to list: ${match.dateAdded}. ${match.description}`,
2539
+ fix: `Remove ${depName} or pin to a version outside the compromised range (${match.versionRange}). Run \`npm audit\` for additional details.`,
2540
+ aiPrompt: `My project depends on ${depName}@${installedVersion} which is flagged as compromised (${match.advisoryId}). Help me find a safe alternative or a patched version. Explain what the compromise does and whether my project is affected.`
2541
+ });
2542
+ }
2543
+ }
2544
+ if (findings.length === 0) {
2545
+ return [
2546
+ {
2547
+ id: CHECK_ID12,
2548
+ name: CHECK_NAME5,
2549
+ status: "pass",
2550
+ severity: "info",
2551
+ category: CATEGORY8,
2552
+ description: `No compromised packages found among ${directDeps.length} direct dependencies.`
2553
+ }
2554
+ ];
2555
+ }
2556
+ return findings;
2557
+ };
2558
+ var compromised_deps_default = compromisedDepsCheck;
2559
+
2560
+ // src/checks/supply-chain/npm-ci.ts
2561
+ import { readFile as readFile11, readdir } from "fs/promises";
2562
+ import { join as join12 } from "path";
2563
+ import { parse as parseYaml } from "yaml";
2564
+ var CHECK_ID13 = "npm-ci";
2565
+ var CHECK_NAME6 = "npm ci in CI workflows";
2566
+ var CATEGORY9 = "supply-chain";
2567
+ function isProblematicNpmCommand(cmdLine) {
2568
+ const trimmed = cmdLine.trim();
2569
+ const hasNpmInstall = /\bnpm\s+install\b/.test(trimmed) || /\bnpm\s+i\b/.test(trimmed);
2570
+ if (!hasNpmInstall) return false;
2571
+ if (/\s-g\b/.test(trimmed) || /\s--global\b/.test(trimmed)) return false;
2572
+ return true;
2573
+ }
2574
+ function extractRunCommands(parsedYaml) {
2575
+ if (!parsedYaml || typeof parsedYaml !== "object") return [];
2576
+ const workflow = parsedYaml;
2577
+ const jobs = workflow["jobs"];
2578
+ if (!jobs || typeof jobs !== "object") return [];
2579
+ const results = [];
2580
+ for (const job of Object.values(jobs)) {
2581
+ if (!job || typeof job !== "object") continue;
2582
+ const steps = job["steps"];
2583
+ if (!Array.isArray(steps)) continue;
2584
+ for (const step of steps) {
2585
+ if (!step || typeof step !== "object") continue;
2586
+ const stepObj = step;
2587
+ const run = stepObj["run"];
2588
+ if (typeof run !== "string") continue;
2589
+ const stepName = typeof stepObj["name"] === "string" ? stepObj["name"] : void 0;
2590
+ for (const line of run.split(/\r?\n/)) {
2591
+ const trimmed = line.trim();
2592
+ if (trimmed) {
2593
+ results.push({ stepName, command: trimmed });
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ return results;
2599
+ }
2600
+ var npmCiCheck = async (context) => {
2601
+ const workflowsDir = join12(context.projectPath, ".github", "workflows");
2602
+ let filenames;
2603
+ try {
2604
+ const entries = await readdir(workflowsDir);
2605
+ filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2606
+ } catch (error) {
2607
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2608
+ if (isNotFound) {
2609
+ return [
2610
+ {
2611
+ id: CHECK_ID13,
2612
+ name: CHECK_NAME6,
2613
+ status: "skip",
2614
+ severity: "info",
2615
+ category: CATEGORY9,
2616
+ description: "No .github/workflows/ directory found \u2014 skipping CI workflow check."
2617
+ }
2618
+ ];
2619
+ }
2620
+ return [
2621
+ {
2622
+ id: CHECK_ID13,
2623
+ name: CHECK_NAME6,
2624
+ status: "skip",
2625
+ severity: "info",
2626
+ category: CATEGORY9,
2627
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2628
+ }
2629
+ ];
2630
+ }
2631
+ if (filenames.length === 0) {
2632
+ return [
2633
+ {
2634
+ id: CHECK_ID13,
2635
+ name: CHECK_NAME6,
2636
+ status: "skip",
2637
+ severity: "info",
2638
+ category: CATEGORY9,
2639
+ description: "No workflow files found in .github/workflows/ \u2014 skipping CI workflow check."
2640
+ }
2641
+ ];
2642
+ }
2643
+ const findings = [];
2644
+ for (const filename of filenames) {
2645
+ let content;
2646
+ try {
2647
+ content = await readFile11(join12(workflowsDir, filename), "utf-8");
2648
+ } catch {
2649
+ continue;
2650
+ }
2651
+ let parsed;
2652
+ try {
2653
+ parsed = parseYaml(content);
2654
+ } catch {
2655
+ continue;
2656
+ }
2657
+ const commands = extractRunCommands(parsed);
2658
+ for (const { stepName, command } of commands) {
2659
+ if (isProblematicNpmCommand(command)) {
2660
+ findings.push({
2661
+ id: CHECK_ID13,
2662
+ name: CHECK_NAME6,
2663
+ status: "warn",
2664
+ severity: "medium",
2665
+ category: CATEGORY9,
2666
+ location: `.github/workflows/${filename}`,
2667
+ description: `CI workflow uses \`npm install\` instead of \`npm ci\`: ${filename}. Step: ${stepName ?? "(unnamed)"}. Command: \`${command}\`. npm install can mutate package-lock.json, bypassing lockfile integrity.`,
2668
+ fix: "Replace `npm install` with `npm ci` which enforces lockfile-based installs.",
2669
+ aiPrompt: `My GitHub Actions workflow ${filename} uses \`npm install\` instead of \`npm ci\`. Explain the security implications of npm install mutating the lockfile in CI, and help me update the workflow to use npm ci correctly. Note any cases where npm install might still be needed (e.g., global tool installs).`
2670
+ });
2671
+ }
2672
+ }
2673
+ }
2674
+ if (findings.length === 0) {
2675
+ return [
2676
+ {
2677
+ id: CHECK_ID13,
2678
+ name: CHECK_NAME6,
2679
+ status: "pass",
2680
+ severity: "info",
2681
+ category: CATEGORY9,
2682
+ description: `All ${filenames.length} workflow file(s) use npm ci or have no npm install commands.`
2683
+ }
2684
+ ];
2685
+ }
2686
+ return findings;
2687
+ };
2688
+ var npm_ci_default = npmCiCheck;
2689
+
2690
+ // src/checks/supply-chain/self-hosted-runner.ts
2691
+ import { readFile as readFile12, readdir as readdir2 } from "fs/promises";
2692
+ import { join as join13 } from "path";
2693
+ import { parse as parseYaml2 } from "yaml";
2694
+ var CHECK_ID14 = "self-hosted-runner";
2695
+ var CHECK_NAME7 = "Self-hosted runner detection";
2696
+ var CATEGORY10 = "supply-chain";
2697
+ var GITHUB_HOSTED_REGEX = /^(ubuntu|macos|windows)(-(latest|\d+(\.\d+)?))?$/;
2698
+ var IOC_PATTERNS = ["sha1hulud", "shai-hulud", "sha1-hulud", "hulud"];
2699
+ function categorizeRunner(label) {
2700
+ const trimmed = label.trim();
2701
+ const lower = trimmed.toLowerCase();
2702
+ if (IOC_PATTERNS.some((pattern) => lower.includes(pattern))) {
2703
+ return "ioc-match";
2704
+ }
2705
+ if (lower.includes("self-hosted")) {
2706
+ return "self-hosted";
2707
+ }
2708
+ if (GITHUB_HOSTED_REGEX.test(trimmed)) {
2709
+ return "github-hosted";
2710
+ }
2711
+ return "unknown";
2712
+ }
2713
+ function extractRunsOn(parsedYaml) {
2714
+ if (!parsedYaml || typeof parsedYaml !== "object") return [];
2715
+ const workflow = parsedYaml;
2716
+ const jobs = workflow["jobs"];
2717
+ if (!jobs || typeof jobs !== "object") return [];
2718
+ const results = [];
2719
+ for (const [jobName, job] of Object.entries(jobs)) {
2720
+ if (!job || typeof job !== "object") continue;
2721
+ const runsOn = job["runs-on"];
2722
+ if (!runsOn) continue;
2723
+ if (typeof runsOn === "string") {
2724
+ results.push({ jobName, runsOn: [runsOn] });
2725
+ } else if (Array.isArray(runsOn)) {
2726
+ const labels = runsOn.filter((item) => typeof item === "string");
2727
+ if (labels.length > 0) {
2728
+ results.push({ jobName, runsOn: labels });
2729
+ }
2730
+ }
2731
+ }
2732
+ return results;
2733
+ }
2734
+ var CATEGORY_PRIORITY = {
2735
+ "ioc-match": 3,
2736
+ "self-hosted": 2,
2737
+ "unknown": 1,
2738
+ "github-hosted": 0
2739
+ };
2740
+ function highestCategory(labels) {
2741
+ let highest = "github-hosted";
2742
+ for (const label of labels) {
2743
+ const cat = categorizeRunner(label);
2744
+ if (CATEGORY_PRIORITY[cat] > CATEGORY_PRIORITY[highest]) {
2745
+ highest = cat;
2746
+ }
2747
+ }
2748
+ return highest;
2749
+ }
2750
+ var selfHostedRunnerCheck = async (context) => {
2751
+ const workflowsDir = join13(context.projectPath, ".github", "workflows");
2752
+ let filenames;
2753
+ try {
2754
+ const entries = await readdir2(workflowsDir);
2755
+ filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2756
+ } catch (error) {
2757
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2758
+ if (isNotFound) {
2759
+ return [
2760
+ {
2761
+ id: CHECK_ID14,
2762
+ name: CHECK_NAME7,
2763
+ status: "skip",
2764
+ severity: "info",
2765
+ category: CATEGORY10,
2766
+ description: "No .github/workflows/ directory found \u2014 skipping runner detection."
2767
+ }
2768
+ ];
2769
+ }
2770
+ return [
2771
+ {
2772
+ id: CHECK_ID14,
2773
+ name: CHECK_NAME7,
2774
+ status: "skip",
2775
+ severity: "info",
2776
+ category: CATEGORY10,
2777
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2778
+ }
2779
+ ];
2780
+ }
2781
+ if (filenames.length === 0) {
2782
+ return [
2783
+ {
2784
+ id: CHECK_ID14,
2785
+ name: CHECK_NAME7,
2786
+ status: "skip",
2787
+ severity: "info",
2788
+ category: CATEGORY10,
2789
+ description: "No workflow files found in .github/workflows/ \u2014 skipping runner detection."
2790
+ }
2791
+ ];
2792
+ }
2793
+ const findings = [];
2794
+ for (const filename of filenames) {
2795
+ let content;
2796
+ try {
2797
+ content = await readFile12(join13(workflowsDir, filename), "utf-8");
2798
+ } catch {
2799
+ continue;
2800
+ }
2801
+ let parsed;
2802
+ try {
2803
+ parsed = parseYaml2(content);
2804
+ } catch {
2805
+ continue;
2806
+ }
2807
+ const jobsRunsOn = extractRunsOn(parsed);
2808
+ for (const { jobName, runsOn } of jobsRunsOn) {
2809
+ const category = highestCategory(runsOn);
2810
+ const labelsDisplay = runsOn.join(", ");
2811
+ if (category === "ioc-match") {
2812
+ findings.push({
2813
+ id: CHECK_ID14,
2814
+ name: CHECK_NAME7,
2815
+ status: "fail",
2816
+ severity: "high",
2817
+ category: CATEGORY10,
2818
+ location: `.github/workflows/${filename}`,
2819
+ description: `Suspicious runner label matches known IoC: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. This label matches known indicators of compromise from the Shai-Hulud / SHA1-Hulud worm.`,
2820
+ fix: "IMMEDIATE ACTION: Inspect this repository's recent commits, workflow files, and any associated infrastructure. This may be evidence of active compromise. Remove the suspicious runner and audit all recent workflow runs.",
2821
+ aiPrompt: `My GitHub Actions workflow ${filename} has a runner label "${labelsDisplay}" that matches known IoC patterns from the Shai-Hulud worm. Help me investigate: what should I check for signs of compromise, how do I audit recent workflow runs, and what remediation steps should I take immediately?`
2822
+ });
2823
+ } else if (category === "self-hosted") {
2824
+ findings.push({
2825
+ id: CHECK_ID14,
2826
+ name: CHECK_NAME7,
2827
+ status: "warn",
2828
+ severity: "medium",
2829
+ category: CATEGORY10,
2830
+ location: `.github/workflows/${filename}`,
2831
+ description: `Self-hosted runner in workflow: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. Self-hosted runners can be a persistence vector for supply-chain attacks.`,
2832
+ fix: "Verify this runner was intentionally configured. If not, this may indicate compromise. Ensure self-hosted runners are hardened, regularly patched, and use ephemeral instances where possible.",
2833
+ aiPrompt: `My GitHub Actions workflow ${filename} uses a self-hosted runner (${labelsDisplay}). Explain the supply-chain security risks of self-hosted runners, best practices for hardening them, and how to verify this configuration is intentional and safe.`
2834
+ });
2835
+ } else if (category === "unknown") {
2836
+ findings.push({
2837
+ id: CHECK_ID14,
2838
+ name: CHECK_NAME7,
2839
+ status: "pass",
2840
+ severity: "info",
2841
+ category: CATEGORY10,
2842
+ location: `.github/workflows/${filename}`,
2843
+ description: `Custom/unrecognized runner label: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. This label doesn't match standard GitHub-hosted runner patterns. If this is intentional (e.g., self-hosted with a custom name), document it.`
2844
+ });
2845
+ }
2846
+ }
2847
+ }
2848
+ if (findings.length === 0) {
2849
+ return [
2850
+ {
2851
+ id: CHECK_ID14,
2852
+ name: CHECK_NAME7,
2853
+ status: "pass",
2854
+ severity: "info",
2855
+ category: CATEGORY10,
2856
+ description: `All ${filenames.length} workflow file(s) use standard GitHub-hosted runners.`
2857
+ }
2858
+ ];
2859
+ }
2860
+ return findings;
2861
+ };
2862
+ var self_hosted_runner_default = selfHostedRunnerCheck;
2863
+
2275
2864
  // src/checks/index.ts
2276
2865
  function getAllChecks() {
2277
2866
  return [
@@ -2289,7 +2878,11 @@ function getAllChecks() {
2289
2878
  auth_default,
2290
2879
  cookies_default,
2291
2880
  server_disclosure_default,
2292
- dmarc_default
2881
+ dmarc_default,
2882
+ ignore_scripts_default,
2883
+ compromised_deps_default,
2884
+ npm_ci_default,
2885
+ self_hosted_runner_default
2293
2886
  ];
2294
2887
  }
2295
2888
  function getUrlOnlyChecks() {
@@ -2511,7 +3104,7 @@ var corsPrompt = (result, context) => {
2511
3104
  const framework = context.stack.framework ?? "my web application";
2512
3105
  return `${stack}${loc} ${result.description.split(".")[0]}. Help me fix this by: (1) replacing the wildcard origin with my specific domain(s), (2) showing the correct CORS configuration for ${framework}, (3) handling preflight OPTIONS requests properly, and (4) configuring credentials mode safely. Show me the complete working code.`;
2513
3106
  };
2514
- var rateLimitPrompt = (result, context) => {
3107
+ var rateLimitPrompt = (_result, context) => {
2515
3108
  const stack = buildStackDescription(context.stack);
2516
3109
  const framework = context.stack.framework;
2517
3110
  const pkg2 = getRateLimitPackage(context.stack);
@@ -2643,18 +3236,30 @@ function detectProjectType(packageJson, files) {
2643
3236
  }
2644
3237
  async function buildContext(options) {
2645
3238
  const projectPath = resolve(options.path);
3239
+ if (options.urlOnly) {
3240
+ return {
3241
+ projectPath,
3242
+ url: options.url,
3243
+ stack: { language: "unknown" },
3244
+ files: [],
3245
+ verbose: options.verbose,
3246
+ projectType: "static",
3247
+ projectTypeSource: "auto",
3248
+ urlOnly: true
3249
+ };
3250
+ }
2646
3251
  const info = await stat(projectPath).catch(() => null);
2647
3252
  if (!info?.isDirectory()) {
2648
3253
  throw new Error(`Not a directory: ${projectPath}`);
2649
3254
  }
2650
3255
  let packageJson;
2651
3256
  try {
2652
- const raw = await readFile9(join10(projectPath, "package.json"), "utf-8");
3257
+ const raw = await readFile13(join14(projectPath, "package.json"), "utf-8");
2653
3258
  packageJson = JSON.parse(raw);
2654
3259
  } catch {
2655
3260
  }
2656
- const entries = await readdir(projectPath, { recursive: true, withFileTypes: true });
2657
- const files = entries.filter((e) => e.isFile()).map((e) => relative(projectPath, join10(e.parentPath, e.name))).filter((f) => !f.split("/").some((segment) => EXCLUDED_DIRS.has(segment)));
3261
+ const entries = await readdir3(projectPath, { recursive: true, withFileTypes: true });
3262
+ const files = entries.filter((e) => e.isFile()).map((e) => relative(projectPath, join14(e.parentPath, e.name))).filter((f) => !f.split("/").some((segment) => EXCLUDED_DIRS.has(segment)));
2658
3263
  const stack = detectStack(packageJson, files);
2659
3264
  const typeOverride = options.type ?? "auto";
2660
3265
  const projectType = typeOverride === "auto" ? detectProjectType(packageJson, files) : typeOverride;
@@ -2697,17 +3302,17 @@ async function runChecks(context, checks) {
2697
3302
  }
2698
3303
  var PROJECT_INDICATORS = ["package.json", ".gitignore", ".git", "src", "lib"];
2699
3304
  var CODE_EXTENSIONS2 = /\.(ts|js|tsx|jsx)$/;
2700
- function hasProjectFiles(files, projectPath, packageJson) {
3305
+ function hasProjectFiles(files, _projectPath, packageJson) {
2701
3306
  if (packageJson) return true;
2702
3307
  for (const f of files) {
2703
- const first = f.split("/")[0];
3308
+ const first = f.split("/")[0] ?? "";
2704
3309
  if (PROJECT_INDICATORS.includes(first)) return true;
2705
3310
  if (CODE_EXTENSIONS2.test(f)) return true;
2706
3311
  }
2707
3312
  return false;
2708
3313
  }
2709
3314
  async function scan(context) {
2710
- const isUrlOnly = context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
3315
+ const isUrlOnly = context.urlOnly || !!context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
2711
3316
  if (isUrlOnly) {
2712
3317
  const report2 = await runChecks(context, getUrlOnlyChecks());
2713
3318
  return { ...report2, urlOnly: true, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
@@ -2877,7 +3482,7 @@ function formatJsonReport(report, metadata) {
2877
3482
 
2878
3483
  // src/generators/config.ts
2879
3484
  import { writeFile, mkdir } from "fs/promises";
2880
- import { join as join11 } from "path";
3485
+ import { join as join15 } from "path";
2881
3486
  function expressHelmetConfig() {
2882
3487
  return {
2883
3488
  name: "Helmet.js Security Headers",
@@ -3398,21 +4003,211 @@ async function writeConfigFiles(snippets, outputDir) {
3398
4003
  await mkdir(outputDir, { recursive: true });
3399
4004
  const paths = [];
3400
4005
  for (const snippet of snippets) {
3401
- const filePath = join11(outputDir, snippet.filename);
4006
+ const filePath = join15(outputDir, snippet.filename);
3402
4007
  await writeFile(filePath, snippet.code + "\n", "utf-8");
3403
4008
  paths.push(filePath);
3404
4009
  }
3405
4010
  return paths;
3406
4011
  }
3407
4012
 
4013
+ // src/generators/security-txt.ts
4014
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
4015
+ import { createInterface } from "readline";
4016
+ import { dirname, join as join16, resolve as resolve2 } from "path";
4017
+ import chalk2 from "chalk";
4018
+ function generateSecurityTxt(fields) {
4019
+ const lines = [
4020
+ "# security.txt \u2014 generated by Bastion",
4021
+ "# https://securitytxt.org/ | RFC 9116",
4022
+ "",
4023
+ `Contact: ${fields.contact}`,
4024
+ `Expires: ${fields.expires}`
4025
+ ];
4026
+ if (fields.preferredLanguages) {
4027
+ lines.push(`Preferred-Languages: ${fields.preferredLanguages}`);
4028
+ }
4029
+ if (fields.policy) {
4030
+ lines.push(`Policy: ${fields.policy}`);
4031
+ }
4032
+ if (fields.acknowledgments) {
4033
+ lines.push(`Acknowledgments: ${fields.acknowledgments}`);
4034
+ }
4035
+ lines.push("");
4036
+ return lines.join("\n");
4037
+ }
4038
+ function defaultExpiresDate() {
4039
+ const date = /* @__PURE__ */ new Date();
4040
+ date.setFullYear(date.getFullYear() + 1);
4041
+ return date.toISOString();
4042
+ }
4043
+ function normalizeContact(value) {
4044
+ const trimmed = value.trim();
4045
+ if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && trimmed.includes("@")) {
4046
+ return `mailto:${trimmed}`;
4047
+ }
4048
+ return trimmed;
4049
+ }
4050
+ function validateContact(value) {
4051
+ const trimmed = value.trim();
4052
+ if (!trimmed) {
4053
+ return "Contact is required";
4054
+ }
4055
+ if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && !trimmed.includes("@")) {
4056
+ return "Contact must be an email address, mailto: URI, or https:// URL";
4057
+ }
4058
+ return null;
4059
+ }
4060
+ function validateExpires(value) {
4061
+ const date = new Date(value);
4062
+ if (isNaN(date.getTime())) {
4063
+ return "Invalid date format. Use ISO 8601 (e.g., 2027-04-15T00:00:00.000Z)";
4064
+ }
4065
+ if (date.getTime() <= Date.now()) {
4066
+ return "Expires date must be in the future";
4067
+ }
4068
+ return null;
4069
+ }
4070
+ function promptField(rl, question, defaultValue) {
4071
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
4072
+ return new Promise((res) => {
4073
+ rl.question(` ${question}${suffix}: `, (answer) => {
4074
+ res(answer.trim() || defaultValue || "");
4075
+ });
4076
+ });
4077
+ }
4078
+ async function promptForFields(rl) {
4079
+ console.log();
4080
+ console.log(chalk2.bold.cyan(" security.txt Generator"));
4081
+ console.log(chalk2.dim(" Creates a valid security.txt file per RFC 9116"));
4082
+ console.log();
4083
+ let contact = "";
4084
+ while (!contact) {
4085
+ const raw = await promptField(rl, "Contact email or URL");
4086
+ const error = validateContact(raw);
4087
+ if (error) {
4088
+ console.log(chalk2.red(` ${error}`));
4089
+ } else {
4090
+ contact = normalizeContact(raw);
4091
+ }
4092
+ }
4093
+ const expires = await promptField(rl, "Expires date (ISO 8601)", defaultExpiresDate());
4094
+ const preferredLanguages = await promptField(rl, "Preferred-Languages", "en");
4095
+ const policy = await promptField(rl, "Policy URL (optional)");
4096
+ const acknowledgments = await promptField(rl, "Acknowledgments URL (optional)");
4097
+ const rootAnswer = await promptField(rl, "Also create at project root? (y/N)");
4098
+ const writeToRoot = rootAnswer.toLowerCase() === "y" || rootAnswer.toLowerCase() === "yes";
4099
+ return {
4100
+ fields: {
4101
+ contact,
4102
+ expires,
4103
+ preferredLanguages,
4104
+ ...policy ? { policy } : {},
4105
+ ...acknowledgments ? { acknowledgments } : {}
4106
+ },
4107
+ writeToRoot
4108
+ };
4109
+ }
4110
+ async function writeSecurityTxtFiles(projectPath, content, locations) {
4111
+ const written = [];
4112
+ for (const location of locations) {
4113
+ const fullPath = join16(projectPath, location);
4114
+ await mkdir2(dirname(fullPath), { recursive: true });
4115
+ await writeFile2(fullPath, content, "utf-8");
4116
+ written.push(fullPath);
4117
+ }
4118
+ return written;
4119
+ }
4120
+ function printResult(content, paths) {
4121
+ console.log();
4122
+ console.log(chalk2.bold.green(" \u2713 security.txt generated"));
4123
+ console.log();
4124
+ console.log(chalk2.dim(" Contents:"));
4125
+ console.log();
4126
+ for (const line of content.split("\n")) {
4127
+ if (line) {
4128
+ console.log(` ${line}`);
4129
+ }
4130
+ }
4131
+ console.log();
4132
+ for (const p of paths) {
4133
+ console.log(` ${chalk2.dim("Saved:")} ${p}`);
4134
+ }
4135
+ console.log();
4136
+ }
4137
+ async function runSecurityTxtGenerator(options) {
4138
+ const projectPath = resolve2(options.path);
4139
+ try {
4140
+ let fields;
4141
+ let writeToRoot = false;
4142
+ if (options.contact) {
4143
+ const contact = normalizeContact(options.contact);
4144
+ const contactError = validateContact(contact);
4145
+ if (contactError) {
4146
+ console.error(chalk2.red(`
4147
+ Error: ${contactError}
4148
+ `));
4149
+ process.exitCode = 1;
4150
+ return;
4151
+ }
4152
+ const expires = options.expires ?? defaultExpiresDate();
4153
+ const expiresError = validateExpires(expires);
4154
+ if (expiresError) {
4155
+ console.error(chalk2.red(`
4156
+ Error: ${expiresError}
4157
+ `));
4158
+ process.exitCode = 1;
4159
+ return;
4160
+ }
4161
+ fields = {
4162
+ contact,
4163
+ expires,
4164
+ preferredLanguages: options.languages ?? "en",
4165
+ ...options.policy ? { policy: options.policy } : {},
4166
+ ...options.acknowledgments ? { acknowledgments: options.acknowledgments } : {}
4167
+ };
4168
+ } else {
4169
+ const rl = createInterface({
4170
+ input: process.stdin,
4171
+ output: process.stdout
4172
+ });
4173
+ try {
4174
+ const result = await promptForFields(rl);
4175
+ fields = result.fields;
4176
+ writeToRoot = result.writeToRoot;
4177
+ } finally {
4178
+ rl.close();
4179
+ }
4180
+ }
4181
+ const content = generateSecurityTxt(fields);
4182
+ const locations = [".well-known/security.txt"];
4183
+ if (writeToRoot) {
4184
+ locations.push("security.txt");
4185
+ }
4186
+ const written = await writeSecurityTxtFiles(projectPath, content, locations);
4187
+ printResult(content, written);
4188
+ } catch (error) {
4189
+ console.error(
4190
+ chalk2.red(
4191
+ `
4192
+ Error: ${error instanceof Error ? error.message : String(error)}
4193
+ `
4194
+ )
4195
+ );
4196
+ process.exitCode = 1;
4197
+ }
4198
+ }
4199
+
3408
4200
  // src/cli.ts
4201
+ function computeUrlOnly(options, command) {
4202
+ return !!options.url && command.getOptionValueSource("path") === "default";
4203
+ }
3409
4204
  function createProgram(version) {
3410
4205
  const program = new Command();
3411
- program.name("bastion").description("Privacy-first security checker for AI-era builders").version(version);
4206
+ program.name("bastion").description("Privacy-first security checker for web projects").version(version);
3412
4207
  program.command("scan").description("Scan a project for security issues").option("-p, --path <dir>", "path to project directory", ".").option("-f, --format <type>", "output format (terminal, json, markdown)", "terminal").option("-v, --verbose", "show detailed output", false).option("-u, --url <url>", "URL for HTTP-based checks").option("-o, --output <file>", "output file path (for markdown/json formats)").option("--generate-configs", "generate security config snippets for detected stack", false).option("--output-dir <dir>", "write generated config files to directory").addOption(
3413
4208
  new Option("-t, --type <type>", "project type override").choices(["auto", "static", "api", "fullstack"]).default("auto")
3414
- ).action(async (options) => {
3415
- await runScan(options, version);
4209
+ ).action(async (options, command) => {
4210
+ await runScan({ ...options, urlOnly: computeUrlOnly(options, command) }, version);
3416
4211
  });
3417
4212
  const generate = program.command("generate").description("Generate security configuration files");
3418
4213
  generate.command("security-txt").description("Create a valid security.txt file (RFC 9116)").option("-c, --contact <value>", "contact email or URL (enables non-interactive mode)").option("-e, --expires <date>", "expires date in ISO 8601 (default: 1 year from now)").option("-l, --languages <langs>", "preferred languages (default: en)").option("--policy <url>", "policy URL").option("--acknowledgments <url>", "acknowledgments URL").option("-p, --path <dir>", "project directory", ".").action(async (options) => {
@@ -3427,21 +4222,21 @@ async function runScan(options, version) {
3427
4222
  }
3428
4223
  if (!isValidFormat(options.format)) {
3429
4224
  console.error(
3430
- chalk2.red(`
4225
+ chalk3.red(`
3431
4226
  Error: Invalid format "${options.format}". Use: ${OUTPUT_FORMATS.join(", ")}`)
3432
4227
  );
3433
4228
  process.exitCode = 1;
3434
4229
  return;
3435
4230
  }
3436
4231
  if (!isJson && options.verbose) {
3437
- const { resolve: resolve2 } = await import("path");
3438
- console.log(chalk2.dim(` Path: ${resolve2(options.path)}`));
3439
- console.log(chalk2.dim(` Format: ${options.format}`));
4232
+ const { resolve: resolve3 } = await import("path");
4233
+ console.log(chalk3.dim(` Path: ${resolve3(options.path)}`));
4234
+ console.log(chalk3.dim(` Format: ${options.format}`));
3440
4235
  if (options.url) {
3441
- console.log(chalk2.dim(` URL: ${options.url}`));
4236
+ console.log(chalk3.dim(` URL: ${options.url}`));
3442
4237
  }
3443
4238
  if (options.type !== "auto") {
3444
- console.log(chalk2.dim(` Type: ${options.type} (manual override)`));
4239
+ console.log(chalk3.dim(` Type: ${options.type} (manual override)`));
3445
4240
  }
3446
4241
  console.log();
3447
4242
  }
@@ -3451,7 +4246,8 @@ async function runScan(options, version) {
3451
4246
  path: options.path,
3452
4247
  url: options.url,
3453
4248
  verbose: options.verbose,
3454
- type: options.type
4249
+ type: options.type,
4250
+ urlOnly: options.urlOnly
3455
4251
  });
3456
4252
  const report = await scan(context);
3457
4253
  if (isJson) {
@@ -3467,16 +4263,16 @@ async function runScan(options, version) {
3467
4263
  } else {
3468
4264
  spinner?.succeed(`Scan complete (${report.duration}ms)`);
3469
4265
  if (report.urlOnly) {
3470
- console.log(chalk2.yellow("\n URL-only scan \u2014 6 HTTP checks performed."));
3471
- console.log(chalk2.dim(" Point --path at your source code for a full 15-check audit.\n"));
4266
+ console.log(chalk3.yellow("\n URL-only scan \u2014 6 HTTP checks performed."));
4267
+ console.log(chalk3.dim(" Point --path at your source code for a full 15-check audit.\n"));
3472
4268
  }
3473
4269
  if (report.projectType && report.projectType !== "unknown") {
3474
4270
  const source = report.projectTypeSource === "manual" ? "manual" : "auto-detected";
3475
- console.log(chalk2.dim(`
4271
+ console.log(chalk3.dim(`
3476
4272
  Project type: ${report.projectType} (${source})`));
3477
4273
  }
3478
4274
  if (report.projectType === "static" && report.summary.notApplicable > 0) {
3479
- console.log(chalk2.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
4275
+ console.log(chalk3.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
3480
4276
  }
3481
4277
  console.log(formatTerminalReport(report, options.verbose));
3482
4278
  }
@@ -3485,10 +4281,10 @@ async function runScan(options, version) {
3485
4281
  if (options.outputDir) {
3486
4282
  const paths = await writeConfigFiles(snippets, options.outputDir);
3487
4283
  if (!isJson) {
3488
- console.log(chalk2.green(`
4284
+ console.log(chalk3.green(`
3489
4285
  \u2713 Wrote ${paths.length} config file${paths.length === 1 ? "" : "s"} to ${options.outputDir}/`));
3490
4286
  for (const p of paths) {
3491
- console.log(chalk2.dim(` ${p}`));
4287
+ console.log(chalk3.dim(` ${p}`));
3492
4288
  }
3493
4289
  console.log();
3494
4290
  }
@@ -3505,7 +4301,7 @@ async function runScan(options, version) {
3505
4301
  } else {
3506
4302
  spinner?.fail("Scan failed");
3507
4303
  console.error(
3508
- chalk2.red(`
4304
+ chalk3.red(`
3509
4305
  ${error instanceof Error ? error.message : String(error)}
3510
4306
  `)
3511
4307
  );
@@ -3515,8 +4311,8 @@ async function runScan(options, version) {
3515
4311
  }
3516
4312
  function printBanner(version) {
3517
4313
  console.log();
3518
- console.log(chalk2.bold.cyan(" Bastion") + chalk2.dim(` v${version}`));
3519
- console.log(chalk2.dim(" Privacy-first security checker"));
4314
+ console.log(chalk3.bold.cyan(" Bastion") + chalk3.dim(` v${version}`));
4315
+ console.log(chalk3.dim(" Privacy-first security checker"));
3520
4316
  console.log();
3521
4317
  }
3522
4318
  function isValidFormat(format) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bastion-scan",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Privacy-first security checker for web projects. 15 checks, zero data uploaded, actionable fixes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,15 +34,20 @@
34
34
  "license": "MIT",
35
35
  "scripts": {
36
36
  "build": "tsup",
37
- "dev": "tsup --watch"
37
+ "dev": "tsup --watch",
38
+ "test": "vitest run --root ../..",
39
+ "prepublishOnly": "npm run build && npm run test"
38
40
  },
39
41
  "dependencies": {
40
- "bastion-shared": "^0.1.3",
41
42
  "chalk": "^5.6.2",
42
43
  "commander": "^14.0.3",
43
- "ora": "^9.3.0"
44
+ "ora": "^9.3.0",
45
+ "semver": "^7.8.0",
46
+ "yaml": "^2.9.0"
44
47
  },
45
48
  "devDependencies": {
49
+ "@bastion/shared": "*",
50
+ "@types/semver": "^7.7.1",
46
51
  "tsup": "^8.0.0"
47
52
  }
48
53
  }