bastion-scan 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -16
- package/dist/index.js +798 -51
- 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
|
|
@@ -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,7 +198,7 @@ var SCANNABLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
196
198
|
]);
|
|
197
199
|
var IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "tests", "__tests__", "test", "fixtures"]);
|
|
198
200
|
var SECRET_PATTERNS = [
|
|
199
|
-
//
|
|
201
|
+
// -- OpenAI ------------------------------------------------------------------
|
|
200
202
|
{
|
|
201
203
|
name: "OpenAI API key (project)",
|
|
202
204
|
regex: /sk-proj-[a-zA-Z0-9_-]{20,}/,
|
|
@@ -215,7 +217,7 @@ var SECRET_PATTERNS = [
|
|
|
215
217
|
description: "OpenAI API key detected",
|
|
216
218
|
severity: "critical"
|
|
217
219
|
},
|
|
218
|
-
//
|
|
220
|
+
// -- Anthropic ---------------------------------------------------------------
|
|
219
221
|
{
|
|
220
222
|
name: "Anthropic API key",
|
|
221
223
|
regex: /sk-ant-api[0-9]{2}-[a-zA-Z0-9_-]{40,}/,
|
|
@@ -228,7 +230,7 @@ var SECRET_PATTERNS = [
|
|
|
228
230
|
description: "Anthropic admin API key detected",
|
|
229
231
|
severity: "critical"
|
|
230
232
|
},
|
|
231
|
-
//
|
|
233
|
+
// -- GitHub ------------------------------------------------------------------
|
|
232
234
|
{
|
|
233
235
|
name: "GitHub PAT (classic)",
|
|
234
236
|
regex: /ghp_[a-zA-Z0-9]{36}/,
|
|
@@ -259,7 +261,7 @@ var SECRET_PATTERNS = [
|
|
|
259
261
|
description: "GitHub refresh token detected",
|
|
260
262
|
severity: "critical"
|
|
261
263
|
},
|
|
262
|
-
//
|
|
264
|
+
// -- Stripe ------------------------------------------------------------------
|
|
263
265
|
{
|
|
264
266
|
name: "Stripe secret key",
|
|
265
267
|
regex: /sk_live_[a-zA-Z0-9]{24,}/,
|
|
@@ -284,21 +286,21 @@ var SECRET_PATTERNS = [
|
|
|
284
286
|
description: "Stripe test secret key detected",
|
|
285
287
|
severity: "high"
|
|
286
288
|
},
|
|
287
|
-
//
|
|
289
|
+
// -- AWS ---------------------------------------------------------------------
|
|
288
290
|
{
|
|
289
291
|
name: "AWS access key",
|
|
290
292
|
regex: /AKIA[0-9A-Z]{16}/,
|
|
291
293
|
description: "AWS access key ID detected",
|
|
292
294
|
severity: "critical"
|
|
293
295
|
},
|
|
294
|
-
//
|
|
296
|
+
// -- Google ------------------------------------------------------------------
|
|
295
297
|
{
|
|
296
298
|
name: "Google API key",
|
|
297
299
|
regex: /AIza[a-zA-Z0-9_-]{35}/,
|
|
298
300
|
description: "Google API key detected",
|
|
299
301
|
severity: "critical"
|
|
300
302
|
},
|
|
301
|
-
//
|
|
303
|
+
// -- Slack -------------------------------------------------------------------
|
|
302
304
|
{
|
|
303
305
|
name: "Slack bot token",
|
|
304
306
|
regex: /xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24,}/,
|
|
@@ -317,7 +319,7 @@ var SECRET_PATTERNS = [
|
|
|
317
319
|
description: "Slack incoming webhook URL detected",
|
|
318
320
|
severity: "high"
|
|
319
321
|
},
|
|
320
|
-
//
|
|
322
|
+
// -- Generic -----------------------------------------------------------------
|
|
321
323
|
{
|
|
322
324
|
name: "Generic API key assignment",
|
|
323
325
|
regex: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"][A-Za-z0-9_\-/.]{8,}['"]/i,
|
|
@@ -856,7 +858,7 @@ function isConnectionError(error) {
|
|
|
856
858
|
return CONNECTION_ERROR_PATTERNS.some((code) => combined.includes(code));
|
|
857
859
|
}
|
|
858
860
|
function delay(ms) {
|
|
859
|
-
return new Promise((
|
|
861
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
860
862
|
}
|
|
861
863
|
async function fetchWithRetry(url, init) {
|
|
862
864
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
@@ -1154,7 +1156,7 @@ var CONNECTION_ERRORS = /* @__PURE__ */ new Set([
|
|
|
1154
1156
|
"EHOSTUNREACH"
|
|
1155
1157
|
]);
|
|
1156
1158
|
function verifyCertificate(hostname, port) {
|
|
1157
|
-
return new Promise((
|
|
1159
|
+
return new Promise((resolve3, reject) => {
|
|
1158
1160
|
let settled = false;
|
|
1159
1161
|
const socket = tlsConnect(
|
|
1160
1162
|
{ host: hostname, port, servername: hostname, rejectUnauthorized: true },
|
|
@@ -1162,7 +1164,7 @@ function verifyCertificate(hostname, port) {
|
|
|
1162
1164
|
if (settled) return;
|
|
1163
1165
|
settled = true;
|
|
1164
1166
|
socket.destroy();
|
|
1165
|
-
|
|
1167
|
+
resolve3();
|
|
1166
1168
|
}
|
|
1167
1169
|
);
|
|
1168
1170
|
socket.setTimeout(TIMEOUT_MS);
|
|
@@ -1181,7 +1183,7 @@ function verifyCertificate(hostname, port) {
|
|
|
1181
1183
|
});
|
|
1182
1184
|
}
|
|
1183
1185
|
function checkHttpsRedirect(hostname) {
|
|
1184
|
-
return new Promise((
|
|
1186
|
+
return new Promise((resolve3) => {
|
|
1185
1187
|
let settled = false;
|
|
1186
1188
|
const req = httpRequest(
|
|
1187
1189
|
{ hostname, port: 80, method: "HEAD", path: "/" },
|
|
@@ -1191,27 +1193,27 @@ function checkHttpsRedirect(hostname) {
|
|
|
1191
1193
|
const status = res.statusCode ?? 0;
|
|
1192
1194
|
const location = res.headers.location ?? "";
|
|
1193
1195
|
const isRedirect = status >= 300 && status < 400;
|
|
1194
|
-
|
|
1196
|
+
resolve3(isRedirect && location.startsWith("https://"));
|
|
1195
1197
|
}
|
|
1196
1198
|
);
|
|
1197
1199
|
req.setTimeout(TIMEOUT_MS);
|
|
1198
1200
|
req.on("timeout", () => {
|
|
1199
1201
|
if (settled) return;
|
|
1200
1202
|
settled = true;
|
|
1201
|
-
|
|
1203
|
+
resolve3(false);
|
|
1202
1204
|
req.destroy();
|
|
1203
1205
|
});
|
|
1204
1206
|
req.on("error", () => {
|
|
1205
1207
|
if (settled) return;
|
|
1206
1208
|
settled = true;
|
|
1207
|
-
|
|
1209
|
+
resolve3(false);
|
|
1208
1210
|
});
|
|
1209
1211
|
req.end();
|
|
1210
1212
|
});
|
|
1211
1213
|
}
|
|
1212
1214
|
var RETRY_DELAY_MS2 = 2e3;
|
|
1213
1215
|
function delay2(ms) {
|
|
1214
|
-
return new Promise((
|
|
1216
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1215
1217
|
}
|
|
1216
1218
|
async function verifyCertificateWithRetry(hostname, port) {
|
|
1217
1219
|
try {
|
|
@@ -1983,7 +1985,7 @@ var auth_default = authCheck;
|
|
|
1983
1985
|
var CHECK_ID8 = "cookies";
|
|
1984
1986
|
var CATEGORY4 = "cookies";
|
|
1985
1987
|
function parseCookie(raw) {
|
|
1986
|
-
const name = raw.split("=")[0].trim();
|
|
1988
|
+
const name = (raw.split("=")[0] ?? "").trim();
|
|
1987
1989
|
const lower = raw.toLowerCase();
|
|
1988
1990
|
return {
|
|
1989
1991
|
name,
|
|
@@ -2236,7 +2238,7 @@ var dmarcCheck = async (context) => {
|
|
|
2236
2238
|
}];
|
|
2237
2239
|
}
|
|
2238
2240
|
const policyMatch = dmarc.match(/;\s*p\s*=\s*(reject|quarantine|none)/i);
|
|
2239
|
-
const policy = policyMatch
|
|
2241
|
+
const policy = policyMatch?.[1]?.toLowerCase() ?? "unknown";
|
|
2240
2242
|
if (policy === "reject") {
|
|
2241
2243
|
return [{
|
|
2242
2244
|
id: CHECK_ID10,
|
|
@@ -2272,6 +2274,544 @@ var dmarcCheck = async (context) => {
|
|
|
2272
2274
|
};
|
|
2273
2275
|
var dmarc_default = dmarcCheck;
|
|
2274
2276
|
|
|
2277
|
+
// src/checks/supply-chain/ignore-scripts.ts
|
|
2278
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
2279
|
+
import { join as join10 } from "path";
|
|
2280
|
+
var CHECK_ID11 = "ignore-scripts";
|
|
2281
|
+
var CHECK_NAME4 = "ignore-scripts protection";
|
|
2282
|
+
var CATEGORY7 = "supply-chain";
|
|
2283
|
+
function parseNpmrc(content) {
|
|
2284
|
+
const result = {};
|
|
2285
|
+
for (const line of content.split(/\r?\n/)) {
|
|
2286
|
+
const trimmed = line.trim();
|
|
2287
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) {
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
const eqIndex = trimmed.indexOf("=");
|
|
2291
|
+
if (eqIndex === -1) continue;
|
|
2292
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
2293
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
2294
|
+
if (key) {
|
|
2295
|
+
result[key] = value;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
return result;
|
|
2299
|
+
}
|
|
2300
|
+
var ignoreScriptsCheck = async (context) => {
|
|
2301
|
+
let content;
|
|
2302
|
+
try {
|
|
2303
|
+
content = await readFile9(join10(context.projectPath, ".npmrc"), "utf-8");
|
|
2304
|
+
} catch (error) {
|
|
2305
|
+
const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2306
|
+
if (isNotFound) {
|
|
2307
|
+
return [
|
|
2308
|
+
{
|
|
2309
|
+
id: CHECK_ID11,
|
|
2310
|
+
name: CHECK_NAME4,
|
|
2311
|
+
status: "warn",
|
|
2312
|
+
severity: "medium",
|
|
2313
|
+
category: CATEGORY7,
|
|
2314
|
+
location: ".npmrc",
|
|
2315
|
+
description: "No .npmrc file found. Without ignore-scripts=true, malicious postinstall scripts in dependencies will execute during npm install. This was the primary attack vector for Shai-Hulud and similar worm-style attacks.",
|
|
2316
|
+
fix: "Create .npmrc in the project root and add:\n ignore-scripts=true",
|
|
2317
|
+
aiPrompt: "My project has no .npmrc file. Create one with ignore-scripts=true to prevent malicious postinstall scripts from running during npm install. Explain what legitimate postinstall scripts I might need to run manually after installing with this setting enabled."
|
|
2318
|
+
}
|
|
2319
|
+
];
|
|
2320
|
+
}
|
|
2321
|
+
return [
|
|
2322
|
+
{
|
|
2323
|
+
id: CHECK_ID11,
|
|
2324
|
+
name: CHECK_NAME4,
|
|
2325
|
+
status: "skip",
|
|
2326
|
+
severity: "info",
|
|
2327
|
+
category: CATEGORY7,
|
|
2328
|
+
description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2329
|
+
}
|
|
2330
|
+
];
|
|
2331
|
+
}
|
|
2332
|
+
const config = parseNpmrc(content);
|
|
2333
|
+
if (config["ignore-scripts"] === "true") {
|
|
2334
|
+
return [
|
|
2335
|
+
{
|
|
2336
|
+
id: CHECK_ID11,
|
|
2337
|
+
name: CHECK_NAME4,
|
|
2338
|
+
status: "pass",
|
|
2339
|
+
severity: "info",
|
|
2340
|
+
category: CATEGORY7,
|
|
2341
|
+
location: ".npmrc",
|
|
2342
|
+
description: "ignore-scripts=true is configured \u2014 postinstall scripts are blocked by default"
|
|
2343
|
+
}
|
|
2344
|
+
];
|
|
2345
|
+
}
|
|
2346
|
+
return [
|
|
2347
|
+
{
|
|
2348
|
+
id: CHECK_ID11,
|
|
2349
|
+
name: CHECK_NAME4,
|
|
2350
|
+
status: "warn",
|
|
2351
|
+
severity: "medium",
|
|
2352
|
+
category: CATEGORY7,
|
|
2353
|
+
location: ".npmrc",
|
|
2354
|
+
description: "Project does not have ignore-scripts=true configured. Without this setting, malicious postinstall scripts in dependencies will execute during npm install. This was the primary attack vector for Shai-Hulud and similar worm-style attacks.",
|
|
2355
|
+
fix: "Edit .npmrc in the project root and add:\n ignore-scripts=true",
|
|
2356
|
+
aiPrompt: "My project .npmrc does not have ignore-scripts=true. Update it to add this setting. Explain what legitimate postinstall scripts I might need to run manually after installing with this setting enabled, and how to allowlist specific packages if needed."
|
|
2357
|
+
}
|
|
2358
|
+
];
|
|
2359
|
+
};
|
|
2360
|
+
var ignore_scripts_default = ignoreScriptsCheck;
|
|
2361
|
+
|
|
2362
|
+
// src/checks/supply-chain/compromised-deps.ts
|
|
2363
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
2364
|
+
import { join as join11 } from "path";
|
|
2365
|
+
import { satisfies } from "semver";
|
|
2366
|
+
|
|
2367
|
+
// src/data/compromised-packages.ts
|
|
2368
|
+
var COMPROMISED_PACKAGES = [
|
|
2369
|
+
// Initial seed entries to be added after reviewing:
|
|
2370
|
+
// - StepSecurity SHA1-Hulud IoC report
|
|
2371
|
+
// - Snyk vulnerability database entries
|
|
2372
|
+
// - npm security advisories for worm-propagated packages
|
|
2373
|
+
//
|
|
2374
|
+
// Example structure for future entries:
|
|
2375
|
+
// {
|
|
2376
|
+
// name: 'example-malicious-pkg',
|
|
2377
|
+
// versionRange: '>=1.0.0 <1.2.0',
|
|
2378
|
+
// advisoryId: 'GHSA-xxxx-xxxx-xxxx',
|
|
2379
|
+
// source: 'shai-hulud',
|
|
2380
|
+
// dateAdded: '2026-05-16',
|
|
2381
|
+
// description: 'Malicious postinstall script exfiltrating env vars',
|
|
2382
|
+
// },
|
|
2383
|
+
];
|
|
2384
|
+
|
|
2385
|
+
// src/checks/supply-chain/compromised-deps.ts
|
|
2386
|
+
var CHECK_ID12 = "compromised-deps";
|
|
2387
|
+
var CHECK_NAME5 = "Compromised package detection";
|
|
2388
|
+
var CATEGORY8 = "supply-chain";
|
|
2389
|
+
function getDirectDeps(packageJson) {
|
|
2390
|
+
const deps = packageJson["dependencies"] ?? {};
|
|
2391
|
+
const devDeps = packageJson["devDependencies"] ?? {};
|
|
2392
|
+
return [...Object.keys(deps), ...Object.keys(devDeps)];
|
|
2393
|
+
}
|
|
2394
|
+
function resolveVersionFromLockfile(lockfile, packageName) {
|
|
2395
|
+
const packages = lockfile["packages"];
|
|
2396
|
+
if (!packages) return void 0;
|
|
2397
|
+
const entry = packages[`node_modules/${packageName}`];
|
|
2398
|
+
if (!entry) return void 0;
|
|
2399
|
+
const version = entry["version"];
|
|
2400
|
+
return typeof version === "string" ? version : void 0;
|
|
2401
|
+
}
|
|
2402
|
+
function findCompromisedMatch(packageName, installedVersion, list = COMPROMISED_PACKAGES) {
|
|
2403
|
+
return list.find(
|
|
2404
|
+
(entry) => entry.name === packageName && satisfies(installedVersion, entry.versionRange)
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
var compromisedDepsCheck = async (context) => {
|
|
2408
|
+
if (!context.packageJson) {
|
|
2409
|
+
return [
|
|
2410
|
+
{
|
|
2411
|
+
id: CHECK_ID12,
|
|
2412
|
+
name: CHECK_NAME5,
|
|
2413
|
+
status: "skip",
|
|
2414
|
+
severity: "info",
|
|
2415
|
+
category: CATEGORY8,
|
|
2416
|
+
description: "No package.json found \u2014 skipping compromised dependency check."
|
|
2417
|
+
}
|
|
2418
|
+
];
|
|
2419
|
+
}
|
|
2420
|
+
let lockfileContent;
|
|
2421
|
+
try {
|
|
2422
|
+
lockfileContent = await readFile10(join11(context.projectPath, "package-lock.json"), "utf-8");
|
|
2423
|
+
} catch (error) {
|
|
2424
|
+
const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2425
|
+
if (isNotFound) {
|
|
2426
|
+
return [
|
|
2427
|
+
{
|
|
2428
|
+
id: CHECK_ID12,
|
|
2429
|
+
name: CHECK_NAME5,
|
|
2430
|
+
status: "skip",
|
|
2431
|
+
severity: "info",
|
|
2432
|
+
category: CATEGORY8,
|
|
2433
|
+
description: "No package-lock.json found \u2014 skipping compromised dependency check. This check requires an npm lockfile to resolve installed versions."
|
|
2434
|
+
}
|
|
2435
|
+
];
|
|
2436
|
+
}
|
|
2437
|
+
return [
|
|
2438
|
+
{
|
|
2439
|
+
id: CHECK_ID12,
|
|
2440
|
+
name: CHECK_NAME5,
|
|
2441
|
+
status: "skip",
|
|
2442
|
+
severity: "info",
|
|
2443
|
+
category: CATEGORY8,
|
|
2444
|
+
description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2445
|
+
}
|
|
2446
|
+
];
|
|
2447
|
+
}
|
|
2448
|
+
let lockfile;
|
|
2449
|
+
try {
|
|
2450
|
+
lockfile = JSON.parse(lockfileContent);
|
|
2451
|
+
} catch {
|
|
2452
|
+
return [
|
|
2453
|
+
{
|
|
2454
|
+
id: CHECK_ID12,
|
|
2455
|
+
name: CHECK_NAME5,
|
|
2456
|
+
status: "skip",
|
|
2457
|
+
severity: "info",
|
|
2458
|
+
category: CATEGORY8,
|
|
2459
|
+
description: "package-lock.json contains malformed JSON \u2014 cannot check dependencies."
|
|
2460
|
+
}
|
|
2461
|
+
];
|
|
2462
|
+
}
|
|
2463
|
+
if (COMPROMISED_PACKAGES.length === 0) {
|
|
2464
|
+
return [
|
|
2465
|
+
{
|
|
2466
|
+
id: CHECK_ID12,
|
|
2467
|
+
name: CHECK_NAME5,
|
|
2468
|
+
status: "pass",
|
|
2469
|
+
severity: "info",
|
|
2470
|
+
category: CATEGORY8,
|
|
2471
|
+
description: "Compromised package list is empty \u2014 no packages to check against."
|
|
2472
|
+
}
|
|
2473
|
+
];
|
|
2474
|
+
}
|
|
2475
|
+
const directDeps = getDirectDeps(context.packageJson);
|
|
2476
|
+
const findings = [];
|
|
2477
|
+
for (const depName of directDeps) {
|
|
2478
|
+
const installedVersion = resolveVersionFromLockfile(lockfile, depName);
|
|
2479
|
+
if (!installedVersion) continue;
|
|
2480
|
+
const match = findCompromisedMatch(depName, installedVersion);
|
|
2481
|
+
if (match) {
|
|
2482
|
+
findings.push({
|
|
2483
|
+
id: CHECK_ID12,
|
|
2484
|
+
name: CHECK_NAME5,
|
|
2485
|
+
status: "fail",
|
|
2486
|
+
severity: "high",
|
|
2487
|
+
category: CATEGORY8,
|
|
2488
|
+
location: `package-lock.json (${depName}@${installedVersion})`,
|
|
2489
|
+
description: `Compromised package detected: ${depName}@${installedVersion}. Match: ${match.versionRange} (${match.source}, ${match.advisoryId}). Date added to list: ${match.dateAdded}. ${match.description}`,
|
|
2490
|
+
fix: `Remove ${depName} or pin to a version outside the compromised range (${match.versionRange}). Run \`npm audit\` for additional details.`,
|
|
2491
|
+
aiPrompt: `My project depends on ${depName}@${installedVersion} which is flagged as compromised (${match.advisoryId}). Help me find a safe alternative or a patched version. Explain what the compromise does and whether my project is affected.`
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
if (findings.length === 0) {
|
|
2496
|
+
return [
|
|
2497
|
+
{
|
|
2498
|
+
id: CHECK_ID12,
|
|
2499
|
+
name: CHECK_NAME5,
|
|
2500
|
+
status: "pass",
|
|
2501
|
+
severity: "info",
|
|
2502
|
+
category: CATEGORY8,
|
|
2503
|
+
description: `No compromised packages found among ${directDeps.length} direct dependencies.`
|
|
2504
|
+
}
|
|
2505
|
+
];
|
|
2506
|
+
}
|
|
2507
|
+
return findings;
|
|
2508
|
+
};
|
|
2509
|
+
var compromised_deps_default = compromisedDepsCheck;
|
|
2510
|
+
|
|
2511
|
+
// src/checks/supply-chain/npm-ci.ts
|
|
2512
|
+
import { readFile as readFile11, readdir } from "fs/promises";
|
|
2513
|
+
import { join as join12 } from "path";
|
|
2514
|
+
import { parse as parseYaml } from "yaml";
|
|
2515
|
+
var CHECK_ID13 = "npm-ci";
|
|
2516
|
+
var CHECK_NAME6 = "npm ci in CI workflows";
|
|
2517
|
+
var CATEGORY9 = "supply-chain";
|
|
2518
|
+
function isProblematicNpmCommand(cmdLine) {
|
|
2519
|
+
const trimmed = cmdLine.trim();
|
|
2520
|
+
const hasNpmInstall = /\bnpm\s+install\b/.test(trimmed) || /\bnpm\s+i\b/.test(trimmed);
|
|
2521
|
+
if (!hasNpmInstall) return false;
|
|
2522
|
+
if (/\s-g\b/.test(trimmed) || /\s--global\b/.test(trimmed)) return false;
|
|
2523
|
+
return true;
|
|
2524
|
+
}
|
|
2525
|
+
function extractRunCommands(parsedYaml) {
|
|
2526
|
+
if (!parsedYaml || typeof parsedYaml !== "object") return [];
|
|
2527
|
+
const workflow = parsedYaml;
|
|
2528
|
+
const jobs = workflow["jobs"];
|
|
2529
|
+
if (!jobs || typeof jobs !== "object") return [];
|
|
2530
|
+
const results = [];
|
|
2531
|
+
for (const job of Object.values(jobs)) {
|
|
2532
|
+
if (!job || typeof job !== "object") continue;
|
|
2533
|
+
const steps = job["steps"];
|
|
2534
|
+
if (!Array.isArray(steps)) continue;
|
|
2535
|
+
for (const step of steps) {
|
|
2536
|
+
if (!step || typeof step !== "object") continue;
|
|
2537
|
+
const stepObj = step;
|
|
2538
|
+
const run = stepObj["run"];
|
|
2539
|
+
if (typeof run !== "string") continue;
|
|
2540
|
+
const stepName = typeof stepObj["name"] === "string" ? stepObj["name"] : void 0;
|
|
2541
|
+
for (const line of run.split(/\r?\n/)) {
|
|
2542
|
+
const trimmed = line.trim();
|
|
2543
|
+
if (trimmed) {
|
|
2544
|
+
results.push({ stepName, command: trimmed });
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
return results;
|
|
2550
|
+
}
|
|
2551
|
+
var npmCiCheck = async (context) => {
|
|
2552
|
+
const workflowsDir = join12(context.projectPath, ".github", "workflows");
|
|
2553
|
+
let filenames;
|
|
2554
|
+
try {
|
|
2555
|
+
const entries = await readdir(workflowsDir);
|
|
2556
|
+
filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2559
|
+
if (isNotFound) {
|
|
2560
|
+
return [
|
|
2561
|
+
{
|
|
2562
|
+
id: CHECK_ID13,
|
|
2563
|
+
name: CHECK_NAME6,
|
|
2564
|
+
status: "skip",
|
|
2565
|
+
severity: "info",
|
|
2566
|
+
category: CATEGORY9,
|
|
2567
|
+
description: "No .github/workflows/ directory found \u2014 skipping CI workflow check."
|
|
2568
|
+
}
|
|
2569
|
+
];
|
|
2570
|
+
}
|
|
2571
|
+
return [
|
|
2572
|
+
{
|
|
2573
|
+
id: CHECK_ID13,
|
|
2574
|
+
name: CHECK_NAME6,
|
|
2575
|
+
status: "skip",
|
|
2576
|
+
severity: "info",
|
|
2577
|
+
category: CATEGORY9,
|
|
2578
|
+
description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2579
|
+
}
|
|
2580
|
+
];
|
|
2581
|
+
}
|
|
2582
|
+
if (filenames.length === 0) {
|
|
2583
|
+
return [
|
|
2584
|
+
{
|
|
2585
|
+
id: CHECK_ID13,
|
|
2586
|
+
name: CHECK_NAME6,
|
|
2587
|
+
status: "skip",
|
|
2588
|
+
severity: "info",
|
|
2589
|
+
category: CATEGORY9,
|
|
2590
|
+
description: "No workflow files found in .github/workflows/ \u2014 skipping CI workflow check."
|
|
2591
|
+
}
|
|
2592
|
+
];
|
|
2593
|
+
}
|
|
2594
|
+
const findings = [];
|
|
2595
|
+
for (const filename of filenames) {
|
|
2596
|
+
let content;
|
|
2597
|
+
try {
|
|
2598
|
+
content = await readFile11(join12(workflowsDir, filename), "utf-8");
|
|
2599
|
+
} catch {
|
|
2600
|
+
continue;
|
|
2601
|
+
}
|
|
2602
|
+
let parsed;
|
|
2603
|
+
try {
|
|
2604
|
+
parsed = parseYaml(content);
|
|
2605
|
+
} catch {
|
|
2606
|
+
continue;
|
|
2607
|
+
}
|
|
2608
|
+
const commands = extractRunCommands(parsed);
|
|
2609
|
+
for (const { stepName, command } of commands) {
|
|
2610
|
+
if (isProblematicNpmCommand(command)) {
|
|
2611
|
+
findings.push({
|
|
2612
|
+
id: CHECK_ID13,
|
|
2613
|
+
name: CHECK_NAME6,
|
|
2614
|
+
status: "warn",
|
|
2615
|
+
severity: "medium",
|
|
2616
|
+
category: CATEGORY9,
|
|
2617
|
+
location: `.github/workflows/${filename}`,
|
|
2618
|
+
description: `CI workflow uses \`npm install\` instead of \`npm ci\`: ${filename}. Step: ${stepName ?? "(unnamed)"}. Command: \`${command}\`. npm install can mutate package-lock.json, bypassing lockfile integrity.`,
|
|
2619
|
+
fix: "Replace `npm install` with `npm ci` which enforces lockfile-based installs.",
|
|
2620
|
+
aiPrompt: `My GitHub Actions workflow ${filename} uses \`npm install\` instead of \`npm ci\`. Explain the security implications of npm install mutating the lockfile in CI, and help me update the workflow to use npm ci correctly. Note any cases where npm install might still be needed (e.g., global tool installs).`
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
if (findings.length === 0) {
|
|
2626
|
+
return [
|
|
2627
|
+
{
|
|
2628
|
+
id: CHECK_ID13,
|
|
2629
|
+
name: CHECK_NAME6,
|
|
2630
|
+
status: "pass",
|
|
2631
|
+
severity: "info",
|
|
2632
|
+
category: CATEGORY9,
|
|
2633
|
+
description: `All ${filenames.length} workflow file(s) use npm ci or have no npm install commands.`
|
|
2634
|
+
}
|
|
2635
|
+
];
|
|
2636
|
+
}
|
|
2637
|
+
return findings;
|
|
2638
|
+
};
|
|
2639
|
+
var npm_ci_default = npmCiCheck;
|
|
2640
|
+
|
|
2641
|
+
// src/checks/supply-chain/self-hosted-runner.ts
|
|
2642
|
+
import { readFile as readFile12, readdir as readdir2 } from "fs/promises";
|
|
2643
|
+
import { join as join13 } from "path";
|
|
2644
|
+
import { parse as parseYaml2 } from "yaml";
|
|
2645
|
+
var CHECK_ID14 = "self-hosted-runner";
|
|
2646
|
+
var CHECK_NAME7 = "Self-hosted runner detection";
|
|
2647
|
+
var CATEGORY10 = "supply-chain";
|
|
2648
|
+
var GITHUB_HOSTED_REGEX = /^(ubuntu|macos|windows)(-(latest|\d+(\.\d+)?))?$/;
|
|
2649
|
+
var IOC_PATTERNS = ["sha1hulud", "shai-hulud", "sha1-hulud", "hulud"];
|
|
2650
|
+
function categorizeRunner(label) {
|
|
2651
|
+
const trimmed = label.trim();
|
|
2652
|
+
const lower = trimmed.toLowerCase();
|
|
2653
|
+
if (IOC_PATTERNS.some((pattern) => lower.includes(pattern))) {
|
|
2654
|
+
return "ioc-match";
|
|
2655
|
+
}
|
|
2656
|
+
if (lower.includes("self-hosted")) {
|
|
2657
|
+
return "self-hosted";
|
|
2658
|
+
}
|
|
2659
|
+
if (GITHUB_HOSTED_REGEX.test(trimmed)) {
|
|
2660
|
+
return "github-hosted";
|
|
2661
|
+
}
|
|
2662
|
+
return "unknown";
|
|
2663
|
+
}
|
|
2664
|
+
function extractRunsOn(parsedYaml) {
|
|
2665
|
+
if (!parsedYaml || typeof parsedYaml !== "object") return [];
|
|
2666
|
+
const workflow = parsedYaml;
|
|
2667
|
+
const jobs = workflow["jobs"];
|
|
2668
|
+
if (!jobs || typeof jobs !== "object") return [];
|
|
2669
|
+
const results = [];
|
|
2670
|
+
for (const [jobName, job] of Object.entries(jobs)) {
|
|
2671
|
+
if (!job || typeof job !== "object") continue;
|
|
2672
|
+
const runsOn = job["runs-on"];
|
|
2673
|
+
if (!runsOn) continue;
|
|
2674
|
+
if (typeof runsOn === "string") {
|
|
2675
|
+
results.push({ jobName, runsOn: [runsOn] });
|
|
2676
|
+
} else if (Array.isArray(runsOn)) {
|
|
2677
|
+
const labels = runsOn.filter((item) => typeof item === "string");
|
|
2678
|
+
if (labels.length > 0) {
|
|
2679
|
+
results.push({ jobName, runsOn: labels });
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
return results;
|
|
2684
|
+
}
|
|
2685
|
+
var CATEGORY_PRIORITY = {
|
|
2686
|
+
"ioc-match": 3,
|
|
2687
|
+
"self-hosted": 2,
|
|
2688
|
+
"unknown": 1,
|
|
2689
|
+
"github-hosted": 0
|
|
2690
|
+
};
|
|
2691
|
+
function highestCategory(labels) {
|
|
2692
|
+
let highest = "github-hosted";
|
|
2693
|
+
for (const label of labels) {
|
|
2694
|
+
const cat = categorizeRunner(label);
|
|
2695
|
+
if (CATEGORY_PRIORITY[cat] > CATEGORY_PRIORITY[highest]) {
|
|
2696
|
+
highest = cat;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
return highest;
|
|
2700
|
+
}
|
|
2701
|
+
var selfHostedRunnerCheck = async (context) => {
|
|
2702
|
+
const workflowsDir = join13(context.projectPath, ".github", "workflows");
|
|
2703
|
+
let filenames;
|
|
2704
|
+
try {
|
|
2705
|
+
const entries = await readdir2(workflowsDir);
|
|
2706
|
+
filenames = entries.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
const isNotFound = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
2709
|
+
if (isNotFound) {
|
|
2710
|
+
return [
|
|
2711
|
+
{
|
|
2712
|
+
id: CHECK_ID14,
|
|
2713
|
+
name: CHECK_NAME7,
|
|
2714
|
+
status: "skip",
|
|
2715
|
+
severity: "info",
|
|
2716
|
+
category: CATEGORY10,
|
|
2717
|
+
description: "No .github/workflows/ directory found \u2014 skipping runner detection."
|
|
2718
|
+
}
|
|
2719
|
+
];
|
|
2720
|
+
}
|
|
2721
|
+
return [
|
|
2722
|
+
{
|
|
2723
|
+
id: CHECK_ID14,
|
|
2724
|
+
name: CHECK_NAME7,
|
|
2725
|
+
status: "skip",
|
|
2726
|
+
severity: "info",
|
|
2727
|
+
category: CATEGORY10,
|
|
2728
|
+
description: `Check failed: ${error instanceof Error ? error.message : String(error)}`
|
|
2729
|
+
}
|
|
2730
|
+
];
|
|
2731
|
+
}
|
|
2732
|
+
if (filenames.length === 0) {
|
|
2733
|
+
return [
|
|
2734
|
+
{
|
|
2735
|
+
id: CHECK_ID14,
|
|
2736
|
+
name: CHECK_NAME7,
|
|
2737
|
+
status: "skip",
|
|
2738
|
+
severity: "info",
|
|
2739
|
+
category: CATEGORY10,
|
|
2740
|
+
description: "No workflow files found in .github/workflows/ \u2014 skipping runner detection."
|
|
2741
|
+
}
|
|
2742
|
+
];
|
|
2743
|
+
}
|
|
2744
|
+
const findings = [];
|
|
2745
|
+
for (const filename of filenames) {
|
|
2746
|
+
let content;
|
|
2747
|
+
try {
|
|
2748
|
+
content = await readFile12(join13(workflowsDir, filename), "utf-8");
|
|
2749
|
+
} catch {
|
|
2750
|
+
continue;
|
|
2751
|
+
}
|
|
2752
|
+
let parsed;
|
|
2753
|
+
try {
|
|
2754
|
+
parsed = parseYaml2(content);
|
|
2755
|
+
} catch {
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2758
|
+
const jobsRunsOn = extractRunsOn(parsed);
|
|
2759
|
+
for (const { jobName, runsOn } of jobsRunsOn) {
|
|
2760
|
+
const category = highestCategory(runsOn);
|
|
2761
|
+
const labelsDisplay = runsOn.join(", ");
|
|
2762
|
+
if (category === "ioc-match") {
|
|
2763
|
+
findings.push({
|
|
2764
|
+
id: CHECK_ID14,
|
|
2765
|
+
name: CHECK_NAME7,
|
|
2766
|
+
status: "fail",
|
|
2767
|
+
severity: "high",
|
|
2768
|
+
category: CATEGORY10,
|
|
2769
|
+
location: `.github/workflows/${filename}`,
|
|
2770
|
+
description: `Suspicious runner label matches known IoC: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. This label matches known indicators of compromise from the Shai-Hulud / SHA1-Hulud worm.`,
|
|
2771
|
+
fix: "IMMEDIATE ACTION: Inspect this repository's recent commits, workflow files, and any associated infrastructure. This may be evidence of active compromise. Remove the suspicious runner and audit all recent workflow runs.",
|
|
2772
|
+
aiPrompt: `My GitHub Actions workflow ${filename} has a runner label "${labelsDisplay}" that matches known IoC patterns from the Shai-Hulud worm. Help me investigate: what should I check for signs of compromise, how do I audit recent workflow runs, and what remediation steps should I take immediately?`
|
|
2773
|
+
});
|
|
2774
|
+
} else if (category === "self-hosted") {
|
|
2775
|
+
findings.push({
|
|
2776
|
+
id: CHECK_ID14,
|
|
2777
|
+
name: CHECK_NAME7,
|
|
2778
|
+
status: "warn",
|
|
2779
|
+
severity: "medium",
|
|
2780
|
+
category: CATEGORY10,
|
|
2781
|
+
location: `.github/workflows/${filename}`,
|
|
2782
|
+
description: `Self-hosted runner in workflow: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. Self-hosted runners can be a persistence vector for supply-chain attacks.`,
|
|
2783
|
+
fix: "Verify this runner was intentionally configured. If not, this may indicate compromise. Ensure self-hosted runners are hardened, regularly patched, and use ephemeral instances where possible.",
|
|
2784
|
+
aiPrompt: `My GitHub Actions workflow ${filename} uses a self-hosted runner (${labelsDisplay}). Explain the supply-chain security risks of self-hosted runners, best practices for hardening them, and how to verify this configuration is intentional and safe.`
|
|
2785
|
+
});
|
|
2786
|
+
} else if (category === "unknown") {
|
|
2787
|
+
findings.push({
|
|
2788
|
+
id: CHECK_ID14,
|
|
2789
|
+
name: CHECK_NAME7,
|
|
2790
|
+
status: "pass",
|
|
2791
|
+
severity: "info",
|
|
2792
|
+
category: CATEGORY10,
|
|
2793
|
+
location: `.github/workflows/${filename}`,
|
|
2794
|
+
description: `Custom/unrecognized runner label: ${filename}. Job: ${jobName}. Runs-on: ${labelsDisplay}. This label doesn't match standard GitHub-hosted runner patterns. If this is intentional (e.g., self-hosted with a custom name), document it.`
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
if (findings.length === 0) {
|
|
2800
|
+
return [
|
|
2801
|
+
{
|
|
2802
|
+
id: CHECK_ID14,
|
|
2803
|
+
name: CHECK_NAME7,
|
|
2804
|
+
status: "pass",
|
|
2805
|
+
severity: "info",
|
|
2806
|
+
category: CATEGORY10,
|
|
2807
|
+
description: `All ${filenames.length} workflow file(s) use standard GitHub-hosted runners.`
|
|
2808
|
+
}
|
|
2809
|
+
];
|
|
2810
|
+
}
|
|
2811
|
+
return findings;
|
|
2812
|
+
};
|
|
2813
|
+
var self_hosted_runner_default = selfHostedRunnerCheck;
|
|
2814
|
+
|
|
2275
2815
|
// src/checks/index.ts
|
|
2276
2816
|
function getAllChecks() {
|
|
2277
2817
|
return [
|
|
@@ -2289,7 +2829,11 @@ function getAllChecks() {
|
|
|
2289
2829
|
auth_default,
|
|
2290
2830
|
cookies_default,
|
|
2291
2831
|
server_disclosure_default,
|
|
2292
|
-
dmarc_default
|
|
2832
|
+
dmarc_default,
|
|
2833
|
+
ignore_scripts_default,
|
|
2834
|
+
compromised_deps_default,
|
|
2835
|
+
npm_ci_default,
|
|
2836
|
+
self_hosted_runner_default
|
|
2293
2837
|
];
|
|
2294
2838
|
}
|
|
2295
2839
|
function getUrlOnlyChecks() {
|
|
@@ -2511,7 +3055,7 @@ var corsPrompt = (result, context) => {
|
|
|
2511
3055
|
const framework = context.stack.framework ?? "my web application";
|
|
2512
3056
|
return `${stack}${loc} ${result.description.split(".")[0]}. Help me fix this by: (1) replacing the wildcard origin with my specific domain(s), (2) showing the correct CORS configuration for ${framework}, (3) handling preflight OPTIONS requests properly, and (4) configuring credentials mode safely. Show me the complete working code.`;
|
|
2513
3057
|
};
|
|
2514
|
-
var rateLimitPrompt = (
|
|
3058
|
+
var rateLimitPrompt = (_result, context) => {
|
|
2515
3059
|
const stack = buildStackDescription(context.stack);
|
|
2516
3060
|
const framework = context.stack.framework;
|
|
2517
3061
|
const pkg2 = getRateLimitPackage(context.stack);
|
|
@@ -2643,18 +3187,30 @@ function detectProjectType(packageJson, files) {
|
|
|
2643
3187
|
}
|
|
2644
3188
|
async function buildContext(options) {
|
|
2645
3189
|
const projectPath = resolve(options.path);
|
|
3190
|
+
if (options.urlOnly) {
|
|
3191
|
+
return {
|
|
3192
|
+
projectPath,
|
|
3193
|
+
url: options.url,
|
|
3194
|
+
stack: { language: "unknown" },
|
|
3195
|
+
files: [],
|
|
3196
|
+
verbose: options.verbose,
|
|
3197
|
+
projectType: "static",
|
|
3198
|
+
projectTypeSource: "auto",
|
|
3199
|
+
urlOnly: true
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
2646
3202
|
const info = await stat(projectPath).catch(() => null);
|
|
2647
3203
|
if (!info?.isDirectory()) {
|
|
2648
3204
|
throw new Error(`Not a directory: ${projectPath}`);
|
|
2649
3205
|
}
|
|
2650
3206
|
let packageJson;
|
|
2651
3207
|
try {
|
|
2652
|
-
const raw = await
|
|
3208
|
+
const raw = await readFile13(join14(projectPath, "package.json"), "utf-8");
|
|
2653
3209
|
packageJson = JSON.parse(raw);
|
|
2654
3210
|
} catch {
|
|
2655
3211
|
}
|
|
2656
|
-
const entries = await
|
|
2657
|
-
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)));
|
|
2658
3214
|
const stack = detectStack(packageJson, files);
|
|
2659
3215
|
const typeOverride = options.type ?? "auto";
|
|
2660
3216
|
const projectType = typeOverride === "auto" ? detectProjectType(packageJson, files) : typeOverride;
|
|
@@ -2697,17 +3253,17 @@ async function runChecks(context, checks) {
|
|
|
2697
3253
|
}
|
|
2698
3254
|
var PROJECT_INDICATORS = ["package.json", ".gitignore", ".git", "src", "lib"];
|
|
2699
3255
|
var CODE_EXTENSIONS2 = /\.(ts|js|tsx|jsx)$/;
|
|
2700
|
-
function hasProjectFiles(files,
|
|
3256
|
+
function hasProjectFiles(files, _projectPath, packageJson) {
|
|
2701
3257
|
if (packageJson) return true;
|
|
2702
3258
|
for (const f of files) {
|
|
2703
|
-
const first = f.split("/")[0];
|
|
3259
|
+
const first = f.split("/")[0] ?? "";
|
|
2704
3260
|
if (PROJECT_INDICATORS.includes(first)) return true;
|
|
2705
3261
|
if (CODE_EXTENSIONS2.test(f)) return true;
|
|
2706
3262
|
}
|
|
2707
3263
|
return false;
|
|
2708
3264
|
}
|
|
2709
3265
|
async function scan(context) {
|
|
2710
|
-
const isUrlOnly = context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
|
|
3266
|
+
const isUrlOnly = context.urlOnly || !!context.url && !hasProjectFiles(context.files, context.projectPath, context.packageJson);
|
|
2711
3267
|
if (isUrlOnly) {
|
|
2712
3268
|
const report2 = await runChecks(context, getUrlOnlyChecks());
|
|
2713
3269
|
return { ...report2, urlOnly: true, projectType: context.projectType, projectTypeSource: context.projectTypeSource };
|
|
@@ -2877,7 +3433,7 @@ function formatJsonReport(report, metadata) {
|
|
|
2877
3433
|
|
|
2878
3434
|
// src/generators/config.ts
|
|
2879
3435
|
import { writeFile, mkdir } from "fs/promises";
|
|
2880
|
-
import { join as
|
|
3436
|
+
import { join as join15 } from "path";
|
|
2881
3437
|
function expressHelmetConfig() {
|
|
2882
3438
|
return {
|
|
2883
3439
|
name: "Helmet.js Security Headers",
|
|
@@ -3398,21 +3954,211 @@ async function writeConfigFiles(snippets, outputDir) {
|
|
|
3398
3954
|
await mkdir(outputDir, { recursive: true });
|
|
3399
3955
|
const paths = [];
|
|
3400
3956
|
for (const snippet of snippets) {
|
|
3401
|
-
const filePath =
|
|
3957
|
+
const filePath = join15(outputDir, snippet.filename);
|
|
3402
3958
|
await writeFile(filePath, snippet.code + "\n", "utf-8");
|
|
3403
3959
|
paths.push(filePath);
|
|
3404
3960
|
}
|
|
3405
3961
|
return paths;
|
|
3406
3962
|
}
|
|
3407
3963
|
|
|
3964
|
+
// src/generators/security-txt.ts
|
|
3965
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
3966
|
+
import { createInterface } from "readline";
|
|
3967
|
+
import { dirname, join as join16, resolve as resolve2 } from "path";
|
|
3968
|
+
import chalk2 from "chalk";
|
|
3969
|
+
function generateSecurityTxt(fields) {
|
|
3970
|
+
const lines = [
|
|
3971
|
+
"# security.txt \u2014 generated by Bastion",
|
|
3972
|
+
"# https://securitytxt.org/ | RFC 9116",
|
|
3973
|
+
"",
|
|
3974
|
+
`Contact: ${fields.contact}`,
|
|
3975
|
+
`Expires: ${fields.expires}`
|
|
3976
|
+
];
|
|
3977
|
+
if (fields.preferredLanguages) {
|
|
3978
|
+
lines.push(`Preferred-Languages: ${fields.preferredLanguages}`);
|
|
3979
|
+
}
|
|
3980
|
+
if (fields.policy) {
|
|
3981
|
+
lines.push(`Policy: ${fields.policy}`);
|
|
3982
|
+
}
|
|
3983
|
+
if (fields.acknowledgments) {
|
|
3984
|
+
lines.push(`Acknowledgments: ${fields.acknowledgments}`);
|
|
3985
|
+
}
|
|
3986
|
+
lines.push("");
|
|
3987
|
+
return lines.join("\n");
|
|
3988
|
+
}
|
|
3989
|
+
function defaultExpiresDate() {
|
|
3990
|
+
const date = /* @__PURE__ */ new Date();
|
|
3991
|
+
date.setFullYear(date.getFullYear() + 1);
|
|
3992
|
+
return date.toISOString();
|
|
3993
|
+
}
|
|
3994
|
+
function normalizeContact(value) {
|
|
3995
|
+
const trimmed = value.trim();
|
|
3996
|
+
if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && trimmed.includes("@")) {
|
|
3997
|
+
return `mailto:${trimmed}`;
|
|
3998
|
+
}
|
|
3999
|
+
return trimmed;
|
|
4000
|
+
}
|
|
4001
|
+
function validateContact(value) {
|
|
4002
|
+
const trimmed = value.trim();
|
|
4003
|
+
if (!trimmed) {
|
|
4004
|
+
return "Contact is required";
|
|
4005
|
+
}
|
|
4006
|
+
if (!trimmed.startsWith("mailto:") && !trimmed.startsWith("https://") && !trimmed.includes("@")) {
|
|
4007
|
+
return "Contact must be an email address, mailto: URI, or https:// URL";
|
|
4008
|
+
}
|
|
4009
|
+
return null;
|
|
4010
|
+
}
|
|
4011
|
+
function validateExpires(value) {
|
|
4012
|
+
const date = new Date(value);
|
|
4013
|
+
if (isNaN(date.getTime())) {
|
|
4014
|
+
return "Invalid date format. Use ISO 8601 (e.g., 2027-04-15T00:00:00.000Z)";
|
|
4015
|
+
}
|
|
4016
|
+
if (date.getTime() <= Date.now()) {
|
|
4017
|
+
return "Expires date must be in the future";
|
|
4018
|
+
}
|
|
4019
|
+
return null;
|
|
4020
|
+
}
|
|
4021
|
+
function promptField(rl, question, defaultValue) {
|
|
4022
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
4023
|
+
return new Promise((res) => {
|
|
4024
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
4025
|
+
res(answer.trim() || defaultValue || "");
|
|
4026
|
+
});
|
|
4027
|
+
});
|
|
4028
|
+
}
|
|
4029
|
+
async function promptForFields(rl) {
|
|
4030
|
+
console.log();
|
|
4031
|
+
console.log(chalk2.bold.cyan(" security.txt Generator"));
|
|
4032
|
+
console.log(chalk2.dim(" Creates a valid security.txt file per RFC 9116"));
|
|
4033
|
+
console.log();
|
|
4034
|
+
let contact = "";
|
|
4035
|
+
while (!contact) {
|
|
4036
|
+
const raw = await promptField(rl, "Contact email or URL");
|
|
4037
|
+
const error = validateContact(raw);
|
|
4038
|
+
if (error) {
|
|
4039
|
+
console.log(chalk2.red(` ${error}`));
|
|
4040
|
+
} else {
|
|
4041
|
+
contact = normalizeContact(raw);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
const expires = await promptField(rl, "Expires date (ISO 8601)", defaultExpiresDate());
|
|
4045
|
+
const preferredLanguages = await promptField(rl, "Preferred-Languages", "en");
|
|
4046
|
+
const policy = await promptField(rl, "Policy URL (optional)");
|
|
4047
|
+
const acknowledgments = await promptField(rl, "Acknowledgments URL (optional)");
|
|
4048
|
+
const rootAnswer = await promptField(rl, "Also create at project root? (y/N)");
|
|
4049
|
+
const writeToRoot = rootAnswer.toLowerCase() === "y" || rootAnswer.toLowerCase() === "yes";
|
|
4050
|
+
return {
|
|
4051
|
+
fields: {
|
|
4052
|
+
contact,
|
|
4053
|
+
expires,
|
|
4054
|
+
preferredLanguages,
|
|
4055
|
+
...policy ? { policy } : {},
|
|
4056
|
+
...acknowledgments ? { acknowledgments } : {}
|
|
4057
|
+
},
|
|
4058
|
+
writeToRoot
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
async function writeSecurityTxtFiles(projectPath, content, locations) {
|
|
4062
|
+
const written = [];
|
|
4063
|
+
for (const location of locations) {
|
|
4064
|
+
const fullPath = join16(projectPath, location);
|
|
4065
|
+
await mkdir2(dirname(fullPath), { recursive: true });
|
|
4066
|
+
await writeFile2(fullPath, content, "utf-8");
|
|
4067
|
+
written.push(fullPath);
|
|
4068
|
+
}
|
|
4069
|
+
return written;
|
|
4070
|
+
}
|
|
4071
|
+
function printResult(content, paths) {
|
|
4072
|
+
console.log();
|
|
4073
|
+
console.log(chalk2.bold.green(" \u2713 security.txt generated"));
|
|
4074
|
+
console.log();
|
|
4075
|
+
console.log(chalk2.dim(" Contents:"));
|
|
4076
|
+
console.log();
|
|
4077
|
+
for (const line of content.split("\n")) {
|
|
4078
|
+
if (line) {
|
|
4079
|
+
console.log(` ${line}`);
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
console.log();
|
|
4083
|
+
for (const p of paths) {
|
|
4084
|
+
console.log(` ${chalk2.dim("Saved:")} ${p}`);
|
|
4085
|
+
}
|
|
4086
|
+
console.log();
|
|
4087
|
+
}
|
|
4088
|
+
async function runSecurityTxtGenerator(options) {
|
|
4089
|
+
const projectPath = resolve2(options.path);
|
|
4090
|
+
try {
|
|
4091
|
+
let fields;
|
|
4092
|
+
let writeToRoot = false;
|
|
4093
|
+
if (options.contact) {
|
|
4094
|
+
const contact = normalizeContact(options.contact);
|
|
4095
|
+
const contactError = validateContact(contact);
|
|
4096
|
+
if (contactError) {
|
|
4097
|
+
console.error(chalk2.red(`
|
|
4098
|
+
Error: ${contactError}
|
|
4099
|
+
`));
|
|
4100
|
+
process.exitCode = 1;
|
|
4101
|
+
return;
|
|
4102
|
+
}
|
|
4103
|
+
const expires = options.expires ?? defaultExpiresDate();
|
|
4104
|
+
const expiresError = validateExpires(expires);
|
|
4105
|
+
if (expiresError) {
|
|
4106
|
+
console.error(chalk2.red(`
|
|
4107
|
+
Error: ${expiresError}
|
|
4108
|
+
`));
|
|
4109
|
+
process.exitCode = 1;
|
|
4110
|
+
return;
|
|
4111
|
+
}
|
|
4112
|
+
fields = {
|
|
4113
|
+
contact,
|
|
4114
|
+
expires,
|
|
4115
|
+
preferredLanguages: options.languages ?? "en",
|
|
4116
|
+
...options.policy ? { policy: options.policy } : {},
|
|
4117
|
+
...options.acknowledgments ? { acknowledgments: options.acknowledgments } : {}
|
|
4118
|
+
};
|
|
4119
|
+
} else {
|
|
4120
|
+
const rl = createInterface({
|
|
4121
|
+
input: process.stdin,
|
|
4122
|
+
output: process.stdout
|
|
4123
|
+
});
|
|
4124
|
+
try {
|
|
4125
|
+
const result = await promptForFields(rl);
|
|
4126
|
+
fields = result.fields;
|
|
4127
|
+
writeToRoot = result.writeToRoot;
|
|
4128
|
+
} finally {
|
|
4129
|
+
rl.close();
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
const content = generateSecurityTxt(fields);
|
|
4133
|
+
const locations = [".well-known/security.txt"];
|
|
4134
|
+
if (writeToRoot) {
|
|
4135
|
+
locations.push("security.txt");
|
|
4136
|
+
}
|
|
4137
|
+
const written = await writeSecurityTxtFiles(projectPath, content, locations);
|
|
4138
|
+
printResult(content, written);
|
|
4139
|
+
} catch (error) {
|
|
4140
|
+
console.error(
|
|
4141
|
+
chalk2.red(
|
|
4142
|
+
`
|
|
4143
|
+
Error: ${error instanceof Error ? error.message : String(error)}
|
|
4144
|
+
`
|
|
4145
|
+
)
|
|
4146
|
+
);
|
|
4147
|
+
process.exitCode = 1;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
|
|
3408
4151
|
// src/cli.ts
|
|
4152
|
+
function computeUrlOnly(options, command) {
|
|
4153
|
+
return !!options.url && command.getOptionValueSource("path") === "default";
|
|
4154
|
+
}
|
|
3409
4155
|
function createProgram(version) {
|
|
3410
4156
|
const program = new Command();
|
|
3411
|
-
program.name("bastion").description("Privacy-first security checker for
|
|
4157
|
+
program.name("bastion").description("Privacy-first security checker for web projects").version(version);
|
|
3412
4158
|
program.command("scan").description("Scan a project for security issues").option("-p, --path <dir>", "path to project directory", ".").option("-f, --format <type>", "output format (terminal, json, markdown)", "terminal").option("-v, --verbose", "show detailed output", false).option("-u, --url <url>", "URL for HTTP-based checks").option("-o, --output <file>", "output file path (for markdown/json formats)").option("--generate-configs", "generate security config snippets for detected stack", false).option("--output-dir <dir>", "write generated config files to directory").addOption(
|
|
3413
4159
|
new Option("-t, --type <type>", "project type override").choices(["auto", "static", "api", "fullstack"]).default("auto")
|
|
3414
|
-
).action(async (options) => {
|
|
3415
|
-
await runScan(options, version);
|
|
4160
|
+
).action(async (options, command) => {
|
|
4161
|
+
await runScan({ ...options, urlOnly: computeUrlOnly(options, command) }, version);
|
|
3416
4162
|
});
|
|
3417
4163
|
const generate = program.command("generate").description("Generate security configuration files");
|
|
3418
4164
|
generate.command("security-txt").description("Create a valid security.txt file (RFC 9116)").option("-c, --contact <value>", "contact email or URL (enables non-interactive mode)").option("-e, --expires <date>", "expires date in ISO 8601 (default: 1 year from now)").option("-l, --languages <langs>", "preferred languages (default: en)").option("--policy <url>", "policy URL").option("--acknowledgments <url>", "acknowledgments URL").option("-p, --path <dir>", "project directory", ".").action(async (options) => {
|
|
@@ -3427,21 +4173,21 @@ async function runScan(options, version) {
|
|
|
3427
4173
|
}
|
|
3428
4174
|
if (!isValidFormat(options.format)) {
|
|
3429
4175
|
console.error(
|
|
3430
|
-
|
|
4176
|
+
chalk3.red(`
|
|
3431
4177
|
Error: Invalid format "${options.format}". Use: ${OUTPUT_FORMATS.join(", ")}`)
|
|
3432
4178
|
);
|
|
3433
4179
|
process.exitCode = 1;
|
|
3434
4180
|
return;
|
|
3435
4181
|
}
|
|
3436
4182
|
if (!isJson && options.verbose) {
|
|
3437
|
-
const { resolve:
|
|
3438
|
-
console.log(
|
|
3439
|
-
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}`));
|
|
3440
4186
|
if (options.url) {
|
|
3441
|
-
console.log(
|
|
4187
|
+
console.log(chalk3.dim(` URL: ${options.url}`));
|
|
3442
4188
|
}
|
|
3443
4189
|
if (options.type !== "auto") {
|
|
3444
|
-
console.log(
|
|
4190
|
+
console.log(chalk3.dim(` Type: ${options.type} (manual override)`));
|
|
3445
4191
|
}
|
|
3446
4192
|
console.log();
|
|
3447
4193
|
}
|
|
@@ -3451,7 +4197,8 @@ async function runScan(options, version) {
|
|
|
3451
4197
|
path: options.path,
|
|
3452
4198
|
url: options.url,
|
|
3453
4199
|
verbose: options.verbose,
|
|
3454
|
-
type: options.type
|
|
4200
|
+
type: options.type,
|
|
4201
|
+
urlOnly: options.urlOnly
|
|
3455
4202
|
});
|
|
3456
4203
|
const report = await scan(context);
|
|
3457
4204
|
if (isJson) {
|
|
@@ -3467,16 +4214,16 @@ async function runScan(options, version) {
|
|
|
3467
4214
|
} else {
|
|
3468
4215
|
spinner?.succeed(`Scan complete (${report.duration}ms)`);
|
|
3469
4216
|
if (report.urlOnly) {
|
|
3470
|
-
console.log(
|
|
3471
|
-
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"));
|
|
3472
4219
|
}
|
|
3473
4220
|
if (report.projectType && report.projectType !== "unknown") {
|
|
3474
4221
|
const source = report.projectTypeSource === "manual" ? "manual" : "auto-detected";
|
|
3475
|
-
console.log(
|
|
4222
|
+
console.log(chalk3.dim(`
|
|
3476
4223
|
Project type: ${report.projectType} (${source})`));
|
|
3477
4224
|
}
|
|
3478
4225
|
if (report.projectType === "static" && report.summary.notApplicable > 0) {
|
|
3479
|
-
console.log(
|
|
4226
|
+
console.log(chalk3.dim(` Static site detected \u2014 ${report.summary.notApplicable} checks not applicable`));
|
|
3480
4227
|
}
|
|
3481
4228
|
console.log(formatTerminalReport(report, options.verbose));
|
|
3482
4229
|
}
|
|
@@ -3485,10 +4232,10 @@ async function runScan(options, version) {
|
|
|
3485
4232
|
if (options.outputDir) {
|
|
3486
4233
|
const paths = await writeConfigFiles(snippets, options.outputDir);
|
|
3487
4234
|
if (!isJson) {
|
|
3488
|
-
console.log(
|
|
4235
|
+
console.log(chalk3.green(`
|
|
3489
4236
|
\u2713 Wrote ${paths.length} config file${paths.length === 1 ? "" : "s"} to ${options.outputDir}/`));
|
|
3490
4237
|
for (const p of paths) {
|
|
3491
|
-
console.log(
|
|
4238
|
+
console.log(chalk3.dim(` ${p}`));
|
|
3492
4239
|
}
|
|
3493
4240
|
console.log();
|
|
3494
4241
|
}
|
|
@@ -3505,7 +4252,7 @@ async function runScan(options, version) {
|
|
|
3505
4252
|
} else {
|
|
3506
4253
|
spinner?.fail("Scan failed");
|
|
3507
4254
|
console.error(
|
|
3508
|
-
|
|
4255
|
+
chalk3.red(`
|
|
3509
4256
|
${error instanceof Error ? error.message : String(error)}
|
|
3510
4257
|
`)
|
|
3511
4258
|
);
|
|
@@ -3515,8 +4262,8 @@ async function runScan(options, version) {
|
|
|
3515
4262
|
}
|
|
3516
4263
|
function printBanner(version) {
|
|
3517
4264
|
console.log();
|
|
3518
|
-
console.log(
|
|
3519
|
-
console.log(
|
|
4265
|
+
console.log(chalk3.bold.cyan(" Bastion") + chalk3.dim(` v${version}`));
|
|
4266
|
+
console.log(chalk3.dim(" Privacy-first security checker"));
|
|
3520
4267
|
console.log();
|
|
3521
4268
|
}
|
|
3522
4269
|
function isValidFormat(format) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bastion-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Privacy-first security checker for web projects. 15 checks, zero data uploaded, actionable fixes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,15 +34,20 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsup",
|
|
37
|
-
"dev": "tsup --watch"
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"test": "vitest run --root ../..",
|
|
39
|
+
"prepublishOnly": "npm run build && npm run test"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
|
-
"bastion-shared": "^0.1.3",
|
|
41
42
|
"chalk": "^5.6.2",
|
|
42
43
|
"commander": "^14.0.3",
|
|
43
|
-
"ora": "^9.3.0"
|
|
44
|
+
"ora": "^9.3.0",
|
|
45
|
+
"semver": "^7.8.0",
|
|
46
|
+
"yaml": "^2.9.0"
|
|
44
47
|
},
|
|
45
48
|
"devDependencies": {
|
|
49
|
+
"@bastion/shared": "*",
|
|
50
|
+
"@types/semver": "^7.7.1",
|
|
46
51
|
"tsup": "^8.0.0"
|
|
47
52
|
}
|
|
48
53
|
}
|