bastion-scan 0.1.2 → 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 +46 -17
  2. package/dist/index.js +907 -55
  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
@@ -48,7 +74,7 @@ npx bastion-scan scan --generate-configs
48
74
  | Check | What it does |
49
75
  |-------|-------------|
50
76
  | `.gitignore` coverage | Makes sure `.env`, `node_modules`, and keys are excluded |
51
- | Hardcoded secrets | Looks for API keys from OpenAI, Stripe, AWS, and others |
77
+ | Hardcoded secrets | API keys from OpenAI, Anthropic, GitHub, Stripe, AWS, Google, Slack, and more |
52
78
  | Dependency audit | Wraps `npm audit` and maps findings to severity levels |
53
79
  | `.env.example` | Checks that a template exists with safe placeholder values |
54
80
  | `security.txt` | Validates RFC 9116 Contact + Expires fields |
@@ -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,40 +198,145 @@ 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 = [
201
+ // -- OpenAI ------------------------------------------------------------------
202
+ {
203
+ name: "OpenAI API key (project)",
204
+ regex: /sk-proj-[a-zA-Z0-9_-]{20,}/,
205
+ description: "OpenAI project API key detected",
206
+ severity: "critical"
207
+ },
208
+ {
209
+ name: "OpenAI API key (service account)",
210
+ regex: /sk-svcacct-[a-zA-Z0-9_-]{20,}/,
211
+ description: "OpenAI service account key detected",
212
+ severity: "critical"
213
+ },
199
214
  {
200
- name: "OpenAI API key",
201
- regex: /sk-[A-Za-z0-9]{20,}/,
202
- description: "OpenAI API key detected"
215
+ name: "OpenAI API key (legacy)",
216
+ regex: /sk-[a-zA-Z0-9]{20,}/,
217
+ description: "OpenAI API key detected",
218
+ severity: "critical"
203
219
  },
220
+ // -- Anthropic ---------------------------------------------------------------
221
+ {
222
+ name: "Anthropic API key",
223
+ regex: /sk-ant-api[0-9]{2}-[a-zA-Z0-9_-]{40,}/,
224
+ description: "Anthropic API key detected",
225
+ severity: "critical"
226
+ },
227
+ {
228
+ name: "Anthropic admin key",
229
+ regex: /sk-ant-admin[0-9]{2}-[a-zA-Z0-9_-]{40,}/,
230
+ description: "Anthropic admin API key detected",
231
+ severity: "critical"
232
+ },
233
+ // -- GitHub ------------------------------------------------------------------
234
+ {
235
+ name: "GitHub PAT (classic)",
236
+ regex: /ghp_[a-zA-Z0-9]{36}/,
237
+ description: "GitHub personal access token (classic) detected",
238
+ severity: "critical"
239
+ },
240
+ {
241
+ name: "GitHub OAuth token",
242
+ regex: /gho_[a-zA-Z0-9]{36}/,
243
+ description: "GitHub OAuth access token detected",
244
+ severity: "critical"
245
+ },
246
+ {
247
+ name: "GitHub PAT (fine-grained)",
248
+ regex: /github_pat_[a-zA-Z0-9_]{82}/,
249
+ description: "GitHub fine-grained personal access token detected",
250
+ severity: "critical"
251
+ },
252
+ {
253
+ name: "GitHub app token",
254
+ regex: /ghs_[a-zA-Z0-9]{36}/,
255
+ description: "GitHub app installation token detected",
256
+ severity: "critical"
257
+ },
258
+ {
259
+ name: "GitHub refresh token",
260
+ regex: /ghr_[a-zA-Z0-9]{36}/,
261
+ description: "GitHub refresh token detected",
262
+ severity: "critical"
263
+ },
264
+ // -- Stripe ------------------------------------------------------------------
204
265
  {
205
266
  name: "Stripe secret key",
206
- regex: /sk_live_[A-Za-z0-9]{20,}/,
207
- description: "Stripe secret key detected"
267
+ regex: /sk_live_[a-zA-Z0-9]{24,}/,
268
+ description: "Stripe secret key detected",
269
+ severity: "critical"
208
270
  },
209
271
  {
210
272
  name: "Stripe publishable key",
211
- regex: /pk_live_[A-Za-z0-9]{20,}/,
212
- description: "Stripe publishable live key detected"
273
+ regex: /pk_live_[a-zA-Z0-9]{20,}/,
274
+ description: "Stripe publishable live key detected",
275
+ severity: "critical"
276
+ },
277
+ {
278
+ name: "Stripe restricted key",
279
+ regex: /rk_live_[a-zA-Z0-9]{24,}/,
280
+ description: "Stripe restricted API key detected",
281
+ severity: "critical"
282
+ },
283
+ {
284
+ name: "Stripe test key",
285
+ regex: /sk_test_[a-zA-Z0-9]{24,}/,
286
+ description: "Stripe test secret key detected",
287
+ severity: "high"
213
288
  },
289
+ // -- AWS ---------------------------------------------------------------------
214
290
  {
215
291
  name: "AWS access key",
216
292
  regex: /AKIA[0-9A-Z]{16}/,
217
- description: "AWS access key ID detected"
293
+ description: "AWS access key ID detected",
294
+ severity: "critical"
295
+ },
296
+ // -- Google ------------------------------------------------------------------
297
+ {
298
+ name: "Google API key",
299
+ regex: /AIza[a-zA-Z0-9_-]{35}/,
300
+ description: "Google API key detected",
301
+ severity: "critical"
302
+ },
303
+ // -- Slack -------------------------------------------------------------------
304
+ {
305
+ name: "Slack bot token",
306
+ regex: /xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,}/,
307
+ description: "Slack bot token detected",
308
+ severity: "critical"
309
+ },
310
+ {
311
+ name: "Slack user token",
312
+ regex: /xoxp-[0-9]{10,13}-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{32}/,
313
+ description: "Slack user token detected",
314
+ severity: "critical"
315
+ },
316
+ {
317
+ name: "Slack webhook URL",
318
+ regex: /https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9]{8,12}\/B[a-zA-Z0-9]{8,12}\/[a-zA-Z0-9]{24}/,
319
+ description: "Slack incoming webhook URL detected",
320
+ severity: "high"
218
321
  },
322
+ // -- Generic -----------------------------------------------------------------
219
323
  {
220
324
  name: "Generic API key assignment",
221
325
  regex: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"][A-Za-z0-9_\-/.]{8,}['"]/i,
222
- description: "Hardcoded API key assignment detected"
326
+ description: "Hardcoded API key assignment detected",
327
+ severity: "critical"
223
328
  },
224
329
  {
225
330
  name: "Bearer token",
226
331
  regex: /['"]Bearer\s+[A-Za-z0-9_\-/.+]{20,}['"]/,
227
- description: "Hardcoded Bearer token detected"
332
+ description: "Hardcoded Bearer token detected",
333
+ severity: "critical"
228
334
  },
229
335
  {
230
336
  name: "Database connection string",
231
337
  regex: /(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql|redis):\/\/[^:]+:[^@\s]+@/i,
232
- description: "Database connection string with embedded password detected"
338
+ description: "Database connection string with embedded password detected",
339
+ severity: "critical"
233
340
  }
234
341
  ];
235
342
  var AI_PROMPT = "I found a hardcoded secret in my source code. Help me move it to an environment variable. Show me: (1) how to add it to .env, (2) how to read it with process.env or the equivalent for my framework, (3) how to add the key name to .env.example with a placeholder value, and (4) how to validate at startup that the variable is set.";
@@ -265,7 +372,7 @@ function scanContent(content, relativePath) {
265
372
  id: "secrets",
266
373
  name: `Hardcoded secret: ${pattern.name}`,
267
374
  status: "fail",
268
- severity: "critical",
375
+ severity: pattern.severity,
269
376
  category: "Secrets",
270
377
  location: `${relativePath}:${i + 1}`,
271
378
  description: pattern.description,
@@ -751,7 +858,7 @@ function isConnectionError(error) {
751
858
  return CONNECTION_ERROR_PATTERNS.some((code) => combined.includes(code));
752
859
  }
753
860
  function delay(ms) {
754
- return new Promise((resolve2) => setTimeout(resolve2, ms));
861
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
755
862
  }
756
863
  async function fetchWithRetry(url, init) {
757
864
  for (let attempt = 1; attempt <= 2; attempt++) {
@@ -1049,7 +1156,7 @@ var CONNECTION_ERRORS = /* @__PURE__ */ new Set([
1049
1156
  "EHOSTUNREACH"
1050
1157
  ]);
1051
1158
  function verifyCertificate(hostname, port) {
1052
- return new Promise((resolve2, reject) => {
1159
+ return new Promise((resolve3, reject) => {
1053
1160
  let settled = false;
1054
1161
  const socket = tlsConnect(
1055
1162
  { host: hostname, port, servername: hostname, rejectUnauthorized: true },
@@ -1057,7 +1164,7 @@ function verifyCertificate(hostname, port) {
1057
1164
  if (settled) return;
1058
1165
  settled = true;
1059
1166
  socket.destroy();
1060
- resolve2();
1167
+ resolve3();
1061
1168
  }
1062
1169
  );
1063
1170
  socket.setTimeout(TIMEOUT_MS);
@@ -1076,7 +1183,7 @@ function verifyCertificate(hostname, port) {
1076
1183
  });
1077
1184
  }
1078
1185
  function checkHttpsRedirect(hostname) {
1079
- return new Promise((resolve2) => {
1186
+ return new Promise((resolve3) => {
1080
1187
  let settled = false;
1081
1188
  const req = httpRequest(
1082
1189
  { hostname, port: 80, method: "HEAD", path: "/" },
@@ -1086,27 +1193,27 @@ function checkHttpsRedirect(hostname) {
1086
1193
  const status = res.statusCode ?? 0;
1087
1194
  const location = res.headers.location ?? "";
1088
1195
  const isRedirect = status >= 300 && status < 400;
1089
- resolve2(isRedirect && location.startsWith("https://"));
1196
+ resolve3(isRedirect && location.startsWith("https://"));
1090
1197
  }
1091
1198
  );
1092
1199
  req.setTimeout(TIMEOUT_MS);
1093
1200
  req.on("timeout", () => {
1094
1201
  if (settled) return;
1095
1202
  settled = true;
1096
- resolve2(false);
1203
+ resolve3(false);
1097
1204
  req.destroy();
1098
1205
  });
1099
1206
  req.on("error", () => {
1100
1207
  if (settled) return;
1101
1208
  settled = true;
1102
- resolve2(false);
1209
+ resolve3(false);
1103
1210
  });
1104
1211
  req.end();
1105
1212
  });
1106
1213
  }
1107
1214
  var RETRY_DELAY_MS2 = 2e3;
1108
1215
  function delay2(ms) {
1109
- return new Promise((resolve2) => setTimeout(resolve2, ms));
1216
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1110
1217
  }
1111
1218
  async function verifyCertificateWithRetry(hostname, port) {
1112
1219
  try {
@@ -1878,7 +1985,7 @@ var auth_default = authCheck;
1878
1985
  var CHECK_ID8 = "cookies";
1879
1986
  var CATEGORY4 = "cookies";
1880
1987
  function parseCookie(raw) {
1881
- const name = raw.split("=")[0].trim();
1988
+ const name = (raw.split("=")[0] ?? "").trim();
1882
1989
  const lower = raw.toLowerCase();
1883
1990
  return {
1884
1991
  name,
@@ -2131,7 +2238,7 @@ var dmarcCheck = async (context) => {
2131
2238
  }];
2132
2239
  }
2133
2240
  const policyMatch = dmarc.match(/;\s*p\s*=\s*(reject|quarantine|none)/i);
2134
- const policy = policyMatch ? policyMatch[1].toLowerCase() : "unknown";
2241
+ const policy = policyMatch?.[1]?.toLowerCase() ?? "unknown";
2135
2242
  if (policy === "reject") {
2136
2243
  return [{
2137
2244
  id: CHECK_ID10,
@@ -2167,6 +2274,544 @@ var dmarcCheck = async (context) => {
2167
2274
  };
2168
2275
  var dmarc_default = dmarcCheck;
2169
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
+
2170
2815
  // src/checks/index.ts
2171
2816
  function getAllChecks() {
2172
2817
  return [
@@ -2184,7 +2829,11 @@ function getAllChecks() {
2184
2829
  auth_default,
2185
2830
  cookies_default,
2186
2831
  server_disclosure_default,
2187
- dmarc_default
2832
+ dmarc_default,
2833
+ ignore_scripts_default,
2834
+ compromised_deps_default,
2835
+ npm_ci_default,
2836
+ self_hosted_runner_default
2188
2837
  ];
2189
2838
  }
2190
2839
  function getUrlOnlyChecks() {
@@ -2406,7 +3055,7 @@ var corsPrompt = (result, context) => {
2406
3055
  const framework = context.stack.framework ?? "my web application";
2407
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.`;
2408
3057
  };
2409
- var rateLimitPrompt = (result, context) => {
3058
+ var rateLimitPrompt = (_result, context) => {
2410
3059
  const stack = buildStackDescription(context.stack);
2411
3060
  const framework = context.stack.framework;
2412
3061
  const pkg2 = getRateLimitPackage(context.stack);
@@ -2538,18 +3187,30 @@ function detectProjectType(packageJson, files) {
2538
3187
  }
2539
3188
  async function buildContext(options) {
2540
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
+ }
2541
3202
  const info = await stat(projectPath).catch(() => null);
2542
3203
  if (!info?.isDirectory()) {
2543
3204
  throw new Error(`Not a directory: ${projectPath}`);
2544
3205
  }
2545
3206
  let packageJson;
2546
3207
  try {
2547
- const raw = await readFile9(join10(projectPath, "package.json"), "utf-8");
3208
+ const raw = await readFile13(join14(projectPath, "package.json"), "utf-8");
2548
3209
  packageJson = JSON.parse(raw);
2549
3210
  } catch {
2550
3211
  }
2551
- const entries = await readdir(projectPath, { recursive: true, withFileTypes: true });
2552
- 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)));
2553
3214
  const stack = detectStack(packageJson, files);
2554
3215
  const typeOverride = options.type ?? "auto";
2555
3216
  const projectType = typeOverride === "auto" ? detectProjectType(packageJson, files) : typeOverride;
@@ -2592,17 +3253,17 @@ async function runChecks(context, checks) {
2592
3253
  }
2593
3254
  var PROJECT_INDICATORS = ["package.json", ".gitignore", ".git", "src", "lib"];
2594
3255
  var CODE_EXTENSIONS2 = /\.(ts|js|tsx|jsx)$/;
2595
- function hasProjectFiles(files, projectPath, packageJson) {
3256
+ function hasProjectFiles(files, _projectPath, packageJson) {
2596
3257
  if (packageJson) return true;
2597
3258
  for (const f of files) {
2598
- const first = f.split("/")[0];
3259
+ const first = f.split("/")[0] ?? "";
2599
3260
  if (PROJECT_INDICATORS.includes(first)) return true;
2600
3261
  if (CODE_EXTENSIONS2.test(f)) return true;
2601
3262
  }
2602
3263
  return false;
2603
3264
  }
2604
3265
  async function scan(context) {
2605
- 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);
2606
3267
  if (isUrlOnly) {
2607
3268
  const report2 = await runChecks(context, getUrlOnlyChecks());
2608
3269
  return { ...report2, urlOnly: true, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
@@ -2772,7 +3433,7 @@ function formatJsonReport(report, metadata) {
2772
3433
 
2773
3434
  // src/generators/config.ts
2774
3435
  import { writeFile, mkdir } from "fs/promises";
2775
- import { join as join11 } from "path";
3436
+ import { join as join15 } from "path";
2776
3437
  function expressHelmetConfig() {
2777
3438
  return {
2778
3439
  name: "Helmet.js Security Headers",
@@ -3293,21 +3954,211 @@ async function writeConfigFiles(snippets, outputDir) {
3293
3954
  await mkdir(outputDir, { recursive: true });
3294
3955
  const paths = [];
3295
3956
  for (const snippet of snippets) {
3296
- const filePath = join11(outputDir, snippet.filename);
3957
+ const filePath = join15(outputDir, snippet.filename);
3297
3958
  await writeFile(filePath, snippet.code + "\n", "utf-8");
3298
3959
  paths.push(filePath);
3299
3960
  }
3300
3961
  return paths;
3301
3962
  }
3302
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
+
3303
4151
  // src/cli.ts
4152
+ function computeUrlOnly(options, command) {
4153
+ return !!options.url && command.getOptionValueSource("path") === "default";
4154
+ }
3304
4155
  function createProgram(version) {
3305
4156
  const program = new Command();
3306
- 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);
3307
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(
3308
4159
  new Option("-t, --type <type>", "project type override").choices(["auto", "static", "api", "fullstack"]).default("auto")
3309
- ).action(async (options) => {
3310
- await runScan(options, version);
4160
+ ).action(async (options, command) => {
4161
+ await runScan({ ...options, urlOnly: computeUrlOnly(options, command) }, version);
3311
4162
  });
3312
4163
  const generate = program.command("generate").description("Generate security configuration files");
3313
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) => {
@@ -3322,21 +4173,21 @@ async function runScan(options, version) {
3322
4173
  }
3323
4174
  if (!isValidFormat(options.format)) {
3324
4175
  console.error(
3325
- chalk2.red(`
4176
+ chalk3.red(`
3326
4177
  Error: Invalid format "${options.format}". Use: ${OUTPUT_FORMATS.join(", ")}`)
3327
4178
  );
3328
4179
  process.exitCode = 1;
3329
4180
  return;
3330
4181
  }
3331
4182
  if (!isJson && options.verbose) {
3332
- const { resolve: resolve2 } = await import("path");
3333
- console.log(chalk2.dim(` Path: ${resolve2(options.path)}`));
3334
- 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}`));
3335
4186
  if (options.url) {
3336
- console.log(chalk2.dim(` URL: ${options.url}`));
4187
+ console.log(chalk3.dim(` URL: ${options.url}`));
3337
4188
  }
3338
4189
  if (options.type !== "auto") {
3339
- console.log(chalk2.dim(` Type: ${options.type} (manual override)`));
4190
+ console.log(chalk3.dim(` Type: ${options.type} (manual override)`));
3340
4191
  }
3341
4192
  console.log();
3342
4193
  }
@@ -3346,7 +4197,8 @@ async function runScan(options, version) {
3346
4197
  path: options.path,
3347
4198
  url: options.url,
3348
4199
  verbose: options.verbose,
3349
- type: options.type
4200
+ type: options.type,
4201
+ urlOnly: options.urlOnly
3350
4202
  });
3351
4203
  const report = await scan(context);
3352
4204
  if (isJson) {
@@ -3362,16 +4214,16 @@ async function runScan(options, version) {
3362
4214
  } else {
3363
4215
  spinner?.succeed(`Scan complete (${report.duration}ms)`);
3364
4216
  if (report.urlOnly) {
3365
- console.log(chalk2.yellow("\n URL-only scan \u2014 6 HTTP checks performed."));
3366
- 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"));
3367
4219
  }
3368
4220
  if (report.projectType && report.projectType !== "unknown") {
3369
4221
  const source = report.projectTypeSource === "manual" ? "manual" : "auto-detected";
3370
- console.log(chalk2.dim(`
4222
+ console.log(chalk3.dim(`
3371
4223
  Project type: ${report.projectType} (${source})`));
3372
4224
  }
3373
4225
  if (report.projectType === "static" && report.summary.notApplicable > 0) {
3374
- 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`));
3375
4227
  }
3376
4228
  console.log(formatTerminalReport(report, options.verbose));
3377
4229
  }
@@ -3380,10 +4232,10 @@ async function runScan(options, version) {
3380
4232
  if (options.outputDir) {
3381
4233
  const paths = await writeConfigFiles(snippets, options.outputDir);
3382
4234
  if (!isJson) {
3383
- console.log(chalk2.green(`
4235
+ console.log(chalk3.green(`
3384
4236
  \u2713 Wrote ${paths.length} config file${paths.length === 1 ? "" : "s"} to ${options.outputDir}/`));
3385
4237
  for (const p of paths) {
3386
- console.log(chalk2.dim(` ${p}`));
4238
+ console.log(chalk3.dim(` ${p}`));
3387
4239
  }
3388
4240
  console.log();
3389
4241
  }
@@ -3400,7 +4252,7 @@ async function runScan(options, version) {
3400
4252
  } else {
3401
4253
  spinner?.fail("Scan failed");
3402
4254
  console.error(
3403
- chalk2.red(`
4255
+ chalk3.red(`
3404
4256
  ${error instanceof Error ? error.message : String(error)}
3405
4257
  `)
3406
4258
  );
@@ -3410,8 +4262,8 @@ async function runScan(options, version) {
3410
4262
  }
3411
4263
  function printBanner(version) {
3412
4264
  console.log();
3413
- console.log(chalk2.bold.cyan(" Bastion") + chalk2.dim(` v${version}`));
3414
- 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"));
3415
4267
  console.log();
3416
4268
  }
3417
4269
  function isValidFormat(format) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bastion-scan",
3
- "version": "0.1.2",
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.2",
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
  }