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.
- package/README.md +46 -17
- package/dist/index.js +907 -55
- 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
|
|
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
|
-
#
|
|
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 |
|
|
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
|
-
|
|
|
128
|
-
|
|
|
129
|
-
| AI prompts |
|
|
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,
|
|
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
|
|
8
|
+
import chalk3 from "chalk";
|
|
9
9
|
import ora from "ora";
|
|
10
|
-
|
|
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
|
|
14
|
-
import { resolve, join as
|
|
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-[
|
|
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_[
|
|
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_[
|
|
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:
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
1203
|
+
resolve3(false);
|
|
1097
1204
|
req.destroy();
|
|
1098
1205
|
});
|
|
1099
1206
|
req.on("error", () => {
|
|
1100
1207
|
if (settled) return;
|
|
1101
1208
|
settled = true;
|
|
1102
|
-
|
|
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((
|
|
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
|
|
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 = (
|
|
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
|
|
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
|
|
2552
|
-
const files = entries.filter((e) => e.isFile()).map((e) => relative(projectPath,
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
3333
|
-
console.log(
|
|
3334
|
-
console.log(
|
|
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(
|
|
4187
|
+
console.log(chalk3.dim(` URL: ${options.url}`));
|
|
3337
4188
|
}
|
|
3338
4189
|
if (options.type !== "auto") {
|
|
3339
|
-
console.log(
|
|
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(
|
|
3366
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
3414
|
-
console.log(
|
|
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.
|
|
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
|
}
|