bastion-scan 0.1.3 → 0.2.0

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 +798 -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,544 @@ 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
+ // Initial seed entries to be added after reviewing:
2370
+ // - StepSecurity SHA1-Hulud IoC report
2371
+ // - Snyk vulnerability database entries
2372
+ // - npm security advisories for worm-propagated packages
2373
+ //
2374
+ // Example structure for future entries:
2375
+ // {
2376
+ // name: 'example-malicious-pkg',
2377
+ // versionRange: '>=1.0.0 <1.2.0',
2378
+ // advisoryId: 'GHSA-xxxx-xxxx-xxxx',
2379
+ // source: 'shai-hulud',
2380
+ // dateAdded: '2026-05-16',
2381
+ // description: 'Malicious postinstall script exfiltrating env vars',
2382
+ // },
2383
+ ];
2384
+
2385
+ // src/checks/supply-chain/compromised-deps.ts
2386
+ var CHECK_ID12 = "compromised-deps";
2387
+ var CHECK_NAME5 = "Compromised package detection";
2388
+ var CATEGORY8 = "supply-chain";
2389
+ function getDirectDeps(packageJson) {
2390
+ const deps = packageJson["dependencies"] ?? {};
2391
+ const devDeps = packageJson["devDependencies"] ?? {};
2392
+ return [...Object.keys(deps), ...Object.keys(devDeps)];
2393
+ }
2394
+ function resolveVersionFromLockfile(lockfile, packageName) {
2395
+ const packages = lockfile["packages"];
2396
+ if (!packages) return void 0;
2397
+ const entry = packages[`node_modules/${packageName}`];
2398
+ if (!entry) return void 0;
2399
+ const version = entry["version"];
2400
+ return typeof version === "string" ? version : void 0;
2401
+ }
2402
+ function findCompromisedMatch(packageName, installedVersion, list = COMPROMISED_PACKAGES) {
2403
+ return list.find(
2404
+ (entry) => entry.name === packageName && satisfies(installedVersion, entry.versionRange)
2405
+ );
2406
+ }
2407
+ var compromisedDepsCheck = async (context) => {
2408
+ if (!context.packageJson) {
2409
+ return [
2410
+ {
2411
+ id: CHECK_ID12,
2412
+ name: CHECK_NAME5,
2413
+ status: "skip",
2414
+ severity: "info",
2415
+ category: CATEGORY8,
2416
+ description: "No package.json found \u2014 skipping compromised dependency check."
2417
+ }
2418
+ ];
2419
+ }
2420
+ let lockfileContent;
2421
+ try {
2422
+ lockfileContent = await readFile10(join11(context.projectPath, "package-lock.json"), "utf-8");
2423
+ } catch (error) {
2424
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2425
+ if (isNotFound) {
2426
+ return [
2427
+ {
2428
+ id: CHECK_ID12,
2429
+ name: CHECK_NAME5,
2430
+ status: "skip",
2431
+ severity: "info",
2432
+ category: CATEGORY8,
2433
+ description: "No package-lock.json found \u2014 skipping compromised dependency check. This check requires an npm lockfile to resolve installed versions."
2434
+ }
2435
+ ];
2436
+ }
2437
+ return [
2438
+ {
2439
+ id: CHECK_ID12,
2440
+ name: CHECK_NAME5,
2441
+ status: "skip",
2442
+ severity: "info",
2443
+ category: CATEGORY8,
2444
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2445
+ }
2446
+ ];
2447
+ }
2448
+ let lockfile;
2449
+ try {
2450
+ lockfile = JSON.parse(lockfileContent);
2451
+ } catch {
2452
+ return [
2453
+ {
2454
+ id: CHECK_ID12,
2455
+ name: CHECK_NAME5,
2456
+ status: "skip",
2457
+ severity: "info",
2458
+ category: CATEGORY8,
2459
+ description: "package-lock.json contains malformed JSON \u2014 cannot check dependencies."
2460
+ }
2461
+ ];
2462
+ }
2463
+ if (COMPROMISED_PACKAGES.length === 0) {
2464
+ return [
2465
+ {
2466
+ id: CHECK_ID12,
2467
+ name: CHECK_NAME5,
2468
+ status: "pass",
2469
+ severity: "info",
2470
+ category: CATEGORY8,
2471
+ description: "Compromised package list is empty \u2014 no packages to check against."
2472
+ }
2473
+ ];
2474
+ }
2475
+ const directDeps = getDirectDeps(context.packageJson);
2476
+ const findings = [];
2477
+ for (const depName of directDeps) {
2478
+ const installedVersion = resolveVersionFromLockfile(lockfile, depName);
2479
+ if (!installedVersion) continue;
2480
+ const match = findCompromisedMatch(depName, installedVersion);
2481
+ if (match) {
2482
+ findings.push({
2483
+ id: CHECK_ID12,
2484
+ name: CHECK_NAME5,
2485
+ status: "fail",
2486
+ severity: "high",
2487
+ category: CATEGORY8,
2488
+ location: `package-lock.json (${depName}@${installedVersion})`,
2489
+ description: `Compromised package detected: ${depName}@${installedVersion}. Match: ${match.versionRange} (${match.source}, ${match.advisoryId}). Date added to list: ${match.dateAdded}. ${match.description}`,
2490
+ fix: `Remove ${depName} or pin to a version outside the compromised range (${match.versionRange}). Run \`npm audit\` for additional details.`,
2491
+ 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.`
2492
+ });
2493
+ }
2494
+ }
2495
+ if (findings.length === 0) {
2496
+ return [
2497
+ {
2498
+ id: CHECK_ID12,
2499
+ name: CHECK_NAME5,
2500
+ status: "pass",
2501
+ severity: "info",
2502
+ category: CATEGORY8,
2503
+ description: `No compromised packages found among ${directDeps.length} direct dependencies.`
2504
+ }
2505
+ ];
2506
+ }
2507
+ return findings;
2508
+ };
2509
+ var compromised_deps_default = compromisedDepsCheck;
2510
+
2511
+ // src/checks/supply-chain/npm-ci.ts
2512
+ import { readFile as readFile11, readdir } from "fs/promises";
2513
+ import { join as join12 } from "path";
2514
+ import { parse as parseYaml } from "yaml";
2515
+ var CHECK_ID13 = "npm-ci";
2516
+ var CHECK_NAME6 = "npm ci in CI workflows";
2517
+ var CATEGORY9 = "supply-chain";
2518
+ function isProblematicNpmCommand(cmdLine) {
2519
+ const trimmed = cmdLine.trim();
2520
+ const hasNpmInstall = /\bnpm\s+install\b/.test(trimmed) || /\bnpm\s+i\b/.test(trimmed);
2521
+ if (!hasNpmInstall) return false;
2522
+ if (/\s-g\b/.test(trimmed) || /\s--global\b/.test(trimmed)) return false;
2523
+ return true;
2524
+ }
2525
+ function extractRunCommands(parsedYaml) {
2526
+ if (!parsedYaml || typeof parsedYaml !== "object") return [];
2527
+ const workflow = parsedYaml;
2528
+ const jobs = workflow["jobs"];
2529
+ if (!jobs || typeof jobs !== "object") return [];
2530
+ const results = [];
2531
+ for (const job of Object.values(jobs)) {
2532
+ if (!job || typeof job !== "object") continue;
2533
+ const steps = job["steps"];
2534
+ if (!Array.isArray(steps)) continue;
2535
+ for (const step of steps) {
2536
+ if (!step || typeof step !== "object") continue;
2537
+ const stepObj = step;
2538
+ const run = stepObj["run"];
2539
+ if (typeof run !== "string") continue;
2540
+ const stepName = typeof stepObj["name"] === "string" ? stepObj["name"] : void 0;
2541
+ for (const line of run.split(/\r?\n/)) {
2542
+ const trimmed = line.trim();
2543
+ if (trimmed) {
2544
+ results.push({ stepName, command: trimmed });
2545
+ }
2546
+ }
2547
+ }
2548
+ }
2549
+ return results;
2550
+ }
2551
+ var npmCiCheck = async (context) => {
2552
+ const workflowsDir = join12(context.projectPath, ".github", "workflows");
2553
+ let filenames;
2554
+ try {
2555
+ const entries = await readdir(workflowsDir);
2556
+ filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2557
+ } catch (error) {
2558
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2559
+ if (isNotFound) {
2560
+ return [
2561
+ {
2562
+ id: CHECK_ID13,
2563
+ name: CHECK_NAME6,
2564
+ status: "skip",
2565
+ severity: "info",
2566
+ category: CATEGORY9,
2567
+ description: "No .github/workflows/ directory found \u2014 skipping CI workflow check."
2568
+ }
2569
+ ];
2570
+ }
2571
+ return [
2572
+ {
2573
+ id: CHECK_ID13,
2574
+ name: CHECK_NAME6,
2575
+ status: "skip",
2576
+ severity: "info",
2577
+ category: CATEGORY9,
2578
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2579
+ }
2580
+ ];
2581
+ }
2582
+ if (filenames.length === 0) {
2583
+ return [
2584
+ {
2585
+ id: CHECK_ID13,
2586
+ name: CHECK_NAME6,
2587
+ status: "skip",
2588
+ severity: "info",
2589
+ category: CATEGORY9,
2590
+ description: "No workflow files found in .github/workflows/ \u2014 skipping CI workflow check."
2591
+ }
2592
+ ];
2593
+ }
2594
+ const findings = [];
2595
+ for (const filename of filenames) {
2596
+ let content;
2597
+ try {
2598
+ content = await readFile11(join12(workflowsDir, filename), "utf-8");
2599
+ } catch {
2600
+ continue;
2601
+ }
2602
+ let parsed;
2603
+ try {
2604
+ parsed = parseYaml(content);
2605
+ } catch {
2606
+ continue;
2607
+ }
2608
+ const commands = extractRunCommands(parsed);
2609
+ for (const { stepName, command } of commands) {
2610
+ if (isProblematicNpmCommand(command)) {
2611
+ findings.push({
2612
+ id: CHECK_ID13,
2613
+ name: CHECK_NAME6,
2614
+ status: "warn",
2615
+ severity: "medium",
2616
+ category: CATEGORY9,
2617
+ location: `.github/workflows/${filename}`,
2618
+ 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.`,
2619
+ fix: "Replace `npm install` with `npm ci` which enforces lockfile-based installs.",
2620
+ 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).`
2621
+ });
2622
+ }
2623
+ }
2624
+ }
2625
+ if (findings.length === 0) {
2626
+ return [
2627
+ {
2628
+ id: CHECK_ID13,
2629
+ name: CHECK_NAME6,
2630
+ status: "pass",
2631
+ severity: "info",
2632
+ category: CATEGORY9,
2633
+ description: `All ${filenames.length} workflow file(s) use npm ci or have no npm install commands.`
2634
+ }
2635
+ ];
2636
+ }
2637
+ return findings;
2638
+ };
2639
+ var npm_ci_default = npmCiCheck;
2640
+
2641
+ // src/checks/supply-chain/self-hosted-runner.ts
2642
+ import { readFile as readFile12, readdir as readdir2 } from "fs/promises";
2643
+ import { join as join13 } from "path";
2644
+ import { parse as parseYaml2 } from "yaml";
2645
+ var CHECK_ID14 = "self-hosted-runner";
2646
+ var CHECK_NAME7 = "Self-hosted runner detection";
2647
+ var CATEGORY10 = "supply-chain";
2648
+ var GITHUB_HOSTED_REGEX = /^(ubuntu|macos|windows)(-(latest|\d+(\.\d+)?))?$/;
2649
+ var IOC_PATTERNS = ["sha1hulud", "shai-hulud", "sha1-hulud", "hulud"];
2650
+ function categorizeRunner(label) {
2651
+ const trimmed = label.trim();
2652
+ const lower = trimmed.toLowerCase();
2653
+ if (IOC_PATTERNS.some((pattern) => lower.includes(pattern))) {
2654
+ return "ioc-match";
2655
+ }
2656
+ if (lower.includes("self-hosted")) {
2657
+ return "self-hosted";
2658
+ }
2659
+ if (GITHUB_HOSTED_REGEX.test(trimmed)) {
2660
+ return "github-hosted";
2661
+ }
2662
+ return "unknown";
2663
+ }
2664
+ function extractRunsOn(parsedYaml) {
2665
+ if (!parsedYaml || typeof parsedYaml !== "object") return [];
2666
+ const workflow = parsedYaml;
2667
+ const jobs = workflow["jobs"];
2668
+ if (!jobs || typeof jobs !== "object") return [];
2669
+ const results = [];
2670
+ for (const [jobName, job] of Object.entries(jobs)) {
2671
+ if (!job || typeof job !== "object") continue;
2672
+ const runsOn = job["runs-on"];
2673
+ if (!runsOn) continue;
2674
+ if (typeof runsOn === "string") {
2675
+ results.push({ jobName, runsOn: [runsOn] });
2676
+ } else if (Array.isArray(runsOn)) {
2677
+ const labels = runsOn.filter((item) => typeof item === "string");
2678
+ if (labels.length > 0) {
2679
+ results.push({ jobName, runsOn: labels });
2680
+ }
2681
+ }
2682
+ }
2683
+ return results;
2684
+ }
2685
+ var CATEGORY_PRIORITY = {
2686
+ "ioc-match": 3,
2687
+ "self-hosted": 2,
2688
+ "unknown": 1,
2689
+ "github-hosted": 0
2690
+ };
2691
+ function highestCategory(labels) {
2692
+ let highest = "github-hosted";
2693
+ for (const label of labels) {
2694
+ const cat = categorizeRunner(label);
2695
+ if (CATEGORY_PRIORITY[cat] > CATEGORY_PRIORITY[highest]) {
2696
+ highest = cat;
2697
+ }
2698
+ }
2699
+ return highest;
2700
+ }
2701
+ var selfHostedRunnerCheck = async (context) => {
2702
+ const workflowsDir = join13(context.projectPath, ".github", "workflows");
2703
+ let filenames;
2704
+ try {
2705
+ const entries = await readdir2(workflowsDir);
2706
+ filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
2707
+ } catch (error) {
2708
+ const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
2709
+ if (isNotFound) {
2710
+ return [
2711
+ {
2712
+ id: CHECK_ID14,
2713
+ name: CHECK_NAME7,
2714
+ status: "skip",
2715
+ severity: "info",
2716
+ category: CATEGORY10,
2717
+ description: "No .github/workflows/ directory found \u2014 skipping runner detection."
2718
+ }
2719
+ ];
2720
+ }
2721
+ return [
2722
+ {
2723
+ id: CHECK_ID14,
2724
+ name: CHECK_NAME7,
2725
+ status: "skip",
2726
+ severity: "info",
2727
+ category: CATEGORY10,
2728
+ description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
2729
+ }
2730
+ ];
2731
+ }
2732
+ if (filenames.length === 0) {
2733
+ return [
2734
+ {
2735
+ id: CHECK_ID14,
2736
+ name: CHECK_NAME7,
2737
+ status: "skip",
2738
+ severity: "info",
2739
+ category: CATEGORY10,
2740
+ description: "No workflow files found in .github/workflows/ \u2014 skipping runner detection."
2741
+ }
2742
+ ];
2743
+ }
2744
+ const findings = [];
2745
+ for (const filename of filenames) {
2746
+ let content;
2747
+ try {
2748
+ content = await readFile12(join13(workflowsDir, filename), "utf-8");
2749
+ } catch {
2750
+ continue;
2751
+ }
2752
+ let parsed;
2753
+ try {
2754
+ parsed = parseYaml2(content);
2755
+ } catch {
2756
+ continue;
2757
+ }
2758
+ const jobsRunsOn = extractRunsOn(parsed);
2759
+ for (const { jobName, runsOn } of jobsRunsOn) {
2760
+ const category = highestCategory(runsOn);
2761
+ const labelsDisplay = runsOn.join(", ");
2762
+ if (category === "ioc-match") {
2763
+ findings.push({
2764
+ id: CHECK_ID14,
2765
+ name: CHECK_NAME7,
2766
+ status: "fail",
2767
+ severity: "high",
2768
+ category: CATEGORY10,
2769
+ location: `.github/workflows/${filename}`,
2770
+ 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.`,
2771
+ 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.",
2772
+ 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?`
2773
+ });
2774
+ } else if (category === "self-hosted") {
2775
+ findings.push({
2776
+ id: CHECK_ID14,
2777
+ name: CHECK_NAME7,
2778
+ status: "warn",
2779
+ severity: "medium",
2780
+ category: CATEGORY10,
2781
+ location: `.github/workflows/${filename}`,
2782
+ description: `Self-hosted runner in workflow: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. Self-hosted runners can be a persistence vector for supply-chain attacks.`,
2783
+ 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.",
2784
+ 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.`
2785
+ });
2786
+ } else if (category === "unknown") {
2787
+ findings.push({
2788
+ id: CHECK_ID14,
2789
+ name: CHECK_NAME7,
2790
+ status: "pass",
2791
+ severity: "info",
2792
+ category: CATEGORY10,
2793
+ location: `.github/workflows/${filename}`,
2794
+ 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.`
2795
+ });
2796
+ }
2797
+ }
2798
+ }
2799
+ if (findings.length === 0) {
2800
+ return [
2801
+ {
2802
+ id: CHECK_ID14,
2803
+ name: CHECK_NAME7,
2804
+ status: "pass",
2805
+ severity: "info",
2806
+ category: CATEGORY10,
2807
+ description: `All ${filenames.length} workflow file(s) use standard GitHub-hosted runners.`
2808
+ }
2809
+ ];
2810
+ }
2811
+ return findings;
2812
+ };
2813
+ var self_hosted_runner_default = selfHostedRunnerCheck;
2814
+
2275
2815
  // src/checks/index.ts
2276
2816
  function getAllChecks() {
2277
2817
  return [
@@ -2289,7 +2829,11 @@ function getAllChecks() {
2289
2829
  auth_default,
2290
2830
  cookies_default,
2291
2831
  server_disclosure_default,
2292
- dmarc_default
2832
+ dmarc_default,
2833
+ ignore_scripts_default,
2834
+ compromised_deps_default,
2835
+ npm_ci_default,
2836
+ self_hosted_runner_default
2293
2837
  ];
2294
2838
  }
2295
2839
  function getUrlOnlyChecks() {
@@ -2511,7 +3055,7 @@ var corsPrompt = (result, context) => {
2511
3055
  const framework = context.stack.framework ?? "my web application";
2512
3056
  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
3057
  };
2514
- var rateLimitPrompt = (result, context) => {
3058
+ var rateLimitPrompt = (_result, context) => {
2515
3059
  const stack = buildStackDescription(context.stack);
2516
3060
  const framework = context.stack.framework;
2517
3061
  const pkg2 = getRateLimitPackage(context.stack);
@@ -2643,18 +3187,30 @@ function detectProjectType(packageJson, files) {
2643
3187
  }
2644
3188
  async function buildContext(options) {
2645
3189
  const projectPath = resolve(options.path);
3190
+ if (options.urlOnly) {
3191
+ return {
3192
+ projectPath,
3193
+ url: options.url,
3194
+ stack: { language: "unknown" },
3195
+ files: [],
3196
+ verbose: options.verbose,
3197
+ projectType: "static",
3198
+ projectTypeSource: "auto",
3199
+ urlOnly: true
3200
+ };
3201
+ }
2646
3202
  const info = await stat(projectPath).catch(() => null);
2647
3203
  if (!info?.isDirectory()) {
2648
3204
  throw new Error(`Not a directory: ${projectPath}`);
2649
3205
  }
2650
3206
  let packageJson;
2651
3207
  try {
2652
- const raw = await readFile9(join10(projectPath, "package.json"), "utf-8");
3208
+ const raw = await readFile13(join14(projectPath, "package.json"), "utf-8");
2653
3209
  packageJson = JSON.parse(raw);
2654
3210
  } catch {
2655
3211
  }
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)));
3212
+ const entries = await readdir3(projectPath, { recursive: true, withFileTypes: true });
3213
+ 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
3214
  const stack = detectStack(packageJson, files);
2659
3215
  const typeOverride = options.type ?? "auto";
2660
3216
  const projectType = typeOverride === "auto" ? detectProjectType(packageJson, files) : typeOverride;
@@ -2697,17 +3253,17 @@ async function runChecks(context, checks) {
2697
3253
  }
2698
3254
  var PROJECT_INDICATORS = ["package.json", ".gitignore", ".git", "src", "lib"];
2699
3255
  var CODE_EXTENSIONS2 = /\.(ts|js|tsx|jsx)$/;
2700
- function hasProjectFiles(files, projectPath, packageJson) {
3256
+ function hasProjectFiles(files, _projectPath, packageJson) {
2701
3257
  if (packageJson) return true;
2702
3258
  for (const f of files) {
2703
- const first = f.split("/")[0];
3259
+ const first = f.split("/")[0] ?? "";
2704
3260
  if (PROJECT_INDICATORS.includes(first)) return true;
2705
3261
  if (CODE_EXTENSIONS2.test(f)) return true;
2706
3262
  }
2707
3263
  return false;
2708
3264
  }
2709
3265
  async function scan(context) {
2710
- const isUrlOnly = context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
3266
+ const isUrlOnly = context.urlOnly || !!context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
2711
3267
  if (isUrlOnly) {
2712
3268
  const report2 = await runChecks(context, getUrlOnlyChecks());
2713
3269
  return { ...report2, urlOnly: true, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
@@ -2877,7 +3433,7 @@ function formatJsonReport(report, metadata) {
2877
3433
 
2878
3434
  // src/generators/config.ts
2879
3435
  import { writeFile, mkdir } from "fs/promises";
2880
- import { join as join11 } from "path";
3436
+ import { join as join15 } from "path";
2881
3437
  function expressHelmetConfig() {
2882
3438
  return {
2883
3439
  name: "Helmet.js Security Headers",
@@ -3398,21 +3954,211 @@ async function writeConfigFiles(snippets, outputDir) {
3398
3954
  await mkdir(outputDir, { recursive: true });
3399
3955
  const paths = [];
3400
3956
  for (const snippet of snippets) {
3401
- const filePath = join11(outputDir, snippet.filename);
3957
+ const filePath = join15(outputDir, snippet.filename);
3402
3958
  await writeFile(filePath, snippet.code + "\n", "utf-8");
3403
3959
  paths.push(filePath);
3404
3960
  }
3405
3961
  return paths;
3406
3962
  }
3407
3963
 
3964
+ // src/generators/security-txt.ts
3965
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
3966
+ import { createInterface } from "readline";
3967
+ import { dirname, join as join16, resolve as resolve2 } from "path";
3968
+ import chalk2 from "chalk";
3969
+ function generateSecurityTxt(fields) {
3970
+ const lines = [
3971
+ "# security.txt \u2014 generated by Bastion",
3972
+ "# https://securitytxt.org/ | RFC 9116",
3973
+ "",
3974
+ `Contact: ${fields.contact}`,
3975
+ `Expires: ${fields.expires}`
3976
+ ];
3977
+ if (fields.preferredLanguages) {
3978
+ lines.push(`Preferred-Languages: ${fields.preferredLanguages}`);
3979
+ }
3980
+ if (fields.policy) {
3981
+ lines.push(`Policy: ${fields.policy}`);
3982
+ }
3983
+ if (fields.acknowledgments) {
3984
+ lines.push(`Acknowledgments: ${fields.acknowledgments}`);
3985
+ }
3986
+ lines.push("");
3987
+ return lines.join("\n");
3988
+ }
3989
+ function defaultExpiresDate() {
3990
+ const date = /* @__PURE__ */ new Date();
3991
+ date.setFullYear(date.getFullYear() + 1);
3992
+ return date.toISOString();
3993
+ }
3994
+ function normalizeContact(value) {
3995
+ const trimmed = value.trim();
3996
+ if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && trimmed.includes("@")) {
3997
+ return `mailto:${trimmed}`;
3998
+ }
3999
+ return trimmed;
4000
+ }
4001
+ function validateContact(value) {
4002
+ const trimmed = value.trim();
4003
+ if (!trimmed) {
4004
+ return "Contact is required";
4005
+ }
4006
+ if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && !trimmed.includes("@")) {
4007
+ return "Contact must be an email address, mailto: URI, or https:// URL";
4008
+ }
4009
+ return null;
4010
+ }
4011
+ function validateExpires(value) {
4012
+ const date = new Date(value);
4013
+ if (isNaN(date.getTime())) {
4014
+ return "Invalid date format. Use ISO 8601 (e.g., 2027-04-15T00:00:00.000Z)";
4015
+ }
4016
+ if (date.getTime() <= Date.now()) {
4017
+ return "Expires date must be in the future";
4018
+ }
4019
+ return null;
4020
+ }
4021
+ function promptField(rl, question, defaultValue) {
4022
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
4023
+ return new Promise((res) => {
4024
+ rl.question(` ${question}${suffix}: `, (answer) => {
4025
+ res(answer.trim() || defaultValue || "");
4026
+ });
4027
+ });
4028
+ }
4029
+ async function promptForFields(rl) {
4030
+ console.log();
4031
+ console.log(chalk2.bold.cyan(" security.txt Generator"));
4032
+ console.log(chalk2.dim(" Creates a valid security.txt file per RFC 9116"));
4033
+ console.log();
4034
+ let contact = "";
4035
+ while (!contact) {
4036
+ const raw = await promptField(rl, "Contact email or URL");
4037
+ const error = validateContact(raw);
4038
+ if (error) {
4039
+ console.log(chalk2.red(` ${error}`));
4040
+ } else {
4041
+ contact = normalizeContact(raw);
4042
+ }
4043
+ }
4044
+ const expires = await promptField(rl, "Expires date (ISO 8601)", defaultExpiresDate());
4045
+ const preferredLanguages = await promptField(rl, "Preferred-Languages", "en");
4046
+ const policy = await promptField(rl, "Policy URL (optional)");
4047
+ const acknowledgments = await promptField(rl, "Acknowledgments URL (optional)");
4048
+ const rootAnswer = await promptField(rl, "Also create at project root? (y/N)");
4049
+ const writeToRoot = rootAnswer.toLowerCase() === "y" || rootAnswer.toLowerCase() === "yes";
4050
+ return {
4051
+ fields: {
4052
+ contact,
4053
+ expires,
4054
+ preferredLanguages,
4055
+ ...policy ? { policy } : {},
4056
+ ...acknowledgments ? { acknowledgments } : {}
4057
+ },
4058
+ writeToRoot
4059
+ };
4060
+ }
4061
+ async function writeSecurityTxtFiles(projectPath, content, locations) {
4062
+ const written = [];
4063
+ for (const location of locations) {
4064
+ const fullPath = join16(projectPath, location);
4065
+ await mkdir2(dirname(fullPath), { recursive: true });
4066
+ await writeFile2(fullPath, content, "utf-8");
4067
+ written.push(fullPath);
4068
+ }
4069
+ return written;
4070
+ }
4071
+ function printResult(content, paths) {
4072
+ console.log();
4073
+ console.log(chalk2.bold.green(" \u2713 security.txt generated"));
4074
+ console.log();
4075
+ console.log(chalk2.dim(" Contents:"));
4076
+ console.log();
4077
+ for (const line of content.split("\n")) {
4078
+ if (line) {
4079
+ console.log(` ${line}`);
4080
+ }
4081
+ }
4082
+ console.log();
4083
+ for (const p of paths) {
4084
+ console.log(` ${chalk2.dim("Saved:")} ${p}`);
4085
+ }
4086
+ console.log();
4087
+ }
4088
+ async function runSecurityTxtGenerator(options) {
4089
+ const projectPath = resolve2(options.path);
4090
+ try {
4091
+ let fields;
4092
+ let writeToRoot = false;
4093
+ if (options.contact) {
4094
+ const contact = normalizeContact(options.contact);
4095
+ const contactError = validateContact(contact);
4096
+ if (contactError) {
4097
+ console.error(chalk2.red(`
4098
+ Error: ${contactError}
4099
+ `));
4100
+ process.exitCode = 1;
4101
+ return;
4102
+ }
4103
+ const expires = options.expires ?? defaultExpiresDate();
4104
+ const expiresError = validateExpires(expires);
4105
+ if (expiresError) {
4106
+ console.error(chalk2.red(`
4107
+ Error: ${expiresError}
4108
+ `));
4109
+ process.exitCode = 1;
4110
+ return;
4111
+ }
4112
+ fields = {
4113
+ contact,
4114
+ expires,
4115
+ preferredLanguages: options.languages ?? "en",
4116
+ ...options.policy ? { policy: options.policy } : {},
4117
+ ...options.acknowledgments ? { acknowledgments: options.acknowledgments } : {}
4118
+ };
4119
+ } else {
4120
+ const rl = createInterface({
4121
+ input: process.stdin,
4122
+ output: process.stdout
4123
+ });
4124
+ try {
4125
+ const result = await promptForFields(rl);
4126
+ fields = result.fields;
4127
+ writeToRoot = result.writeToRoot;
4128
+ } finally {
4129
+ rl.close();
4130
+ }
4131
+ }
4132
+ const content = generateSecurityTxt(fields);
4133
+ const locations = [".well-known/security.txt"];
4134
+ if (writeToRoot) {
4135
+ locations.push("security.txt");
4136
+ }
4137
+ const written = await writeSecurityTxtFiles(projectPath, content, locations);
4138
+ printResult(content, written);
4139
+ } catch (error) {
4140
+ console.error(
4141
+ chalk2.red(
4142
+ `
4143
+ Error: ${error instanceof Error ? error.message : String(error)}
4144
+ `
4145
+ )
4146
+ );
4147
+ process.exitCode = 1;
4148
+ }
4149
+ }
4150
+
3408
4151
  // src/cli.ts
4152
+ function computeUrlOnly(options, command) {
4153
+ return !!options.url && command.getOptionValueSource("path") === "default";
4154
+ }
3409
4155
  function createProgram(version) {
3410
4156
  const program = new Command();
3411
- program.name("bastion").description("Privacy-first security checker for AI-era builders").version(version);
4157
+ program.name("bastion").description("Privacy-first security checker for web projects").version(version);
3412
4158
  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
4159
  new Option("-t, --type <type>", "project type override").choices(["auto", "static", "api", "fullstack"]).default("auto")
3414
- ).action(async (options) => {
3415
- await runScan(options, version);
4160
+ ).action(async (options, command) => {
4161
+ await runScan({ ...options, urlOnly: computeUrlOnly(options, command) }, version);
3416
4162
  });
3417
4163
  const generate = program.command("generate").description("Generate security configuration files");
3418
4164
  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 +4173,21 @@ async function runScan(options, version) {
3427
4173
  }
3428
4174
  if (!isValidFormat(options.format)) {
3429
4175
  console.error(
3430
- chalk2.red(`
4176
+ chalk3.red(`
3431
4177
  Error: Invalid format "${options.format}". Use: ${OUTPUT_FORMATS.join(", ")}`)
3432
4178
  );
3433
4179
  process.exitCode = 1;
3434
4180
  return;
3435
4181
  }
3436
4182
  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}`));
4183
+ const { resolve: resolve3 } = await import("path");
4184
+ console.log(chalk3.dim(` Path: ${resolve3(options.path)}`));
4185
+ console.log(chalk3.dim(` Format: ${options.format}`));
3440
4186
  if (options.url) {
3441
- console.log(chalk2.dim(` URL: ${options.url}`));
4187
+ console.log(chalk3.dim(` URL: ${options.url}`));
3442
4188
  }
3443
4189
  if (options.type !== "auto") {
3444
- console.log(chalk2.dim(` Type: ${options.type} (manual override)`));
4190
+ console.log(chalk3.dim(` Type: ${options.type} (manual override)`));
3445
4191
  }
3446
4192
  console.log();
3447
4193
  }
@@ -3451,7 +4197,8 @@ async function runScan(options, version) {
3451
4197
  path: options.path,
3452
4198
  url: options.url,
3453
4199
  verbose: options.verbose,
3454
- type: options.type
4200
+ type: options.type,
4201
+ urlOnly: options.urlOnly
3455
4202
  });
3456
4203
  const report = await scan(context);
3457
4204
  if (isJson) {
@@ -3467,16 +4214,16 @@ async function runScan(options, version) {
3467
4214
  } else {
3468
4215
  spinner?.succeed(`Scan complete (${report.duration}ms)`);
3469
4216
  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"));
4217
+ console.log(chalk3.yellow("\n URL-only scan \u2014 6 HTTP checks performed."));
4218
+ console.log(chalk3.dim(" Point --path at your source code for a full 15-check audit.\n"));
3472
4219
  }
3473
4220
  if (report.projectType && report.projectType !== "unknown") {
3474
4221
  const source = report.projectTypeSource === "manual" ? "manual" : "auto-detected";
3475
- console.log(chalk2.dim(`
4222
+ console.log(chalk3.dim(`
3476
4223
  Project type: ${report.projectType} (${source})`));
3477
4224
  }
3478
4225
  if (report.projectType === "static" && report.summary.notApplicable > 0) {
3479
- console.log(chalk2.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
4226
+ console.log(chalk3.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
3480
4227
  }
3481
4228
  console.log(formatTerminalReport(report, options.verbose));
3482
4229
  }
@@ -3485,10 +4232,10 @@ async function runScan(options, version) {
3485
4232
  if (options.outputDir) {
3486
4233
  const paths = await writeConfigFiles(snippets, options.outputDir);
3487
4234
  if (!isJson) {
3488
- console.log(chalk2.green(`
4235
+ console.log(chalk3.green(`
3489
4236
  \u2713 Wrote ${paths.length} config file${paths.length === 1 ? "" : "s"} to ${options.outputDir}/`));
3490
4237
  for (const p of paths) {
3491
- console.log(chalk2.dim(` ${p}`));
4238
+ console.log(chalk3.dim(` ${p}`));
3492
4239
  }
3493
4240
  console.log();
3494
4241
  }
@@ -3505,7 +4252,7 @@ async function runScan(options, version) {
3505
4252
  } else {
3506
4253
  spinner?.fail("Scan failed");
3507
4254
  console.error(
3508
- chalk2.red(`
4255
+ chalk3.red(`
3509
4256
  ${error instanceof Error ? error.message : String(error)}
3510
4257
  `)
3511
4258
  );
@@ -3515,8 +4262,8 @@ async function runScan(options, version) {
3515
4262
  }
3516
4263
  function printBanner(version) {
3517
4264
  console.log();
3518
- console.log(chalk2.bold.cyan(" Bastion") + chalk2.dim(` v${version}`));
3519
- console.log(chalk2.dim(" Privacy-first security checker"));
4265
+ console.log(chalk3.bold.cyan(" Bastion") + chalk3.dim(` v${version}`));
4266
+ console.log(chalk3.dim(" Privacy-first security checker"));
3520
4267
  console.log();
3521
4268
  }
3522
4269
  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.0",
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
  }