seaworthycode 1.1.4 → 1.2.3
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/dist/index.js +709 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,13 +6,14 @@ import { existsSync as existsSync6 } from "fs";
|
|
|
6
6
|
import { dirname as dirname7, resolve as resolve4 } from "path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
8
8
|
import { cac } from "cac";
|
|
9
|
-
import { createRequire as
|
|
9
|
+
import { createRequire as createRequire3 } from "module";
|
|
10
10
|
|
|
11
11
|
// src/cli-action.ts
|
|
12
12
|
import { stat, access as access2 } from "fs/promises";
|
|
13
13
|
import { resolve as resolve3 } from "path";
|
|
14
14
|
import { homedir as homedir3 } from "os";
|
|
15
15
|
import { join as join15 } from "path";
|
|
16
|
+
import { createRequire as createRequire2 } from "module";
|
|
16
17
|
|
|
17
18
|
// ../core/dist/index.js
|
|
18
19
|
import { readFileSync } from "fs";
|
|
@@ -36,6 +37,8 @@ import { createHash } from "crypto";
|
|
|
36
37
|
import { isAbsolute, relative as relative3 } from "path";
|
|
37
38
|
import { pathToFileURL } from "url";
|
|
38
39
|
import { z } from "zod";
|
|
40
|
+
import { lookup } from "dns/promises";
|
|
41
|
+
import { request as httpsRequest } from "https";
|
|
39
42
|
import { jwtVerify, createLocalJWKSet } from "jose";
|
|
40
43
|
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
|
|
41
44
|
import { join as join6, resolve as pathResolve2 } from "path";
|
|
@@ -1440,6 +1443,52 @@ var copy = {
|
|
|
1440
1443
|
"checks.sql-missing-rls.message": (match) => `RLS is not enabled or lacks restriction on a private/tenant table: ${match}`,
|
|
1441
1444
|
"checks.sql-missing-rls.degraded": (path, language) => `${language} analysis was incomplete for ${path} (no grammar available)`,
|
|
1442
1445
|
"remediation.security.sql-missing-rls": "Define a policy with CREATE POLICY ... ON ... USING (...) to restrict data access to authorized users.",
|
|
1446
|
+
// Security headers
|
|
1447
|
+
"checks.missing-security-headers.name": "Missing HTTP security headers",
|
|
1448
|
+
"checks.missing-security-headers.description": "Application does not set HTTP security response headers (HSTS, X-Content-Type-Options, X-Frame-Options, Permissions-Policy)",
|
|
1449
|
+
"remediation.security.missing-security-headers": "Add helmet() (Node.js) or equivalent security-header middleware. At minimum set Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options, and Permissions-Policy.",
|
|
1450
|
+
// GitHub Actions supply-chain
|
|
1451
|
+
"checks.gh-actions-unpinned.name": "Unpinned GitHub Actions",
|
|
1452
|
+
"checks.gh-actions-unpinned.description": "GitHub Actions workflow steps reference third-party actions by mutable tag rather than a full commit SHA",
|
|
1453
|
+
"remediation.security.gh-actions-unpinned": "Pin every third-party action to a full 40-character commit SHA, e.g. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683. Use a tool like Renovate or pin-github-action to automate this.",
|
|
1454
|
+
"checks.gh-actions-excessive-permissions.name": "Excessive GitHub Actions permissions",
|
|
1455
|
+
"checks.gh-actions-excessive-permissions.description": "GitHub Actions workflow grants overly broad or undeclared default permissions",
|
|
1456
|
+
"remediation.security.gh-actions-excessive-permissions": 'Add a top-level "permissions: {}" block and grant only the minimum scopes needed per job, e.g. "permissions: { contents: read }".',
|
|
1457
|
+
// GraphQL
|
|
1458
|
+
"checks.graphql-introspection-prod.name": "GraphQL introspection in production",
|
|
1459
|
+
"checks.graphql-introspection-prod.description": "GraphQL server exposes introspection without disabling it for production environments",
|
|
1460
|
+
"remediation.security.graphql-introspection-prod": 'Disable GraphQL introspection in production: set introspection: process.env.NODE_ENV !== "production" in Apollo Server, or use a validationRules plugin that blocks introspection queries.',
|
|
1461
|
+
"checks.graphql-no-depth-limit.name": "GraphQL missing depth limit",
|
|
1462
|
+
"checks.graphql-no-depth-limit.description": "GraphQL server has no query depth or complexity limiting, enabling denial-of-service via deeply nested queries",
|
|
1463
|
+
"remediation.security.graphql-no-depth-limit": "Add query depth limiting with graphql-depth-limit or complexity analysis with graphql-query-complexity. Wire the validation rule into ApolloServer validationRules or the equivalent for your framework.",
|
|
1464
|
+
// JWT
|
|
1465
|
+
"checks.jwt-alg-none.name": 'JWT allows algorithm "none"',
|
|
1466
|
+
"checks.jwt-alg-none.description": 'JWT verification does not restrict algorithms, allowing forged unsigned tokens via the "none" algorithm',
|
|
1467
|
+
"remediation.security.jwt-alg-none": 'Always pass an explicit algorithms allowlist to jwt.verify() (e.g. { algorithms: ["HS256"] }). Never accept "none" as a valid algorithm.',
|
|
1468
|
+
"checks.jwt-no-expiry.name": "JWT issued without expiry",
|
|
1469
|
+
"checks.jwt-no-expiry.description": "JWT tokens are signed without an expiry claim (exp / expiresIn), making them valid indefinitely",
|
|
1470
|
+
"remediation.security.jwt-no-expiry": 'Set a short expiry on all issued JWTs using expiresIn (jsonwebtoken), .setExpirationTime() (jose), or an "exp" claim in the payload (PyJWT). Use refresh tokens to extend sessions.',
|
|
1471
|
+
// Browser token storage
|
|
1472
|
+
"checks.refresh-token-in-localstorage.name": "Refresh token in localStorage",
|
|
1473
|
+
"checks.refresh-token-in-localstorage.description": "Refresh tokens or long-lived credentials stored in localStorage are vulnerable to theft via XSS",
|
|
1474
|
+
"remediation.security.refresh-token-in-localstorage": "Store refresh tokens in httpOnly, Secure, SameSite=Strict cookies \u2014 not localStorage or sessionStorage. XSS cannot read httpOnly cookies.",
|
|
1475
|
+
// Dockerfile hardening
|
|
1476
|
+
"checks.dockerfile-runs-as-root.name": "Dockerfile runs as root",
|
|
1477
|
+
"checks.dockerfile-runs-as-root.description": "Container image has no non-root USER directive \u2014 all processes run as root",
|
|
1478
|
+
"remediation.ops.dockerfile-runs-as-root": "Create a non-root user and switch to it before CMD/ENTRYPOINT: RUN addgroup -S app && adduser -S app -G app followed by USER app.",
|
|
1479
|
+
"checks.dockerfile-secrets-in-env.name": "Secrets in Dockerfile ENV",
|
|
1480
|
+
"checks.dockerfile-secrets-in-env.description": "Dockerfile bakes secrets or credentials into image layers via ENV or ARG instructions",
|
|
1481
|
+
"remediation.ops.dockerfile-secrets-in-env": "Never pass secrets via ENV or ARG \u2014 they persist in image history. Use Docker BuildKit secrets (RUN --mount=type=secret), environment variables injected at container start, or a secrets manager.",
|
|
1482
|
+
"checks.dockerfile-no-healthcheck.name": "Dockerfile missing HEALTHCHECK",
|
|
1483
|
+
"checks.dockerfile-no-healthcheck.description": "Dockerfile has no HEALTHCHECK instruction \u2014 container orchestrators cannot detect unhealthy state",
|
|
1484
|
+
"remediation.ops.dockerfile-no-healthcheck": "Add a HEALTHCHECK instruction, e.g.: HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1",
|
|
1485
|
+
"checks.missing-dockerignore.name": "Missing .dockerignore",
|
|
1486
|
+
"checks.missing-dockerignore.description": "Dockerfile is present without a .dockerignore \u2014 build context may include node_modules, .env files, or other sensitive data",
|
|
1487
|
+
"remediation.ops.missing-dockerignore": "Add a .dockerignore file listing directories and files to exclude from the build context: node_modules, .env, .git, *.log, dist, coverage.",
|
|
1488
|
+
// ORM N+1
|
|
1489
|
+
"checks.orm-n-plus-one.name": "ORM N+1 query pattern",
|
|
1490
|
+
"checks.orm-n-plus-one.description": "ORM results are iterated with a per-row database query instead of a single batch load",
|
|
1491
|
+
"remediation.resilience.orm-n-plus-one": "Replace per-row queries inside loops with a single batch query using include/eager loading (Prisma include, Sequelize eager loading, SQLAlchemy joinedload, ActiveRecord includes).",
|
|
1443
1492
|
// Reporting labels
|
|
1444
1493
|
"reporting.analysisType.pattern": "pattern",
|
|
1445
1494
|
"reporting.analysisType.semantic": "semantic"
|
|
@@ -3134,16 +3183,60 @@ var REFRESH_WINDOW_MS = 5 * 60 * 1e3;
|
|
|
3134
3183
|
var LicenseClient = class {
|
|
3135
3184
|
baseUrl;
|
|
3136
3185
|
timeout;
|
|
3186
|
+
userAgent;
|
|
3137
3187
|
cache = null;
|
|
3138
3188
|
jwksFetched = false;
|
|
3139
3189
|
constructor(config = {}) {
|
|
3140
3190
|
this.baseUrl = config.baseUrl ?? LICENSE_SERVER_URL;
|
|
3141
3191
|
this.timeout = config.timeout ?? 1e4;
|
|
3192
|
+
this.userAgent = config.userAgent ?? "seaworthycode/unknown";
|
|
3193
|
+
}
|
|
3194
|
+
async fetchWithRetry(url, init) {
|
|
3195
|
+
try {
|
|
3196
|
+
return await fetch(url, init);
|
|
3197
|
+
} catch (err) {
|
|
3198
|
+
if (!(err instanceof TypeError)) throw err;
|
|
3199
|
+
return this.fetchIpv4(url, init);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
async fetchIpv4(url, init) {
|
|
3203
|
+
const parsed = new URL(url);
|
|
3204
|
+
const { address } = await lookup(parsed.hostname, { family: 4 });
|
|
3205
|
+
const initHeaders = init.headers ?? {};
|
|
3206
|
+
const headers = { ...initHeaders, Host: parsed.hostname };
|
|
3207
|
+
return new Promise((resolve22, reject) => {
|
|
3208
|
+
const options = {
|
|
3209
|
+
hostname: address,
|
|
3210
|
+
port: parseInt(parsed.port || "443"),
|
|
3211
|
+
path: parsed.pathname + (parsed.search || ""),
|
|
3212
|
+
method: init.method ?? "GET",
|
|
3213
|
+
headers,
|
|
3214
|
+
servername: parsed.hostname,
|
|
3215
|
+
signal: init.signal ?? void 0
|
|
3216
|
+
};
|
|
3217
|
+
const req = httpsRequest(options, (res) => {
|
|
3218
|
+
const chunks = [];
|
|
3219
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
3220
|
+
res.on("end", () => {
|
|
3221
|
+
const resHeaders = new Headers();
|
|
3222
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
3223
|
+
if (typeof v === "string") resHeaders.set(k, v);
|
|
3224
|
+
else if (Array.isArray(v)) resHeaders.set(k, v.join(", "));
|
|
3225
|
+
}
|
|
3226
|
+
resolve22(new Response(Buffer.concat(chunks), { status: res.statusCode ?? 0, headers: resHeaders }));
|
|
3227
|
+
});
|
|
3228
|
+
res.on("error", reject);
|
|
3229
|
+
});
|
|
3230
|
+
req.on("error", reject);
|
|
3231
|
+
if (init.body) req.write(init.body);
|
|
3232
|
+
req.end();
|
|
3233
|
+
});
|
|
3142
3234
|
}
|
|
3143
3235
|
async ensureJwks() {
|
|
3144
3236
|
if (this.jwksFetched) return;
|
|
3145
|
-
const response = await
|
|
3146
|
-
signal: AbortSignal.timeout(this.timeout)
|
|
3237
|
+
const response = await this.fetchWithRetry(`${this.baseUrl}/.well-known/jwks.json`, {
|
|
3238
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
3239
|
+
headers: { "User-Agent": this.userAgent }
|
|
3147
3240
|
});
|
|
3148
3241
|
if (!response.ok) {
|
|
3149
3242
|
throw new Error("jwks.fetch_failed");
|
|
@@ -3167,9 +3260,9 @@ var LicenseClient = class {
|
|
|
3167
3260
|
}
|
|
3168
3261
|
try {
|
|
3169
3262
|
await this.ensureJwks();
|
|
3170
|
-
const response = await
|
|
3263
|
+
const response = await this.fetchWithRetry(`${this.baseUrl}/validate`, {
|
|
3171
3264
|
method: "POST",
|
|
3172
|
-
headers: { "Content-Type": "application/json" },
|
|
3265
|
+
headers: { "Content-Type": "application/json", "User-Agent": this.userAgent },
|
|
3173
3266
|
body: JSON.stringify({ licenseKey: licenseKey ?? "free" }),
|
|
3174
3267
|
signal: AbortSignal.timeout(this.timeout)
|
|
3175
3268
|
});
|
|
@@ -3180,7 +3273,8 @@ var LicenseClient = class {
|
|
|
3180
3273
|
return fail("license.rate_limited");
|
|
3181
3274
|
}
|
|
3182
3275
|
if (!response.ok) {
|
|
3183
|
-
|
|
3276
|
+
const rayId = response.headers.get("cf-ray") ?? "";
|
|
3277
|
+
return fail("license.server_error", `HTTP ${response.status}${rayId ? ` ray=${rayId}` : ""}`);
|
|
3184
3278
|
}
|
|
3185
3279
|
const data = await response.json();
|
|
3186
3280
|
const parsed = licenseResponseSchema.safeParse(data);
|
|
@@ -3209,12 +3303,12 @@ var LicenseClient = class {
|
|
|
3209
3303
|
});
|
|
3210
3304
|
} catch (err) {
|
|
3211
3305
|
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
3212
|
-
return fail("license.server_unreachable");
|
|
3306
|
+
return fail("license.server_unreachable", "timeout");
|
|
3213
3307
|
}
|
|
3214
3308
|
if (err instanceof TypeError) {
|
|
3215
|
-
return fail("license.server_unreachable");
|
|
3309
|
+
return fail("license.server_unreachable", err.message);
|
|
3216
3310
|
}
|
|
3217
|
-
return fail("license.server_unreachable");
|
|
3311
|
+
return fail("license.server_unreachable", String(err));
|
|
3218
3312
|
}
|
|
3219
3313
|
}
|
|
3220
3314
|
shouldRefresh() {
|
|
@@ -3756,6 +3850,22 @@ function mergeFindings(tsFindings, otherFindings) {
|
|
|
3756
3850
|
}
|
|
3757
3851
|
return Array.from(map.values());
|
|
3758
3852
|
}
|
|
3853
|
+
var ENV_SUBSTITUTION_PATTERNS = [
|
|
3854
|
+
/^env\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)$/,
|
|
3855
|
+
/^\$\{[A-Za-z_][A-Za-z0-9_]*(?::[-=+?][^}]*)?\}$/,
|
|
3856
|
+
/^\$[A-Za-z_][A-Za-z0-9_]*$/,
|
|
3857
|
+
/^%[A-Za-z_][A-Za-z0-9_]*%$/,
|
|
3858
|
+
/^<%=?\s*ENV\[['"][A-Za-z_][A-Za-z0-9_]*['"]\]\s*%>$/,
|
|
3859
|
+
/^process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[['"][A-Za-z_][A-Za-z0-9_]*['"]\])$/,
|
|
3860
|
+
/^os\.(?:environ\[['"][A-Za-z_][A-Za-z0-9_]*['"]\]|getenv\(['"][A-Za-z_][A-Za-z0-9_]*['"]\))$/
|
|
3861
|
+
];
|
|
3862
|
+
function isEnvSubstitution(value) {
|
|
3863
|
+
const trimmed = value.trim();
|
|
3864
|
+
for (const pattern of ENV_SUBSTITUTION_PATTERNS) {
|
|
3865
|
+
if (pattern.test(trimmed)) return true;
|
|
3866
|
+
}
|
|
3867
|
+
return false;
|
|
3868
|
+
}
|
|
3759
3869
|
var CHECK_ID = "security.hardcoded-secrets";
|
|
3760
3870
|
var SECRET_PATTERNS = [
|
|
3761
3871
|
{ regex: /AWS_SECRET_ACCESS_KEY\s*=\s*['"]([A-Za-z0-9/+=]{40})['"]/gi, name: "AWS secret access key", minLength: 40, method: "known-format" },
|
|
@@ -3791,6 +3901,8 @@ var regexCheck = {
|
|
|
3791
3901
|
const matches = Array.from(line.matchAll(pattern.regex));
|
|
3792
3902
|
for (let matchIdx = 0; matchIdx < matches.length; matchIdx++) {
|
|
3793
3903
|
const match = matches[matchIdx];
|
|
3904
|
+
const captured = match[1] ?? match[0];
|
|
3905
|
+
if (isEnvSubstitution(captured)) continue;
|
|
3794
3906
|
findings.push({
|
|
3795
3907
|
id: `${CHECK_ID}:${file.relativePath}:${lineNum + 1}:${(match.index ?? 0) + 1}:${matchIdx}`,
|
|
3796
3908
|
checkId: CHECK_ID,
|
|
@@ -3947,6 +4059,7 @@ var tsCheck = createTreeSitterCheck({
|
|
|
3947
4059
|
const trimmedValue = value.trim();
|
|
3948
4060
|
if (!/^['"`].*['"`]$/.test(trimmedValue)) return false;
|
|
3949
4061
|
const cleanValue = trimmedValue.replace(/^['"`]|['"`]$/g, "");
|
|
4062
|
+
if (isEnvSubstitution(cleanValue)) return false;
|
|
3950
4063
|
for (const rule of NAME_RULES) {
|
|
3951
4064
|
if (rule.regex.test(name) && cleanValue.length >= rule.minLength) {
|
|
3952
4065
|
return true;
|
|
@@ -5205,7 +5318,7 @@ var MIN_VALUE_LENGTH = 8;
|
|
|
5205
5318
|
var SECRET_ASSIGNMENT_PATTERNS = [
|
|
5206
5319
|
/(?:const|let|var)\s+(\w*(?:secret|api[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|bearer[_-]?token|refresh[_-]?token|apiKey|accessKey|privateKey|authToken|bearerToken|refreshToken)\w*)\s*=\s*['"]([^'"]{8,})['"]/i,
|
|
5207
5320
|
/(\w*(?:secret|api[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|bearer[_-]?token|refresh[_-]?token|apiKey|accessKey|privateKey|authToken|bearerToken|refreshToken)\w*)\s*:\s*['"]([^'"]{8,})['"]/i,
|
|
5208
|
-
/(?:<!--|\/\*)[^]
|
|
5321
|
+
/(?:<!--|\/\*)[^]{0,300}?(\w*(?:secret|api[_-]?key|access[_-]?key|private[_-]?key|auth[_-]?token|bearer[_-]?token|refresh[_-]?token|apiKey|accessKey|privateKey|authToken|bearerToken|refreshToken)\w*)\s*[:=]\s*['"]([^'"]{8,})['"]/i
|
|
5209
5322
|
];
|
|
5210
5323
|
var REMEDIATION9 = getCopy("remediation.configuration.secrets-in-source");
|
|
5211
5324
|
function stripQuotes(value) {
|
|
@@ -5235,7 +5348,9 @@ var regexCheck3 = {
|
|
|
5235
5348
|
const name = match[1] ?? "";
|
|
5236
5349
|
const value = match[2] ?? "";
|
|
5237
5350
|
if (!isCompoundSecretName(name)) continue;
|
|
5238
|
-
|
|
5351
|
+
const cleanValue = stripQuotes(value);
|
|
5352
|
+
if (cleanValue.length < MIN_VALUE_LENGTH) continue;
|
|
5353
|
+
if (isEnvSubstitution(cleanValue)) continue;
|
|
5239
5354
|
const before = content.slice(0, match.index ?? 0);
|
|
5240
5355
|
const line = before.split("\n").length;
|
|
5241
5356
|
const col = (before.split("\n").at(-1) ?? "").length + 1;
|
|
@@ -5264,7 +5379,9 @@ var regexCheck3 = {
|
|
|
5264
5379
|
const name = match[1] ?? "";
|
|
5265
5380
|
const value = match[2] ?? "";
|
|
5266
5381
|
if (!isCompoundSecretName(name)) continue;
|
|
5267
|
-
|
|
5382
|
+
const cleanValue = stripQuotes(value);
|
|
5383
|
+
if (cleanValue.length < MIN_VALUE_LENGTH) continue;
|
|
5384
|
+
if (isEnvSubstitution(cleanValue)) continue;
|
|
5268
5385
|
findings.push({
|
|
5269
5386
|
id: `${CHECK_ID5}:${file.relativePath}:${lineNum + 1}:${(match.index ?? 0) + 1}:${matchIdx}`,
|
|
5270
5387
|
checkId: CHECK_ID5,
|
|
@@ -5400,7 +5517,9 @@ var tsCheck3 = createTreeSitterCheck({
|
|
|
5400
5517
|
const value = captures.value;
|
|
5401
5518
|
if (!name || !value) return false;
|
|
5402
5519
|
if (!isCompoundSecretName(name)) return false;
|
|
5403
|
-
|
|
5520
|
+
const cleanValue = stripQuotes(value);
|
|
5521
|
+
if (cleanValue.length < MIN_VALUE_LENGTH) return false;
|
|
5522
|
+
return !isEnvSubstitution(cleanValue);
|
|
5404
5523
|
},
|
|
5405
5524
|
confidenceFactory: (captures) => mechanicalConfidence("ast", void 0, captures.value ?? "")
|
|
5406
5525
|
});
|
|
@@ -5630,10 +5749,25 @@ var ENTRYPOINT_PATTERNS = [
|
|
|
5630
5749
|
/(?:^|[\\/])bin[\\/]rails$/i,
|
|
5631
5750
|
/(?:^|[\\/])application_controller\.rb$/i
|
|
5632
5751
|
];
|
|
5752
|
+
var DOCKERFILE_CHECK_IDS = /* @__PURE__ */ new Set([
|
|
5753
|
+
"ops.dockerfile-runs-as-root",
|
|
5754
|
+
"ops.dockerfile-secrets-in-env",
|
|
5755
|
+
"ops.dockerfile-no-healthcheck"
|
|
5756
|
+
]);
|
|
5757
|
+
var GH_ACTIONS_CHECK_IDS = /* @__PURE__ */ new Set([
|
|
5758
|
+
"security.gh-actions-unpinned",
|
|
5759
|
+
"security.gh-actions-excessive-permissions"
|
|
5760
|
+
]);
|
|
5633
5761
|
function isProCheckEligible(file, template) {
|
|
5634
5762
|
if (template?.checkId === "security.sql-missing-rls") {
|
|
5635
5763
|
return file.language === "sql" && !isDocumentationPath(file.relativePath) && !isNonProductionPath(file.relativePath);
|
|
5636
5764
|
}
|
|
5765
|
+
if (template?.checkId && DOCKERFILE_CHECK_IDS.has(template.checkId)) {
|
|
5766
|
+
return file.language === "dockerfile" && !isDocumentationPath(file.relativePath) && !isNonProductionPath(file.relativePath);
|
|
5767
|
+
}
|
|
5768
|
+
if (template?.checkId && GH_ACTIONS_CHECK_IDS.has(template.checkId)) {
|
|
5769
|
+
return file.language === "yaml" && /(?:^|[\\/])\.github[\\/]workflows[\\/]/i.test(file.relativePath) && !isDocumentationPath(file.relativePath) && !isNonProductionPath(file.relativePath);
|
|
5770
|
+
}
|
|
5637
5771
|
return PRO_CHECK_SOURCE_LANGUAGES.has(file.language) && !isDocumentationPath(file.relativePath) && !isNonProductionPath(file.relativePath);
|
|
5638
5772
|
}
|
|
5639
5773
|
function pickProjectAnchorFile(files) {
|
|
@@ -7521,6 +7655,10 @@ registry.register({
|
|
|
7521
7655
|
}
|
|
7522
7656
|
});
|
|
7523
7657
|
var CHECK_ID10 = "security.xss-surface";
|
|
7658
|
+
var CGI_PATH_HINT = /(^|[\/_-])cgi([\/_.-]|$)/i;
|
|
7659
|
+
function isLikelyCgiBashScript(file) {
|
|
7660
|
+
return CGI_PATH_HINT.test(file.relativePath);
|
|
7661
|
+
}
|
|
7524
7662
|
var QUERIES = {
|
|
7525
7663
|
javascript: `
|
|
7526
7664
|
(call_expression
|
|
@@ -7804,7 +7942,10 @@ registry.register(
|
|
|
7804
7942
|
queries: QUERIES,
|
|
7805
7943
|
messageKey: "checks.xss-surface.message",
|
|
7806
7944
|
degradedMessageKey: "checks.xss-surface.degraded",
|
|
7807
|
-
remediationKey: "remediation.security.xss-surface"
|
|
7945
|
+
remediationKey: "remediation.security.xss-surface",
|
|
7946
|
+
// Bash echo $VAR writes to stdout, not a browser. Only treat bash
|
|
7947
|
+
// scripts as XSS-relevant when they look like CGI handlers.
|
|
7948
|
+
fileFilter: (file) => file.language !== "bash" || isLikelyCgiBashScript(file)
|
|
7808
7949
|
})
|
|
7809
7950
|
);
|
|
7810
7951
|
var CHECK_ID11 = "security.os-command-injection";
|
|
@@ -14732,6 +14873,556 @@ registry.register({
|
|
|
14732
14873
|
}
|
|
14733
14874
|
}
|
|
14734
14875
|
});
|
|
14876
|
+
var PROMPT_VERSION31 = "1.0.0";
|
|
14877
|
+
var fallbackPatterns31 = [
|
|
14878
|
+
{
|
|
14879
|
+
regex: /helmet\s*\(/i,
|
|
14880
|
+
message: "No HTTP security headers middleware detected",
|
|
14881
|
+
severity: "medium",
|
|
14882
|
+
absenceBased: true
|
|
14883
|
+
}
|
|
14884
|
+
];
|
|
14885
|
+
async function buildPrompt31(sourceFiles) {
|
|
14886
|
+
const parts = [
|
|
14887
|
+
"Check if the application configures HTTP security response headers.",
|
|
14888
|
+
"In Node.js (Express, Fastify, Nest), look for helmet() middleware or explicit setHeader calls for: Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options (or Content-Security-Policy with frame-ancestors), and Permissions-Policy.",
|
|
14889
|
+
"In Python Django, check for SECURE_HSTS_SECONDS, SECURE_CONTENT_TYPE_NOSNIFF, X_FRAME_OPTIONS in settings files.",
|
|
14890
|
+
"In Python FastAPI/Flask, check for SecurityHeadersMiddleware or manual response.headers assignments covering these headers.",
|
|
14891
|
+
"Flag apps that initialise a server without any of these security headers. Prefer one project-level finding rather than one per route file. Do NOT flag if helmet() or a known security-header middleware is present."
|
|
14892
|
+
];
|
|
14893
|
+
for (const file of sourceFiles) {
|
|
14894
|
+
const content = await file.content();
|
|
14895
|
+
parts.push(`--- ${file.relativePath} ---
|
|
14896
|
+
${content.slice(0, 3e3)}`);
|
|
14897
|
+
}
|
|
14898
|
+
return parts.join("\n\n");
|
|
14899
|
+
}
|
|
14900
|
+
function parseResponse31(response, checkId) {
|
|
14901
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
14902
|
+
}
|
|
14903
|
+
var promptTemplate31 = {
|
|
14904
|
+
checkId: "security.missing-security-headers",
|
|
14905
|
+
promptVersion: PROMPT_VERSION31,
|
|
14906
|
+
projectScoped: true,
|
|
14907
|
+
buildPrompt: buildPrompt31,
|
|
14908
|
+
parseResponse: parseResponse31,
|
|
14909
|
+
fallbackPatterns: fallbackPatterns31
|
|
14910
|
+
};
|
|
14911
|
+
registry.register({
|
|
14912
|
+
id: "security.missing-security-headers",
|
|
14913
|
+
name: "Missing security headers",
|
|
14914
|
+
description: "Application does not set HTTP security response headers",
|
|
14915
|
+
category: "security",
|
|
14916
|
+
severity: "medium",
|
|
14917
|
+
minTier: "pro",
|
|
14918
|
+
async run(ctx) {
|
|
14919
|
+
return runLLMCheck(ctx, promptTemplate31);
|
|
14920
|
+
}
|
|
14921
|
+
});
|
|
14922
|
+
var PROMPT_VERSION32 = "1.0.0";
|
|
14923
|
+
var fallbackPatterns32 = [
|
|
14924
|
+
{
|
|
14925
|
+
regex: /uses:\s+(?!\.\/)\S+@(?![0-9a-f]{40}\b)\S+/,
|
|
14926
|
+
message: "GitHub Actions step uses an unpinned action reference (not a 40-char commit SHA)",
|
|
14927
|
+
severity: "medium"
|
|
14928
|
+
}
|
|
14929
|
+
];
|
|
14930
|
+
async function buildPrompt32(sourceFiles) {
|
|
14931
|
+
const parts = [
|
|
14932
|
+
"Check GitHub Actions workflow files for third-party action steps not pinned to a full 40-character commit SHA.",
|
|
14933
|
+
"Safe: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
|
14934
|
+
"Unsafe (flag these): uses: actions/checkout@v4, uses: actions/checkout@main, uses: actions/checkout@latest, uses: actions/checkout@master",
|
|
14935
|
+
"First-party actions (uses: ./ prefix) are always acceptable \u2014 do not flag them.",
|
|
14936
|
+
'Flag every "uses:" line referencing a third-party action without a 40-hex-char commit SHA.'
|
|
14937
|
+
];
|
|
14938
|
+
for (const file of sourceFiles) {
|
|
14939
|
+
const content = await file.content();
|
|
14940
|
+
parts.push(`--- ${file.relativePath} ---
|
|
14941
|
+
${content.slice(0, 4e3)}`);
|
|
14942
|
+
}
|
|
14943
|
+
return parts.join("\n\n");
|
|
14944
|
+
}
|
|
14945
|
+
function parseResponse32(response, checkId) {
|
|
14946
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
14947
|
+
}
|
|
14948
|
+
var promptTemplate32 = {
|
|
14949
|
+
checkId: "security.gh-actions-unpinned",
|
|
14950
|
+
promptVersion: PROMPT_VERSION32,
|
|
14951
|
+
buildPrompt: buildPrompt32,
|
|
14952
|
+
parseResponse: parseResponse32,
|
|
14953
|
+
fallbackPatterns: fallbackPatterns32
|
|
14954
|
+
};
|
|
14955
|
+
registry.register({
|
|
14956
|
+
id: "security.gh-actions-unpinned",
|
|
14957
|
+
name: "Unpinned GitHub Actions",
|
|
14958
|
+
description: "GitHub Actions steps reference third-party actions by mutable tag rather than commit SHA",
|
|
14959
|
+
category: "security",
|
|
14960
|
+
severity: "medium",
|
|
14961
|
+
minTier: "pro",
|
|
14962
|
+
async run(ctx) {
|
|
14963
|
+
return runLLMCheck(ctx, promptTemplate32);
|
|
14964
|
+
}
|
|
14965
|
+
});
|
|
14966
|
+
var PROMPT_VERSION33 = "1.0.0";
|
|
14967
|
+
var fallbackPatterns33 = [
|
|
14968
|
+
{
|
|
14969
|
+
regex: /permissions:\s*write-all/i,
|
|
14970
|
+
message: "Workflow grants write-all permissions",
|
|
14971
|
+
severity: "low"
|
|
14972
|
+
}
|
|
14973
|
+
];
|
|
14974
|
+
async function buildPrompt33(sourceFiles) {
|
|
14975
|
+
const parts = [
|
|
14976
|
+
"Check GitHub Actions workflow files for excessive or undeclared permission grants.",
|
|
14977
|
+
`Flag workflows that: (1) use "permissions: write-all" at the top level or in a job, (2) have no top-level "permissions:" key at all (GitHub's default grants broad write access when omitted), or (3) grant write-level permissions far broader than the job's purpose (e.g. contents: write on a check that only reads).`,
|
|
14978
|
+
'A well-secured workflow adds "permissions: {}" at the top level and grants only the minimum needed per job, e.g. "permissions: { contents: read }".',
|
|
14979
|
+
"Do NOT flag workflows that already have explicitly scoped, narrow permissions defined."
|
|
14980
|
+
];
|
|
14981
|
+
for (const file of sourceFiles) {
|
|
14982
|
+
const content = await file.content();
|
|
14983
|
+
parts.push(`--- ${file.relativePath} ---
|
|
14984
|
+
${content.slice(0, 4e3)}`);
|
|
14985
|
+
}
|
|
14986
|
+
return parts.join("\n\n");
|
|
14987
|
+
}
|
|
14988
|
+
function parseResponse33(response, checkId) {
|
|
14989
|
+
return parseLLMResponse(response, checkId, "low");
|
|
14990
|
+
}
|
|
14991
|
+
var promptTemplate33 = {
|
|
14992
|
+
checkId: "security.gh-actions-excessive-permissions",
|
|
14993
|
+
promptVersion: PROMPT_VERSION33,
|
|
14994
|
+
buildPrompt: buildPrompt33,
|
|
14995
|
+
parseResponse: parseResponse33,
|
|
14996
|
+
fallbackPatterns: fallbackPatterns33
|
|
14997
|
+
};
|
|
14998
|
+
registry.register({
|
|
14999
|
+
id: "security.gh-actions-excessive-permissions",
|
|
15000
|
+
name: "Excessive GitHub Actions permissions",
|
|
15001
|
+
description: "GitHub Actions workflow grants overly broad or undeclared permissions",
|
|
15002
|
+
category: "security",
|
|
15003
|
+
severity: "low",
|
|
15004
|
+
minTier: "pro",
|
|
15005
|
+
async run(ctx) {
|
|
15006
|
+
return runLLMCheck(ctx, promptTemplate33);
|
|
15007
|
+
}
|
|
15008
|
+
});
|
|
15009
|
+
var PROMPT_VERSION34 = "1.0.0";
|
|
15010
|
+
var fallbackPatterns34 = [
|
|
15011
|
+
{
|
|
15012
|
+
regex: /introspection:\s*true/i,
|
|
15013
|
+
message: "GraphQL introspection is explicitly enabled \u2014 disable in production",
|
|
15014
|
+
severity: "medium"
|
|
15015
|
+
}
|
|
15016
|
+
];
|
|
15017
|
+
async function buildPrompt34(sourceFiles) {
|
|
15018
|
+
const parts = [
|
|
15019
|
+
"Check if a GraphQL server has introspection enabled for production traffic.",
|
|
15020
|
+
'In Apollo Server (Node.js), flag ApolloServer instances with introspection: true where there is no production guard (e.g. process.env.NODE_ENV !== "production"), or where introspection is not explicitly set to false.',
|
|
15021
|
+
"In GraphQL Yoga or Mercurius, check for equivalent introspection settings.",
|
|
15022
|
+
"In Python (Strawberry, graphene), flag schema servers without a mechanism to disable introspection in non-development environments.",
|
|
15023
|
+
"Only flag when no environment guard exists that would prevent introspection in production. Return [] if introspection is already disabled or gated on a non-production environment check."
|
|
15024
|
+
];
|
|
15025
|
+
for (const file of sourceFiles) {
|
|
15026
|
+
const content = await file.content();
|
|
15027
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15028
|
+
${content.slice(0, 3e3)}`);
|
|
15029
|
+
}
|
|
15030
|
+
return parts.join("\n\n");
|
|
15031
|
+
}
|
|
15032
|
+
function parseResponse34(response, checkId) {
|
|
15033
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
15034
|
+
}
|
|
15035
|
+
var promptTemplate34 = {
|
|
15036
|
+
checkId: "security.graphql-introspection-prod",
|
|
15037
|
+
promptVersion: PROMPT_VERSION34,
|
|
15038
|
+
buildPrompt: buildPrompt34,
|
|
15039
|
+
parseResponse: parseResponse34,
|
|
15040
|
+
fallbackPatterns: fallbackPatterns34
|
|
15041
|
+
};
|
|
15042
|
+
registry.register({
|
|
15043
|
+
id: "security.graphql-introspection-prod",
|
|
15044
|
+
name: "GraphQL introspection in production",
|
|
15045
|
+
description: "GraphQL server exposes introspection without disabling it for production",
|
|
15046
|
+
category: "security",
|
|
15047
|
+
severity: "medium",
|
|
15048
|
+
minTier: "pro",
|
|
15049
|
+
async run(ctx) {
|
|
15050
|
+
return runLLMCheck(ctx, promptTemplate34);
|
|
15051
|
+
}
|
|
15052
|
+
});
|
|
15053
|
+
var PROMPT_VERSION35 = "1.0.0";
|
|
15054
|
+
var fallbackPatterns35 = [];
|
|
15055
|
+
async function buildPrompt35(sourceFiles) {
|
|
15056
|
+
const parts = [
|
|
15057
|
+
"Check if a GraphQL server enforces query depth or complexity limits.",
|
|
15058
|
+
"Flag GraphQL server setups (Apollo Server, GraphQL Yoga, Mercurius, Strawberry, graphene) that configure no query depth or complexity protection \u2014 none of: graphql-depth-limit, depthLimit, graphql-cost-analysis, graphql-query-complexity, or custom validationRules that restrict query shape.",
|
|
15059
|
+
"Without depth limiting, deeply nested queries cause exponential resolver work and can be used as a denial-of-service vector.",
|
|
15060
|
+
"Emit one project-level finding pointing at the server setup file. Return [] if any depth, complexity, or cost validation plugin is wired into the server, or if no GraphQL server is present."
|
|
15061
|
+
];
|
|
15062
|
+
for (const file of sourceFiles) {
|
|
15063
|
+
const content = await file.content();
|
|
15064
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15065
|
+
${content.slice(0, 3e3)}`);
|
|
15066
|
+
}
|
|
15067
|
+
return parts.join("\n\n");
|
|
15068
|
+
}
|
|
15069
|
+
function parseResponse35(response, checkId) {
|
|
15070
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
15071
|
+
}
|
|
15072
|
+
var promptTemplate35 = {
|
|
15073
|
+
checkId: "security.graphql-no-depth-limit",
|
|
15074
|
+
promptVersion: PROMPT_VERSION35,
|
|
15075
|
+
projectScoped: true,
|
|
15076
|
+
buildPrompt: buildPrompt35,
|
|
15077
|
+
parseResponse: parseResponse35,
|
|
15078
|
+
fallbackPatterns: fallbackPatterns35
|
|
15079
|
+
};
|
|
15080
|
+
registry.register({
|
|
15081
|
+
id: "security.graphql-no-depth-limit",
|
|
15082
|
+
name: "GraphQL no depth limit",
|
|
15083
|
+
description: "GraphQL server has no query depth or complexity limiting",
|
|
15084
|
+
category: "security",
|
|
15085
|
+
severity: "medium",
|
|
15086
|
+
minTier: "pro",
|
|
15087
|
+
async run(ctx) {
|
|
15088
|
+
return runLLMCheck(ctx, promptTemplate35);
|
|
15089
|
+
}
|
|
15090
|
+
});
|
|
15091
|
+
var PROMPT_VERSION36 = "1.0.0";
|
|
15092
|
+
var fallbackPatterns36 = [
|
|
15093
|
+
{
|
|
15094
|
+
regex: /algorithm[s'"`]*\s*[=:]\s*['"`]none['"`]/i,
|
|
15095
|
+
message: 'JWT algorithm set to "none" \u2014 token signatures are not verified',
|
|
15096
|
+
severity: "high"
|
|
15097
|
+
}
|
|
15098
|
+
];
|
|
15099
|
+
async function buildPrompt36(sourceFiles) {
|
|
15100
|
+
const parts = [
|
|
15101
|
+
"Check for insecure JWT algorithm configurations.",
|
|
15102
|
+
'Flag any explicit use of algorithm "none" in JWT signing or verification (jsonwebtoken, jose, PyJWT, ruby-jwt, etc.).',
|
|
15103
|
+
'Also flag jwt.verify() or jwt.decode() calls that do NOT pass an "algorithms" allowlist \u2014 without one, an attacker can forge tokens by switching to the "none" algorithm.',
|
|
15104
|
+
"In Python PyJWT: flag jwt.decode() missing the algorithms=[...] parameter.",
|
|
15105
|
+
'In Node.js jsonwebtoken: flag jwt.verify() without an algorithms option, or with algorithm/algorithms set to "none".',
|
|
15106
|
+
"In jose: flag jwtVerify() without explicit algorithms.",
|
|
15107
|
+
"Return [] if all verify/decode calls have an explicit non-empty algorithms allowlist."
|
|
15108
|
+
];
|
|
15109
|
+
for (const file of sourceFiles) {
|
|
15110
|
+
const content = await file.content();
|
|
15111
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15112
|
+
${content.slice(0, 3e3)}`);
|
|
15113
|
+
}
|
|
15114
|
+
return parts.join("\n\n");
|
|
15115
|
+
}
|
|
15116
|
+
function parseResponse36(response, checkId) {
|
|
15117
|
+
return parseLLMResponse(response, checkId, "high");
|
|
15118
|
+
}
|
|
15119
|
+
var promptTemplate36 = {
|
|
15120
|
+
checkId: "security.jwt-alg-none",
|
|
15121
|
+
promptVersion: PROMPT_VERSION36,
|
|
15122
|
+
buildPrompt: buildPrompt36,
|
|
15123
|
+
parseResponse: parseResponse36,
|
|
15124
|
+
fallbackPatterns: fallbackPatterns36
|
|
15125
|
+
};
|
|
15126
|
+
registry.register({
|
|
15127
|
+
id: "security.jwt-alg-none",
|
|
15128
|
+
name: "JWT algorithm none",
|
|
15129
|
+
description: 'JWT verification allows the "none" algorithm or lacks an algorithms allowlist',
|
|
15130
|
+
category: "security",
|
|
15131
|
+
severity: "high",
|
|
15132
|
+
minTier: "pro",
|
|
15133
|
+
async run(ctx) {
|
|
15134
|
+
return runLLMCheck(ctx, promptTemplate36);
|
|
15135
|
+
}
|
|
15136
|
+
});
|
|
15137
|
+
var PROMPT_VERSION37 = "1.0.1";
|
|
15138
|
+
var fallbackPatterns37 = [];
|
|
15139
|
+
async function buildPrompt37(sourceFiles) {
|
|
15140
|
+
const parts = [
|
|
15141
|
+
"Check for JWT tokens issued without an expiry claim.",
|
|
15142
|
+
'Flag jwt.sign(payload, secret) calls (jsonwebtoken) that have no "expiresIn" in the options argument.',
|
|
15143
|
+
"Flag new SignJWT(payload).sign(key) calls (jose) that do not chain .setExpirationTime(...).",
|
|
15144
|
+
'Flag jwt.encode(payload, key) (PyJWT) where the payload dict has no "exp" key.',
|
|
15145
|
+
"Tokens without expiry are valid indefinitely \u2014 a stolen token cannot be invalidated through expiry.",
|
|
15146
|
+
'Do NOT flag if the payload object itself already contains an "exp" property set before the sign call.',
|
|
15147
|
+
"Do NOT flag short-lived access tokens that have expiresIn set."
|
|
15148
|
+
];
|
|
15149
|
+
for (const file of sourceFiles) {
|
|
15150
|
+
const content = await file.content();
|
|
15151
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15152
|
+
${content.slice(0, 3e3)}`);
|
|
15153
|
+
}
|
|
15154
|
+
return parts.join("\n\n");
|
|
15155
|
+
}
|
|
15156
|
+
function parseResponse37(response, checkId) {
|
|
15157
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
15158
|
+
}
|
|
15159
|
+
var promptTemplate37 = {
|
|
15160
|
+
checkId: "security.jwt-no-expiry",
|
|
15161
|
+
promptVersion: PROMPT_VERSION37,
|
|
15162
|
+
buildPrompt: buildPrompt37,
|
|
15163
|
+
parseResponse: parseResponse37,
|
|
15164
|
+
fallbackPatterns: fallbackPatterns37
|
|
15165
|
+
};
|
|
15166
|
+
registry.register({
|
|
15167
|
+
id: "security.jwt-no-expiry",
|
|
15168
|
+
name: "JWT issued without expiry",
|
|
15169
|
+
description: "JWT tokens are signed without an expiry claim, making them valid indefinitely",
|
|
15170
|
+
category: "security",
|
|
15171
|
+
severity: "medium",
|
|
15172
|
+
minTier: "pro",
|
|
15173
|
+
async run(ctx) {
|
|
15174
|
+
return runLLMCheck(ctx, promptTemplate37);
|
|
15175
|
+
}
|
|
15176
|
+
});
|
|
15177
|
+
var PROMPT_VERSION38 = "1.0.0";
|
|
15178
|
+
var fallbackPatterns38 = [
|
|
15179
|
+
{
|
|
15180
|
+
regex: /(?:localStorage|sessionStorage)\.setItem\s*\(\s*['"`][^'"`]*(?:refresh|refreshToken|refresh_token)[^'"`]*['"`]/i,
|
|
15181
|
+
message: "Refresh token or long-lived credential stored in localStorage/sessionStorage",
|
|
15182
|
+
severity: "high"
|
|
15183
|
+
}
|
|
15184
|
+
];
|
|
15185
|
+
async function buildPrompt38(sourceFiles) {
|
|
15186
|
+
const parts = [
|
|
15187
|
+
"Check for refresh tokens or long-lived authentication credentials written to browser localStorage or sessionStorage.",
|
|
15188
|
+
'Flag any code that calls localStorage.setItem() or sessionStorage.setItem() (or equivalent bracket notation assignments) with a key containing "refresh", "refreshToken", or "refresh_token".',
|
|
15189
|
+
"Also flag storing full auth/session tokens intended for reuse across browser sessions in localStorage.",
|
|
15190
|
+
"XSS attacks can read all localStorage values \u2014 refresh tokens should be in httpOnly cookies instead.",
|
|
15191
|
+
"Short-lived access tokens held in JavaScript memory variables (not persisted to storage) are acceptable. Only flag explicit writes to localStorage/sessionStorage."
|
|
15192
|
+
];
|
|
15193
|
+
for (const file of sourceFiles) {
|
|
15194
|
+
const content = await file.content();
|
|
15195
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15196
|
+
${content.slice(0, 3e3)}`);
|
|
15197
|
+
}
|
|
15198
|
+
return parts.join("\n\n");
|
|
15199
|
+
}
|
|
15200
|
+
function parseResponse38(response, checkId) {
|
|
15201
|
+
return parseLLMResponse(response, checkId, "high");
|
|
15202
|
+
}
|
|
15203
|
+
var promptTemplate38 = {
|
|
15204
|
+
checkId: "security.refresh-token-in-localstorage",
|
|
15205
|
+
promptVersion: PROMPT_VERSION38,
|
|
15206
|
+
buildPrompt: buildPrompt38,
|
|
15207
|
+
parseResponse: parseResponse38,
|
|
15208
|
+
fallbackPatterns: fallbackPatterns38
|
|
15209
|
+
};
|
|
15210
|
+
registry.register({
|
|
15211
|
+
id: "security.refresh-token-in-localstorage",
|
|
15212
|
+
name: "Refresh token in localStorage",
|
|
15213
|
+
description: "Refresh tokens or long-lived credentials stored in localStorage are vulnerable to XSS theft",
|
|
15214
|
+
category: "security",
|
|
15215
|
+
severity: "high",
|
|
15216
|
+
minTier: "pro",
|
|
15217
|
+
async run(ctx) {
|
|
15218
|
+
return runLLMCheck(ctx, promptTemplate38);
|
|
15219
|
+
}
|
|
15220
|
+
});
|
|
15221
|
+
var PROMPT_VERSION39 = "1.0.0";
|
|
15222
|
+
var fallbackPatterns39 = [
|
|
15223
|
+
{
|
|
15224
|
+
regex: /^\s*USER\s+(?!root\b|0\b)/im,
|
|
15225
|
+
message: "Dockerfile has no non-root USER directive \u2014 container runs as root",
|
|
15226
|
+
severity: "medium",
|
|
15227
|
+
absenceBased: true
|
|
15228
|
+
}
|
|
15229
|
+
];
|
|
15230
|
+
async function buildPrompt39(sourceFiles) {
|
|
15231
|
+
const parts = [
|
|
15232
|
+
"Check Dockerfiles for containers running as root.",
|
|
15233
|
+
"Flag Dockerfiles that have no USER instruction before the final CMD or ENTRYPOINT, or where the last USER directive sets root (USER root or USER 0).",
|
|
15234
|
+
"A secure Dockerfile creates a non-privileged user and switches to it, e.g.: RUN adduser --disabled-password appuser followed by USER appuser.",
|
|
15235
|
+
"Running containers as root means any process escape gains host root privileges.",
|
|
15236
|
+
"Return [] if a non-root USER is set before CMD/ENTRYPOINT."
|
|
15237
|
+
];
|
|
15238
|
+
for (const file of sourceFiles) {
|
|
15239
|
+
const content = await file.content();
|
|
15240
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15241
|
+
${content}`);
|
|
15242
|
+
}
|
|
15243
|
+
return parts.join("\n\n");
|
|
15244
|
+
}
|
|
15245
|
+
function parseResponse39(response, checkId) {
|
|
15246
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
15247
|
+
}
|
|
15248
|
+
var promptTemplate39 = {
|
|
15249
|
+
checkId: "ops.dockerfile-runs-as-root",
|
|
15250
|
+
promptVersion: PROMPT_VERSION39,
|
|
15251
|
+
buildPrompt: buildPrompt39,
|
|
15252
|
+
parseResponse: parseResponse39,
|
|
15253
|
+
fallbackPatterns: fallbackPatterns39
|
|
15254
|
+
};
|
|
15255
|
+
registry.register({
|
|
15256
|
+
id: "ops.dockerfile-runs-as-root",
|
|
15257
|
+
name: "Dockerfile runs as root",
|
|
15258
|
+
description: "Container image has no non-root USER directive",
|
|
15259
|
+
category: "ops",
|
|
15260
|
+
severity: "medium",
|
|
15261
|
+
minTier: "pro",
|
|
15262
|
+
async run(ctx) {
|
|
15263
|
+
return runLLMCheck(ctx, promptTemplate39);
|
|
15264
|
+
}
|
|
15265
|
+
});
|
|
15266
|
+
var PROMPT_VERSION40 = "1.0.0";
|
|
15267
|
+
var fallbackPatterns40 = [
|
|
15268
|
+
{
|
|
15269
|
+
regex: /^\s*(?:ENV|ARG)\s+\S*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|CREDENTIAL|API_KEY|PRIVATE)\s*=\s*\S+/im,
|
|
15270
|
+
message: "Dockerfile bakes a secret-shaped value into the image via ENV or ARG",
|
|
15271
|
+
severity: "high"
|
|
15272
|
+
}
|
|
15273
|
+
];
|
|
15274
|
+
async function buildPrompt40(sourceFiles) {
|
|
15275
|
+
const parts = [
|
|
15276
|
+
"Check Dockerfiles for secrets or credentials baked into image layers via ENV or ARG instructions.",
|
|
15277
|
+
"Flag ENV or ARG directives where the variable name suggests a secret (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_PASS, *_CREDENTIAL, *_PRIVATE, DATABASE_URL, PRIVATE_KEY) and a literal non-placeholder value is assigned.",
|
|
15278
|
+
'ENV values are baked into every image layer and visible via "docker inspect". ARG values are also stored in the build history.',
|
|
15279
|
+
"Flag: ENV DATABASE_URL=postgres://user:password@host/db, ENV API_KEY=sk-abc123, ARG STRIPE_SECRET=sk_live_...",
|
|
15280
|
+
"Do NOT flag: ENV PORT=3000, ENV NODE_ENV=production, ARG BUILD_DATE, or ENV DATABASE_URL= (empty assignment)."
|
|
15281
|
+
];
|
|
15282
|
+
for (const file of sourceFiles) {
|
|
15283
|
+
const content = await file.content();
|
|
15284
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15285
|
+
${content}`);
|
|
15286
|
+
}
|
|
15287
|
+
return parts.join("\n\n");
|
|
15288
|
+
}
|
|
15289
|
+
function parseResponse40(response, checkId) {
|
|
15290
|
+
return parseLLMResponse(response, checkId, "high");
|
|
15291
|
+
}
|
|
15292
|
+
var promptTemplate40 = {
|
|
15293
|
+
checkId: "ops.dockerfile-secrets-in-env",
|
|
15294
|
+
promptVersion: PROMPT_VERSION40,
|
|
15295
|
+
buildPrompt: buildPrompt40,
|
|
15296
|
+
parseResponse: parseResponse40,
|
|
15297
|
+
fallbackPatterns: fallbackPatterns40
|
|
15298
|
+
};
|
|
15299
|
+
registry.register({
|
|
15300
|
+
id: "ops.dockerfile-secrets-in-env",
|
|
15301
|
+
name: "Secrets in Dockerfile ENV",
|
|
15302
|
+
description: "Dockerfile bakes secrets into image layers via ENV or ARG instructions",
|
|
15303
|
+
category: "ops",
|
|
15304
|
+
severity: "high",
|
|
15305
|
+
minTier: "pro",
|
|
15306
|
+
async run(ctx) {
|
|
15307
|
+
return runLLMCheck(ctx, promptTemplate40);
|
|
15308
|
+
}
|
|
15309
|
+
});
|
|
15310
|
+
var PROMPT_VERSION41 = "1.0.0";
|
|
15311
|
+
var fallbackPatterns41 = [
|
|
15312
|
+
{
|
|
15313
|
+
regex: /^\s*HEALTHCHECK\b/im,
|
|
15314
|
+
message: "Dockerfile has no HEALTHCHECK instruction \u2014 container orchestrators cannot detect unhealthy state",
|
|
15315
|
+
severity: "low",
|
|
15316
|
+
absenceBased: true
|
|
15317
|
+
}
|
|
15318
|
+
];
|
|
15319
|
+
async function buildPrompt41(sourceFiles) {
|
|
15320
|
+
const parts = [
|
|
15321
|
+
"Check if a Dockerfile includes a HEALTHCHECK instruction.",
|
|
15322
|
+
"Without HEALTHCHECK, Docker and container orchestrators like Kubernetes cannot detect an unhealthy container and will continue routing traffic to it.",
|
|
15323
|
+
"Flag Dockerfiles that have no HEALTHCHECK instruction at all.",
|
|
15324
|
+
"A minimal HEALTHCHECK example: HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1",
|
|
15325
|
+
"Return [] if a HEALTHCHECK instruction is present."
|
|
15326
|
+
];
|
|
15327
|
+
for (const file of sourceFiles) {
|
|
15328
|
+
const content = await file.content();
|
|
15329
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15330
|
+
${content}`);
|
|
15331
|
+
}
|
|
15332
|
+
return parts.join("\n\n");
|
|
15333
|
+
}
|
|
15334
|
+
function parseResponse41(response, checkId) {
|
|
15335
|
+
return parseLLMResponse(response, checkId, "low");
|
|
15336
|
+
}
|
|
15337
|
+
var promptTemplate41 = {
|
|
15338
|
+
checkId: "ops.dockerfile-no-healthcheck",
|
|
15339
|
+
promptVersion: PROMPT_VERSION41,
|
|
15340
|
+
buildPrompt: buildPrompt41,
|
|
15341
|
+
parseResponse: parseResponse41,
|
|
15342
|
+
fallbackPatterns: fallbackPatterns41
|
|
15343
|
+
};
|
|
15344
|
+
registry.register({
|
|
15345
|
+
id: "ops.dockerfile-no-healthcheck",
|
|
15346
|
+
name: "Dockerfile missing HEALTHCHECK",
|
|
15347
|
+
description: "Dockerfile has no HEALTHCHECK instruction \u2014 orchestrators cannot detect unhealthy containers",
|
|
15348
|
+
category: "ops",
|
|
15349
|
+
severity: "low",
|
|
15350
|
+
minTier: "pro",
|
|
15351
|
+
async run(ctx) {
|
|
15352
|
+
return runLLMCheck(ctx, promptTemplate41);
|
|
15353
|
+
}
|
|
15354
|
+
});
|
|
15355
|
+
registry.register({
|
|
15356
|
+
id: "ops.missing-dockerignore",
|
|
15357
|
+
name: "Missing .dockerignore",
|
|
15358
|
+
description: "Dockerfile present without a .dockerignore \u2014 build context may leak sensitive files",
|
|
15359
|
+
category: "ops",
|
|
15360
|
+
severity: "low",
|
|
15361
|
+
minTier: "pro",
|
|
15362
|
+
async run(ctx) {
|
|
15363
|
+
const dockerfile = ctx.files.find((f) => f.language === "dockerfile");
|
|
15364
|
+
if (!dockerfile) return [];
|
|
15365
|
+
const hasDockerignore = ctx.files.some((f) => /(?:^|[\\/])\.dockerignore$/.test(f.relativePath));
|
|
15366
|
+
if (hasDockerignore) return [];
|
|
15367
|
+
let remediation;
|
|
15368
|
+
try {
|
|
15369
|
+
remediation = getCopy("remediation.ops.missing-dockerignore");
|
|
15370
|
+
} catch {
|
|
15371
|
+
}
|
|
15372
|
+
return [
|
|
15373
|
+
{
|
|
15374
|
+
id: "ops.missing-dockerignore-1",
|
|
15375
|
+
checkId: "ops.missing-dockerignore",
|
|
15376
|
+
category: "ops",
|
|
15377
|
+
severity: "low",
|
|
15378
|
+
message: "Dockerfile exists but no .dockerignore found \u2014 sensitive files (node_modules, .env, secrets) may be included in the build context",
|
|
15379
|
+
file: dockerfile.relativePath,
|
|
15380
|
+
line: 1,
|
|
15381
|
+
remediation
|
|
15382
|
+
}
|
|
15383
|
+
];
|
|
15384
|
+
}
|
|
15385
|
+
});
|
|
15386
|
+
var PROMPT_VERSION42 = "1.0.0";
|
|
15387
|
+
var fallbackPatterns42 = [];
|
|
15388
|
+
async function buildPrompt42(sourceFiles) {
|
|
15389
|
+
const parts = [
|
|
15390
|
+
"Check for N+1 query patterns where ORM results are iterated and a new database query is issued per row.",
|
|
15391
|
+
"In Prisma (TypeScript): flag findMany() or findFirst() results iterated with for...of or forEach where a separate findUnique/findFirst/findMany is called inside the loop body.",
|
|
15392
|
+
"In Sequelize (Node.js): flag findAll() results where model.find() or association.get() is called per element inside a loop.",
|
|
15393
|
+
"In TypeORM: flag find()/findBy() results with repository calls inside the iteration.",
|
|
15394
|
+
"In SQLAlchemy (Python): flag query.all() or session.scalars() results where session.get() or a new query is executed per row.",
|
|
15395
|
+
"In Active Record (Ruby): flag .all or .where results iterated with .each where an association is accessed without eager loading (use .includes instead).",
|
|
15396
|
+
"Flag only clear, direct N+1 patterns \u2014 a loop body that calls a query on each iteration. Do not flag loops that process already-loaded data."
|
|
15397
|
+
];
|
|
15398
|
+
for (const file of sourceFiles) {
|
|
15399
|
+
const content = await file.content();
|
|
15400
|
+
parts.push(`--- ${file.relativePath} ---
|
|
15401
|
+
${content.slice(0, 3e3)}`);
|
|
15402
|
+
}
|
|
15403
|
+
return parts.join("\n\n");
|
|
15404
|
+
}
|
|
15405
|
+
function parseResponse42(response, checkId) {
|
|
15406
|
+
return parseLLMResponse(response, checkId, "medium");
|
|
15407
|
+
}
|
|
15408
|
+
var promptTemplate42 = {
|
|
15409
|
+
checkId: "resilience.orm-n-plus-one",
|
|
15410
|
+
promptVersion: PROMPT_VERSION42,
|
|
15411
|
+
buildPrompt: buildPrompt42,
|
|
15412
|
+
parseResponse: parseResponse42,
|
|
15413
|
+
fallbackPatterns: fallbackPatterns42
|
|
15414
|
+
};
|
|
15415
|
+
registry.register({
|
|
15416
|
+
id: "resilience.orm-n-plus-one",
|
|
15417
|
+
name: "ORM N+1 query pattern",
|
|
15418
|
+
description: "ORM results are iterated with a per-row database query instead of a batch load",
|
|
15419
|
+
category: "resilience",
|
|
15420
|
+
severity: "medium",
|
|
15421
|
+
minTier: "pro",
|
|
15422
|
+
async run(ctx) {
|
|
15423
|
+
return runLLMCheck(ctx, promptTemplate42);
|
|
15424
|
+
}
|
|
15425
|
+
});
|
|
14735
15426
|
var LANGUAGE_CODES = [
|
|
14736
15427
|
"javascript",
|
|
14737
15428
|
"typescript",
|
|
@@ -15518,6 +16209,8 @@ async function checkUpdate() {
|
|
|
15518
16209
|
}
|
|
15519
16210
|
|
|
15520
16211
|
// src/cli-action.ts
|
|
16212
|
+
var _require = createRequire2(import.meta.url);
|
|
16213
|
+
var { version: CLI_VERSION } = _require("../package.json");
|
|
15521
16214
|
function getConfigFile2() {
|
|
15522
16215
|
return join15(process.env.SEAWORTHY_CONFIG_DIR ?? join15(homedir3(), ".seaworthy"), "config");
|
|
15523
16216
|
}
|
|
@@ -15594,7 +16287,7 @@ async function runCli(options) {
|
|
|
15594
16287
|
await setupCommand.handler({ action: "run" }, { tier: "free", licensedTo: "free tier" });
|
|
15595
16288
|
}
|
|
15596
16289
|
const provider = new FsCredentialProvider();
|
|
15597
|
-
const license = new LicenseClient({ baseUrl: LICENSE_SERVER_URL });
|
|
16290
|
+
const license = new LicenseClient({ baseUrl: LICENSE_SERVER_URL, userAgent: `seaworthycode/${CLI_VERSION}` });
|
|
15598
16291
|
const authResult = await resolveAuth(provider, license);
|
|
15599
16292
|
if (!authResult.ok) {
|
|
15600
16293
|
const normalized = authResult.code.replace(/^errors\./, "");
|
|
@@ -15718,7 +16411,7 @@ var repoRootEnv = resolve4(dirname7(fileURLToPath3(import.meta.url)), "../../.."
|
|
|
15718
16411
|
if (existsSync6(repoRootEnv)) {
|
|
15719
16412
|
loadEnv({ path: repoRootEnv, override: false });
|
|
15720
16413
|
}
|
|
15721
|
-
var require5 =
|
|
16414
|
+
var require5 = createRequire3(import.meta.url);
|
|
15722
16415
|
var { version } = require5("../package.json");
|
|
15723
16416
|
var cli = cac("seaworthy");
|
|
15724
16417
|
cli.version(version);
|