cqs-audit 2.0.0 → 2.1.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/dist/cqs-mcp.js +2424 -20
- package/dist/cqs.js +2424 -20
- package/package.json +1 -1
package/dist/cqs-mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// cqs-mcp v2.
|
|
2
|
+
// cqs-mcp v2.1.0 — Code Quality Studio MCP Server
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
// bin/cqs-mcp.entry.js
|
|
@@ -1283,6 +1283,30 @@ await page.clock.fastForward('02:00'); // jump 2 hours`,
|
|
|
1283
1283
|
}, disabled);
|
|
1284
1284
|
}
|
|
1285
1285
|
}
|
|
1286
|
+
{
|
|
1287
|
+
const hasTitleTag = /(?:test|describe)\s*(?:\.\w+)?\s*\(\s*['"`][^'"`]*@[\w-]+/.test(content);
|
|
1288
|
+
const hasTagOption = /\btag\s*:\s*(?:['"`]@|\[)/.test(content);
|
|
1289
|
+
const hasGrepAnnotation = /test\.info\s*\(\s*\)\s*\.annotations|annotation\s*:\s*\{/.test(content);
|
|
1290
|
+
if (totalTests > 0 && !hasTitleTag && !hasTagOption && !hasGrepAnnotation) {
|
|
1291
|
+
addFindingLocal(findings, {
|
|
1292
|
+
ruleId: "PW-STD-007",
|
|
1293
|
+
category: "coding_standards",
|
|
1294
|
+
severity: "info",
|
|
1295
|
+
title: "Tests carry no tag for selective runs",
|
|
1296
|
+
description: "No test or describe in this file carries a @tag in its title or a tag option, so these tests cannot be selected with --grep.",
|
|
1297
|
+
impact: "CI must run the whole suite on every commit \u2014 no smoke subset, and a flaky spec can only be excluded by skipping it outright.",
|
|
1298
|
+
fix: `// Either in the title
|
|
1299
|
+
test('checkout completes @smoke', async ({ page }) => { /* ... */ });
|
|
1300
|
+
|
|
1301
|
+
// Or as a tag option (Playwright 1.42+)
|
|
1302
|
+
test('checkout completes', { tag: ['@smoke', '@billing'] }, async ({ page }) => { /* ... */ });
|
|
1303
|
+
|
|
1304
|
+
// then: npx playwright test --grep @smoke`,
|
|
1305
|
+
line: lineMatches(content, /\btest\s*\(/)[0] ?? null,
|
|
1306
|
+
reference: "https://playwright.dev/docs/test-annotations#tag-tests"
|
|
1307
|
+
}, disabled);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1286
1310
|
const categoryScores = Object.fromEntries(
|
|
1287
1311
|
CATEGORY_IDS.map((id) => [id, scoreFromFindings(findings, id)])
|
|
1288
1312
|
);
|
|
@@ -1973,6 +1997,7 @@ var CATEGORY_IDS2 = AUDIT_STACKS.java_api.categories.map((c) => c.id);
|
|
|
1973
1997
|
function analyseJavaApiLocally(filename, content, options = {}) {
|
|
1974
1998
|
const disabledRuleIds = options.disabledRuleIds ?? /* @__PURE__ */ new Set();
|
|
1975
1999
|
const findings = [];
|
|
2000
|
+
const lines = content.split(/\r?\n/);
|
|
1976
2001
|
const sqlConcat = lineMatches2(content, /\+\s*["']|["']\s*\+.*SELECT|executeQuery\s*\(\s*["'][^"']*\+/i);
|
|
1977
2002
|
if (sqlConcat.length || /Statement\s+\w+\s*=|createStatement\s*\(/.test(content)) {
|
|
1978
2003
|
const stmt = lineMatches2(content, /createStatement\s*\(/);
|
|
@@ -2247,6 +2272,227 @@ class OrderServiceTest { ... }`,
|
|
|
2247
2272
|
fix: "Resolve the item or link it to a tracked issue.",
|
|
2248
2273
|
line: jTodo[0]
|
|
2249
2274
|
}, disabledRuleIds);
|
|
2275
|
+
const pathTraversal = lineMatches2(content, /new\s+File\s*\(\s*(?!["'])[\w.]*(?:request|param|input|userPath|fileName|filename)|Paths\.get\s*\(\s*(?!["'])[\w.]*(?:request|param|input|fileName|filename)/i);
|
|
2276
|
+
if (pathTraversal.length) pushFinding(findings, {
|
|
2277
|
+
ruleId: "JV-SEC-005",
|
|
2278
|
+
category: "security",
|
|
2279
|
+
severity: "critical",
|
|
2280
|
+
title: "Path traversal risk \u2014 user input in a file path",
|
|
2281
|
+
description: 'A file path is built from a request/user-supplied value without normalisation, so "../" segments can escape the intended directory.',
|
|
2282
|
+
impact: "An attacker can read or overwrite arbitrary files on the server (CWE-22).",
|
|
2283
|
+
fix: `Path base = Paths.get("/srv/uploads").toAbsolutePath().normalize();
|
|
2284
|
+
Path target = base.resolve(fileName).normalize();
|
|
2285
|
+
if (!target.startsWith(base)) throw new SecurityException("Invalid path");`,
|
|
2286
|
+
line: pathTraversal[0],
|
|
2287
|
+
reference: "https://cwe.mitre.org/data/definitions/22.html"
|
|
2288
|
+
}, disabledRuleIds);
|
|
2289
|
+
const deser = lineMatches2(content, /new\s+ObjectInputStream\s*\(|\.readObject\s*\(\s*\)/);
|
|
2290
|
+
if (deser.length) pushFinding(findings, {
|
|
2291
|
+
ruleId: "JV-SEC-006",
|
|
2292
|
+
category: "security",
|
|
2293
|
+
severity: "critical",
|
|
2294
|
+
title: "Unsafe Java deserialization",
|
|
2295
|
+
description: "ObjectInputStream.readObject() reconstructs arbitrary classes from the byte stream.",
|
|
2296
|
+
impact: "If the stream is attacker-controlled this is remote code execution via gadget chains (CWE-502).",
|
|
2297
|
+
fix: `// Prefer a data format that does not instantiate arbitrary types
|
|
2298
|
+
ObjectMapper mapper = new ObjectMapper();
|
|
2299
|
+
MyDto dto = mapper.readValue(json, MyDto.class);`,
|
|
2300
|
+
line: deser[0],
|
|
2301
|
+
reference: "https://cwe.mitre.org/data/definitions/502.html"
|
|
2302
|
+
}, disabledRuleIds);
|
|
2303
|
+
const xmlParser = lineMatches2(content, /DocumentBuilderFactory\.newInstance|SAXParserFactory\.newInstance|XMLInputFactory\.newInstance/);
|
|
2304
|
+
const xxeGuarded = /disallow-doctype-decl|XMLConstants\.FEATURE_SECURE_PROCESSING|setExpandEntityReferences\s*\(\s*false|IS_SUPPORTING_EXTERNAL_ENTITIES/.test(content);
|
|
2305
|
+
if (xmlParser.length && !xxeGuarded) pushFinding(findings, {
|
|
2306
|
+
ruleId: "JV-SEC-007",
|
|
2307
|
+
category: "security",
|
|
2308
|
+
severity: "critical",
|
|
2309
|
+
title: "XML parser without XXE protection",
|
|
2310
|
+
description: "An XML parser factory is created without disabling DOCTYPE declarations or external entities.",
|
|
2311
|
+
impact: "XML External Entity attacks can read local files or trigger SSRF from parsed documents (CWE-611).",
|
|
2312
|
+
fix: `DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
|
|
2313
|
+
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
|
2314
|
+
f.setXIncludeAware(false);
|
|
2315
|
+
f.setExpandEntityReferences(false);`,
|
|
2316
|
+
line: xmlParser[0],
|
|
2317
|
+
reference: "https://cwe.mitre.org/data/definitions/611.html"
|
|
2318
|
+
}, disabledRuleIds);
|
|
2319
|
+
const logSensitive = lineMatches2(content, /log(?:ger)?\s*\.\s*(?:info|debug|warn|error|trace)\s*\([^)]*(?:password|passwd|secret|token|apiKey|api_key|ssn|creditCard|cvv)/i);
|
|
2320
|
+
if (logSensitive.length) pushFinding(findings, {
|
|
2321
|
+
ruleId: "JV-SEC-008",
|
|
2322
|
+
category: "security",
|
|
2323
|
+
severity: "critical",
|
|
2324
|
+
title: "Sensitive data written to logs",
|
|
2325
|
+
description: `A log statement at line ${logSensitive[0]} interpolates a credential or personal identifier.`,
|
|
2326
|
+
impact: "Secrets and PII leak into log aggregators and backups, where they are rarely access-controlled or rotated.",
|
|
2327
|
+
fix: `// Log an identifier, never the secret itself
|
|
2328
|
+
log.info("Authenticated userId={}", user.getId());`,
|
|
2329
|
+
line: logSensitive[0],
|
|
2330
|
+
reference: "https://cwe.mitre.org/data/definitions/532.html"
|
|
2331
|
+
}, disabledRuleIds);
|
|
2332
|
+
const unsafeFormatter = lineMatches2(content, /(?:private|public|protected|static)[\w\s]*\b(?:SimpleDateFormat|Calendar)\s+\w+\s*=/);
|
|
2333
|
+
if (unsafeFormatter.length) pushFinding(findings, {
|
|
2334
|
+
ruleId: "JV-CON-003",
|
|
2335
|
+
category: "concurrency",
|
|
2336
|
+
severity: "critical",
|
|
2337
|
+
title: "SimpleDateFormat / Calendar held as a shared field",
|
|
2338
|
+
description: "SimpleDateFormat and Calendar are mutable and not thread-safe, but this one is a field shared across requests.",
|
|
2339
|
+
impact: "Under concurrency this silently produces wrong dates or throws \u2014 a bug that never reproduces in single-threaded tests.",
|
|
2340
|
+
fix: `// Thread-safe and immutable
|
|
2341
|
+
private static final DateTimeFormatter FMT =
|
|
2342
|
+
DateTimeFormatter.ofPattern("yyyy-MM-dd");`,
|
|
2343
|
+
line: unsafeFormatter[0],
|
|
2344
|
+
reference: "https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html"
|
|
2345
|
+
}, disabledRuleIds);
|
|
2346
|
+
const unboundedPool = lineMatches2(content, /Executors\.newCachedThreadPool\s*\(|Executors\.newFixedThreadPool\s*\(\s*\d{3,}/);
|
|
2347
|
+
if (unboundedPool.length) pushFinding(findings, {
|
|
2348
|
+
ruleId: "JV-CON-004",
|
|
2349
|
+
category: "concurrency",
|
|
2350
|
+
severity: "warning",
|
|
2351
|
+
title: "Unbounded or oversized thread pool",
|
|
2352
|
+
description: "newCachedThreadPool() grows without limit; a very large fixed pool has the same effect.",
|
|
2353
|
+
impact: "A traffic spike creates threads until the JVM exhausts memory, taking the service down rather than shedding load.",
|
|
2354
|
+
fix: `new ThreadPoolExecutor(8, 32, 60L, TimeUnit.SECONDS,
|
|
2355
|
+
new ArrayBlockingQueue<>(500),
|
|
2356
|
+
new ThreadPoolExecutor.CallerRunsPolicy());`,
|
|
2357
|
+
line: unboundedPool[0]
|
|
2358
|
+
}, disabledRuleIds);
|
|
2359
|
+
const repoWrites = countMatches2(content, /\.(?:save|saveAll|delete|deleteAll|update|persist|merge)\s*\(/g);
|
|
2360
|
+
const isService = /@Service\b|@Component\b/.test(content);
|
|
2361
|
+
if (isService && repoWrites >= 2 && !/@Transactional/.test(content)) pushFinding(findings, {
|
|
2362
|
+
ruleId: "JV-DAT-002",
|
|
2363
|
+
category: "data_access",
|
|
2364
|
+
severity: "critical",
|
|
2365
|
+
title: "Multiple writes without @Transactional",
|
|
2366
|
+
description: `This service performs ${repoWrites} repository write calls but declares no @Transactional boundary.`,
|
|
2367
|
+
impact: "A failure part-way through leaves the database in a half-written state that no rollback will undo.",
|
|
2368
|
+
fix: `@Transactional
|
|
2369
|
+
public void transfer(Long from, Long to, BigDecimal amount) {
|
|
2370
|
+
accounts.debit(from, amount);
|
|
2371
|
+
accounts.credit(to, amount);
|
|
2372
|
+
}`,
|
|
2373
|
+
line: lineMatches2(content, /\.(?:save|delete|update|persist|merge)\s*\(/)[0] ?? null
|
|
2374
|
+
}, disabledRuleIds);
|
|
2375
|
+
const unpaged = lineMatches2(content, /\.findAll\s*\(\s*\)/);
|
|
2376
|
+
if (unpaged.length) pushFinding(findings, {
|
|
2377
|
+
ruleId: "JV-DAT-003",
|
|
2378
|
+
category: "data_access",
|
|
2379
|
+
severity: "warning",
|
|
2380
|
+
title: "findAll() without pagination",
|
|
2381
|
+
description: "findAll() with no Pageable loads the entire table into memory.",
|
|
2382
|
+
impact: "Fine on a seeded dev database, then OOMs in production once the table grows.",
|
|
2383
|
+
fix: `Page<User> page = userRepository.findAll(PageRequest.of(0, 50));`,
|
|
2384
|
+
line: unpaged[0]
|
|
2385
|
+
}, disabledRuleIds);
|
|
2386
|
+
const optionalGet = lineMatches2(content, /\.get\s*\(\s*\)/).filter((ln) => {
|
|
2387
|
+
const l = lines[ln - 1] || "";
|
|
2388
|
+
return /Optional|findBy|findById/.test(l) && !/isPresent|isEmpty|orElse|ifPresent/.test(l);
|
|
2389
|
+
});
|
|
2390
|
+
if (optionalGet.length) pushFinding(findings, {
|
|
2391
|
+
ruleId: "JV-ERR-004",
|
|
2392
|
+
category: "error_handling",
|
|
2393
|
+
severity: "warning",
|
|
2394
|
+
title: "Optional.get() without a presence check",
|
|
2395
|
+
description: `Line ${optionalGet[0]} unwraps an Optional directly instead of handling the empty case.`,
|
|
2396
|
+
impact: "Throws NoSuchElementException, which surfaces as an opaque HTTP 500 rather than a meaningful 404.",
|
|
2397
|
+
fix: `User user = userRepository.findById(id)
|
|
2398
|
+
.orElseThrow(() -> new ResourceNotFoundException("User " + id));`,
|
|
2399
|
+
line: optionalGet[0]
|
|
2400
|
+
}, disabledRuleIds);
|
|
2401
|
+
const rawResource = lineMatches2(content, /=\s*new\s+(?:FileInputStream|FileOutputStream|FileReader|FileWriter|BufferedReader|Socket|Scanner)\s*\(/).filter((ln) => !/try\s*\(/.test(lines[ln - 1] || ""));
|
|
2402
|
+
if (rawResource.length) pushFinding(findings, {
|
|
2403
|
+
ruleId: "JV-ERR-005",
|
|
2404
|
+
category: "error_handling",
|
|
2405
|
+
severity: "warning",
|
|
2406
|
+
title: "Closeable opened outside try-with-resources",
|
|
2407
|
+
description: `A stream or reader is opened at line ${rawResource[0]} without try-with-resources.`,
|
|
2408
|
+
impact: "An exception before close() leaks the file handle or socket; enough leaks exhaust the descriptor limit.",
|
|
2409
|
+
fix: `try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
|
|
2410
|
+
return reader.lines().toList();
|
|
2411
|
+
}`,
|
|
2412
|
+
line: rawResource[0]
|
|
2413
|
+
}, disabledRuleIds);
|
|
2414
|
+
const stringVars = /* @__PURE__ */ new Set();
|
|
2415
|
+
for (const line of lines) {
|
|
2416
|
+
const m = /\bString\s+(\w+)\s*=/.exec(line);
|
|
2417
|
+
if (m) stringVars.add(m[1]);
|
|
2418
|
+
}
|
|
2419
|
+
const isStringAccum = (text) => {
|
|
2420
|
+
for (const re of [/(\w+)\s*\+=\s*[^;]+;/g, /(\w+)\s*=\s*\1\s*\+\s*[^;]+;/g]) {
|
|
2421
|
+
let m;
|
|
2422
|
+
while (m = re.exec(text)) if (stringVars.has(m[1])) return true;
|
|
2423
|
+
}
|
|
2424
|
+
return false;
|
|
2425
|
+
};
|
|
2426
|
+
const concatInLoop = [];
|
|
2427
|
+
lines.forEach((line, idx) => {
|
|
2428
|
+
if (!/\b(?:for|while)\s*\(/.test(line)) return;
|
|
2429
|
+
let depth = (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
|
|
2430
|
+
if (depth <= 0) {
|
|
2431
|
+
if (isStringAccum(line.slice(line.indexOf("{") + 1))) concatInLoop.push(idx + 1);
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
for (let j = idx + 1; j < lines.length && depth > 0; j++) {
|
|
2435
|
+
if (isStringAccum(lines[j])) {
|
|
2436
|
+
concatInLoop.push(j + 1);
|
|
2437
|
+
break;
|
|
2438
|
+
}
|
|
2439
|
+
depth += (lines[j].match(/\{/g) || []).length - (lines[j].match(/\}/g) || []).length;
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
if (concatInLoop.length) pushFinding(findings, {
|
|
2443
|
+
ruleId: "JV-PER-002",
|
|
2444
|
+
category: "performance",
|
|
2445
|
+
severity: "warning",
|
|
2446
|
+
title: "String concatenation inside a loop",
|
|
2447
|
+
description: `Line ${concatInLoop[0]} builds a String with + inside a loop, allocating a new String each iteration.`,
|
|
2448
|
+
impact: "Quadratic time and garbage churn; noticeable once the loop runs thousands of times.",
|
|
2449
|
+
fix: `StringBuilder sb = new StringBuilder();
|
|
2450
|
+
for (String part : parts) sb.append(part);
|
|
2451
|
+
return sb.toString();`,
|
|
2452
|
+
line: concatInLoop[0]
|
|
2453
|
+
}, disabledRuleIds);
|
|
2454
|
+
const httpClient = lineMatches2(content, /new\s+RestTemplate\s*\(\s*\)|HttpClient\.newHttpClient\s*\(\s*\)/);
|
|
2455
|
+
const hasTimeout = /setConnectTimeout|setReadTimeout|connectTimeout|HttpComponentsClientHttpRequestFactory|\.timeout\s*\(/.test(content);
|
|
2456
|
+
if (httpClient.length && !hasTimeout) pushFinding(findings, {
|
|
2457
|
+
ruleId: "JV-PER-003",
|
|
2458
|
+
category: "performance",
|
|
2459
|
+
severity: "critical",
|
|
2460
|
+
title: "HTTP client without timeouts",
|
|
2461
|
+
description: "A RestTemplate or HttpClient is created with default settings, which means no connect or read timeout.",
|
|
2462
|
+
impact: "One slow upstream ties up request threads until the pool is exhausted \u2014 the classic cascading outage.",
|
|
2463
|
+
fix: `RestTemplate rt = new RestTemplateBuilder()
|
|
2464
|
+
.setConnectTimeout(Duration.ofSeconds(2))
|
|
2465
|
+
.setReadTimeout(Duration.ofSeconds(5))
|
|
2466
|
+
.build();`,
|
|
2467
|
+
line: httpClient[0]
|
|
2468
|
+
}, disabledRuleIds);
|
|
2469
|
+
const isController = /@RestController\b|@Controller\b/.test(content);
|
|
2470
|
+
if (isController && /@Entity\b/.test(content)) pushFinding(findings, {
|
|
2471
|
+
ruleId: "JV-API-004",
|
|
2472
|
+
category: "api_design",
|
|
2473
|
+
severity: "warning",
|
|
2474
|
+
title: "JPA entity exposed by a controller",
|
|
2475
|
+
description: "A @Entity type is referenced directly in a controller instead of a dedicated response DTO.",
|
|
2476
|
+
impact: "Every column becomes part of the public contract, lazy associations blow up during serialisation, and a schema rename silently breaks clients.",
|
|
2477
|
+
fix: `public record UserResponse(Long id, String email) {
|
|
2478
|
+
static UserResponse from(User u) { return new UserResponse(u.getId(), u.getEmail()); }
|
|
2479
|
+
}`,
|
|
2480
|
+
line: lineMatches2(content, /@Entity\b/)[0] ?? null
|
|
2481
|
+
}, disabledRuleIds);
|
|
2482
|
+
const hasEquals = /public\s+boolean\s+equals\s*\(\s*Object/.test(content);
|
|
2483
|
+
const hasHashCode = /public\s+int\s+hashCode\s*\(\s*\)/.test(content);
|
|
2484
|
+
if (hasEquals !== hasHashCode) pushFinding(findings, {
|
|
2485
|
+
ruleId: "JV-STD-003",
|
|
2486
|
+
category: "java_standards",
|
|
2487
|
+
severity: "warning",
|
|
2488
|
+
title: `${hasEquals ? "equals() without hashCode()" : "hashCode() without equals()"}`,
|
|
2489
|
+
description: "equals() and hashCode() must be overridden together to honour the Object contract.",
|
|
2490
|
+
impact: "Objects that are equal land in different hash buckets, so HashMap/HashSet lookups silently miss.",
|
|
2491
|
+
fix: `@Override public boolean equals(Object o) { /* ... */ }
|
|
2492
|
+
@Override public int hashCode() { return Objects.hash(id); }`,
|
|
2493
|
+
line: lineMatches2(content, hasEquals ? /public\s+boolean\s+equals\s*\(/ : /public\s+int\s+hashCode\s*\(/)[0] ?? null,
|
|
2494
|
+
reference: "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode--"
|
|
2495
|
+
}, disabledRuleIds);
|
|
2250
2496
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
2251
2497
|
const summary = crit > 0 ? `Java API scan of ${filename}: ${findings.length} finding(s), ${crit} critical (security/data).` : `Java API scan of ${filename}: ${findings.length} finding(s) from standard JVM/API rules.`;
|
|
2252
2498
|
return buildAuditResult({
|
|
@@ -2729,7 +2975,8 @@ void setUp() { page = context.newPage(); page.navigate("/app"); }`,
|
|
|
2729
2975
|
line: screenshot[0],
|
|
2730
2976
|
reference: "https://playwright.dev/java/docs/trace-viewer"
|
|
2731
2977
|
}, disabledRuleIds);
|
|
2732
|
-
|
|
2978
|
+
const closedInTeardown = /@(?:AfterAll|AfterClass|AfterEach|After)\b[\s\S]{0,400}?\b(?:playwright|browser)\s*\.\s*close\s*\(/i.test(content);
|
|
2979
|
+
if (/Playwright\.create\s*\(/.test(content) && !/try\s*\(/.test(content) && !closedInTeardown) pushFinding(findings, {
|
|
2733
2980
|
ruleId: "PWJ-RES-001",
|
|
2734
2981
|
category: "resource_mgmt",
|
|
2735
2982
|
severity: "warning",
|
|
@@ -2790,6 +3037,212 @@ page.navigate("/login");`,
|
|
|
2790
3037
|
line: absUrl[0],
|
|
2791
3038
|
reference: "https://playwright.dev/java/docs/test-runners"
|
|
2792
3039
|
}, disabledRuleIds);
|
|
3040
|
+
const testStarts = [];
|
|
3041
|
+
lines.forEach((l, i) => {
|
|
3042
|
+
if (/^\s*@Test\b/.test(l)) testStarts.push(i);
|
|
3043
|
+
});
|
|
3044
|
+
const blockOf = (k) => {
|
|
3045
|
+
const start = testStarts[k];
|
|
3046
|
+
const rest = lines.slice(start + 1);
|
|
3047
|
+
const nextAnn = rest.findIndex((l) => /^\s*@(?:Test|BeforeEach|BeforeAll|AfterEach|AfterAll|ParameterizedTest)\b/.test(l));
|
|
3048
|
+
return rest.slice(0, nextAnn === -1 ? rest.length : nextAnn).join("\n");
|
|
3049
|
+
};
|
|
3050
|
+
const querySelector = lineMatches2(content, /\.querySelector(?:All)?\s*\(/);
|
|
3051
|
+
if (querySelector.length) pushFinding(findings, {
|
|
3052
|
+
ruleId: "PWJ-SEL-004",
|
|
3053
|
+
category: "selectors",
|
|
3054
|
+
severity: "warning",
|
|
3055
|
+
title: "Legacy querySelector API",
|
|
3056
|
+
description: `querySelector()/querySelectorAll() returns an ElementHandle on line ${querySelector[0]}.`,
|
|
3057
|
+
impact: "ElementHandles resolve once and never re-query, so they go stale after a re-render and skip Playwright's auto-waiting.",
|
|
3058
|
+
fix: `// Before
|
|
3059
|
+
ElementHandle el = page.querySelector(".row");
|
|
3060
|
+
// After
|
|
3061
|
+
Locator row = page.locator(".row");
|
|
3062
|
+
assertThat(row).isVisible();`,
|
|
3063
|
+
line: querySelector[0],
|
|
3064
|
+
reference: "https://playwright.dev/java/docs/locators"
|
|
3065
|
+
}, disabledRuleIds);
|
|
3066
|
+
const waitForNav = lineMatches2(content, /waitForNavigation\s*\(/);
|
|
3067
|
+
if (waitForNav.length) pushFinding(findings, {
|
|
3068
|
+
ruleId: "PWJ-REL-005",
|
|
3069
|
+
category: "reliability",
|
|
3070
|
+
severity: "warning",
|
|
3071
|
+
title: "Deprecated waitForNavigation()",
|
|
3072
|
+
description: `waitForNavigation() is used on line ${waitForNav[0]}.`,
|
|
3073
|
+
impact: "Deprecated and inherently racy \u2014 the navigation can complete before the waiter is attached, which hangs the test until timeout.",
|
|
3074
|
+
fix: `// Before
|
|
3075
|
+
page.waitForNavigation(() -> page.getByRole(AriaRole.LINK).click());
|
|
3076
|
+
// After
|
|
3077
|
+
page.getByRole(AriaRole.LINK).click();
|
|
3078
|
+
assertThat(page).hasURL(Pattern.compile("/orders"));`,
|
|
3079
|
+
line: waitForNav[0],
|
|
3080
|
+
reference: "https://playwright.dev/java/docs/navigations"
|
|
3081
|
+
}, disabledRuleIds);
|
|
3082
|
+
const networkIdle = lineMatches2(content, /WaitUntilState\.NETWORKIDLE|setWaitUntil\s*\([^)]*NETWORKIDLE/i);
|
|
3083
|
+
if (networkIdle.length) pushFinding(findings, {
|
|
3084
|
+
ruleId: "PWJ-REL-006",
|
|
3085
|
+
category: "reliability",
|
|
3086
|
+
severity: "warning",
|
|
3087
|
+
title: "Waiting on NETWORKIDLE",
|
|
3088
|
+
description: `A NETWORKIDLE wait state is requested on line ${networkIdle[0]}.`,
|
|
3089
|
+
impact: "Discouraged by Playwright: polling, analytics beacons or websockets keep the network busy, so the wait times out or resolves at an arbitrary moment.",
|
|
3090
|
+
fix: `// Before
|
|
3091
|
+
page.navigate("/dashboard", new Page.NavigateOptions().setWaitUntil(WaitUntilState.NETWORKIDLE));
|
|
3092
|
+
// After
|
|
3093
|
+
page.navigate("/dashboard");
|
|
3094
|
+
assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Dashboard"))).isVisible();`,
|
|
3095
|
+
line: networkIdle[0],
|
|
3096
|
+
reference: "https://playwright.dev/java/docs/api/class-page#page-navigate"
|
|
3097
|
+
}, disabledRuleIds);
|
|
3098
|
+
const defaultTimeout = lineMatches2(content, /setDefault(?:Navigation)?Timeout\s*\(/);
|
|
3099
|
+
if (defaultTimeout.length) pushFinding(findings, {
|
|
3100
|
+
ruleId: "PWJ-REL-007",
|
|
3101
|
+
category: "reliability",
|
|
3102
|
+
severity: "warning",
|
|
3103
|
+
title: "Timeout tuned inside the test",
|
|
3104
|
+
description: `setDefaultTimeout()/setDefaultNavigationTimeout() is called on line ${defaultTimeout[0]}.`,
|
|
3105
|
+
impact: "Inflating the timeout locally hides genuine slowness and drifts out of sync with the project's CI budget.",
|
|
3106
|
+
fix: `// Configure timeouts once in the shared fixture/options class, then rely on auto-waiting assertions
|
|
3107
|
+
assertThat(page.getByRole(AriaRole.TABLE)).isVisible();`,
|
|
3108
|
+
line: defaultTimeout[0],
|
|
3109
|
+
reference: "https://playwright.dev/java/docs/test-runners"
|
|
3110
|
+
}, disabledRuleIds);
|
|
3111
|
+
const noAssertTests = /assertThat\s*\(/.test(content) ? testStarts.filter((_, k) => !/assertThat\s*\(|\bassert[A-Z]\w*\s*\(|\bAssertions\s*\.|\bassert\s+\w/.test(blockOf(k))).map((i) => i + 1) : [];
|
|
3112
|
+
if (noAssertTests.length) pushFinding(findings, {
|
|
3113
|
+
ruleId: "PWJ-AST-003",
|
|
3114
|
+
category: "assertions",
|
|
3115
|
+
severity: "warning",
|
|
3116
|
+
title: "Test method with no assertion",
|
|
3117
|
+
description: `The @Test at line ${noAssertTests[0]} drives the UI but never asserts anything.`,
|
|
3118
|
+
impact: "It only fails when an action throws, so a silently broken page still reports green.",
|
|
3119
|
+
fix: `@Test
|
|
3120
|
+
void checkout() {
|
|
3121
|
+
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Pay")).click();
|
|
3122
|
+
assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Order confirmed"))).isVisible();
|
|
3123
|
+
}`,
|
|
3124
|
+
line: noAssertTests[0],
|
|
3125
|
+
reference: "https://playwright.dev/java/docs/best-practices"
|
|
3126
|
+
}, disabledRuleIds);
|
|
3127
|
+
const rawPageCalls = countMatches2(content, /\bpage\.(?:navigate|locator|getBy\w+|click|fill|press|check|selectOption)\s*\(/g);
|
|
3128
|
+
if (hasTests && rawPageCalls >= 15 && !/class\s+\w*Page\b|import\s+[\w.]*\.pages?\./.test(content)) pushFinding(findings, {
|
|
3129
|
+
ruleId: "PWJ-STR-002",
|
|
3130
|
+
category: "structure",
|
|
3131
|
+
severity: "info",
|
|
3132
|
+
title: "No page-object abstraction",
|
|
3133
|
+
description: `${rawPageCalls} raw page.* interactions with no page-object class or pages package import.`,
|
|
3134
|
+
impact: "Selectors and flows are duplicated across test classes, so one UI change forces edits in many files.",
|
|
3135
|
+
fix: `public class LoginPage {
|
|
3136
|
+
private final Locator submit;
|
|
3137
|
+
public LoginPage(Page page) { this.submit = page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Sign in")); }
|
|
3138
|
+
public void signIn(String user, String secret) { /* ... */ }
|
|
3139
|
+
}`,
|
|
3140
|
+
line: null,
|
|
3141
|
+
reference: "https://playwright.dev/java/docs/pom"
|
|
3142
|
+
}, disabledRuleIds);
|
|
3143
|
+
const launches = lineMatches2(content, /\.launch\s*\(|newContext\s*\(/);
|
|
3144
|
+
if (launches.length && !/\.close\s*\(/.test(content) && !/try\s*\(/.test(content)) pushFinding(findings, {
|
|
3145
|
+
ruleId: "PWJ-RES-002",
|
|
3146
|
+
category: "resource_mgmt",
|
|
3147
|
+
severity: "warning",
|
|
3148
|
+
title: "Browser/context opened but never closed",
|
|
3149
|
+
description: `launch()/newContext() on line ${launches[0]} with no matching close() and no try-with-resources.`,
|
|
3150
|
+
impact: "Every run leaks a browser process and its profile directory, so long CI jobs exhaust memory and file handles.",
|
|
3151
|
+
fix: `@AfterAll
|
|
3152
|
+
static void tearDown() {
|
|
3153
|
+
context.close();
|
|
3154
|
+
browser.close();
|
|
3155
|
+
}`,
|
|
3156
|
+
line: launches[0],
|
|
3157
|
+
reference: "https://playwright.dev/java/docs/browsers"
|
|
3158
|
+
}, disabledRuleIds);
|
|
3159
|
+
const createInTest = testStarts.filter((_, k) => /Playwright\.create\s*\(/.test(blockOf(k))).map((i) => i + 1);
|
|
3160
|
+
if (createInTest.length) pushFinding(findings, {
|
|
3161
|
+
ruleId: "PWJ-RES-003",
|
|
3162
|
+
category: "resource_mgmt",
|
|
3163
|
+
severity: "warning",
|
|
3164
|
+
title: "Playwright.create() inside a test method",
|
|
3165
|
+
description: `The @Test at line ${createInTest[0]} creates its own Playwright instance instead of taking one from a fixture.`,
|
|
3166
|
+
impact: "Each test pays full driver + browser startup and manages its own teardown, which is where leaked processes come from.",
|
|
3167
|
+
fix: `@BeforeAll
|
|
3168
|
+
static void launchBrowser() {
|
|
3169
|
+
playwright = Playwright.create();
|
|
3170
|
+
browser = playwright.chromium().launch();
|
|
3171
|
+
}
|
|
3172
|
+
// \u2026or use @UsePlaywright to let the JUnit extension own the lifecycle.`,
|
|
3173
|
+
line: createInTest[0],
|
|
3174
|
+
reference: "https://playwright.dev/java/docs/junit"
|
|
3175
|
+
}, disabledRuleIds);
|
|
3176
|
+
const emptyCatch = [];
|
|
3177
|
+
lines.forEach((l, i) => {
|
|
3178
|
+
if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(l)) emptyCatch.push(i + 1);
|
|
3179
|
+
else if (/catch\s*\([^)]*\)\s*\{\s*$/.test(l) && /^\s*\}\s*$/.test(lines[i + 1] ?? "")) emptyCatch.push(i + 1);
|
|
3180
|
+
});
|
|
3181
|
+
if (emptyCatch.length) pushFinding(findings, {
|
|
3182
|
+
ruleId: "PWJ-STD-004",
|
|
3183
|
+
category: "coding_standards",
|
|
3184
|
+
severity: "warning",
|
|
3185
|
+
title: "Exception swallowed by an empty catch",
|
|
3186
|
+
description: `An empty catch block appears on line ${emptyCatch[0]}.`,
|
|
3187
|
+
impact: "Real failures are converted into passes \u2014 this is how a broken flow stays green for weeks.",
|
|
3188
|
+
fix: `// Assert the expected end state instead of guarding with try/catch
|
|
3189
|
+
assertThat(page.getByRole(AriaRole.ALERT)).hasText("Saved");`,
|
|
3190
|
+
line: emptyCatch[0],
|
|
3191
|
+
reference: "https://playwright.dev/java/docs/best-practices"
|
|
3192
|
+
}, disabledRuleIds);
|
|
3193
|
+
const disabledTests = lineMatches2(content, /^\s*@(?:Disabled|Ignore)\b/);
|
|
3194
|
+
if (disabledTests.length) pushFinding(findings, {
|
|
3195
|
+
ruleId: "PWJ-STD-005",
|
|
3196
|
+
category: "coding_standards",
|
|
3197
|
+
severity: "info",
|
|
3198
|
+
title: "Disabled test",
|
|
3199
|
+
description: `A test is switched off with @Disabled/@Ignore on line ${disabledTests[0]}.`,
|
|
3200
|
+
impact: "Permanently disabled tests rot silently while the dashboard still counts them as coverage.",
|
|
3201
|
+
fix: `@Disabled("blocked by BUG-1234 \u2014 re-enable once the fix ships") // always give a reason + ticket`,
|
|
3202
|
+
line: disabledTests[0],
|
|
3203
|
+
reference: "https://junit.org/junit5/docs/current/user-guide/#writing-tests-disabling"
|
|
3204
|
+
}, disabledRuleIds);
|
|
3205
|
+
const headed = lineMatches2(content, /setHeadless\s*\(\s*false\s*\)/i);
|
|
3206
|
+
if (headed.length) pushFinding(findings, {
|
|
3207
|
+
ruleId: "PWJ-CI-002",
|
|
3208
|
+
category: "ci_config",
|
|
3209
|
+
severity: "warning",
|
|
3210
|
+
title: "Headed mode hardcoded",
|
|
3211
|
+
description: `setHeadless(false) is committed on line ${headed[0]}.`,
|
|
3212
|
+
impact: "CI agents have no display, so the job hangs or crashes; locally it just makes the suite slower.",
|
|
3213
|
+
fix: `// Let the environment decide
|
|
3214
|
+
new BrowserType.LaunchOptions().setHeadless(!Boolean.getBoolean("headed"));`,
|
|
3215
|
+
line: headed[0],
|
|
3216
|
+
reference: "https://playwright.dev/java/docs/debug"
|
|
3217
|
+
}, disabledRuleIds);
|
|
3218
|
+
if (launches.length && !/tracing\s*\(\s*\)\s*\.\s*start|setRecordVideoDir|setRecordHarPath/.test(content)) pushFinding(findings, {
|
|
3219
|
+
ruleId: "PWJ-CI-003",
|
|
3220
|
+
category: "ci_config",
|
|
3221
|
+
severity: "info",
|
|
3222
|
+
title: "No trace, video or HAR capture configured",
|
|
3223
|
+
description: `A browser/context is created on line ${launches[0]} with no tracing, video or HAR recording.`,
|
|
3224
|
+
impact: "A CI-only failure leaves nothing to debug with, so the flake gets retried instead of fixed.",
|
|
3225
|
+
fix: `context = browser.newContext(new Browser.NewContextOptions().setRecordVideoDir(Paths.get("target/videos")));
|
|
3226
|
+
context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true));`,
|
|
3227
|
+
line: launches[0],
|
|
3228
|
+
reference: "https://playwright.dev/java/docs/trace-viewer"
|
|
3229
|
+
}, disabledRuleIds);
|
|
3230
|
+
{
|
|
3231
|
+
const hasTag = /@Tag\s*\(|@Category\s*\(|groups\s*=\s*[{"']/.test(content);
|
|
3232
|
+
if (/@Test\b/.test(content) && !hasTag) pushFinding(findings, {
|
|
3233
|
+
ruleId: "PWJ-STD-006",
|
|
3234
|
+
category: "coding_standards",
|
|
3235
|
+
severity: "info",
|
|
3236
|
+
title: "Tests carry no @Tag or TestNG group",
|
|
3237
|
+
description: "No @Tag, @Category or groups= appears in this file, so its tests cannot be selected by the runner.",
|
|
3238
|
+
impact: "CI must run everything on every commit \u2014 no smoke subset, and a flaky test can only be excluded by deleting it.",
|
|
3239
|
+
fix: `@Test
|
|
3240
|
+
@Tag("smoke")
|
|
3241
|
+
void loadsDashboard() { }`,
|
|
3242
|
+
line: lineMatches2(content, /@Test\b/)[0] ?? null,
|
|
3243
|
+
reference: "https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering"
|
|
3244
|
+
}, disabledRuleIds);
|
|
3245
|
+
}
|
|
2793
3246
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
2794
3247
|
const summary = crit > 0 ? `Playwright (Java) scan of ${filename}: ${findings.length} finding(s), ${crit} critical.` : `Playwright (Java) scan of ${filename}: ${findings.length} finding(s) from standard rules.`;
|
|
2795
3248
|
return buildAuditResult({
|
|
@@ -2996,6 +3449,236 @@ page.goto("/login")`,
|
|
|
2996
3449
|
fix: "Configure screenshot on failure instead.",
|
|
2997
3450
|
line: screenshot[0]
|
|
2998
3451
|
});
|
|
3452
|
+
const queryAPI = lineMatches2(content, /\.query_selector(?:_all)?\s*\(/);
|
|
3453
|
+
if (queryAPI.length) add({
|
|
3454
|
+
ruleId: "PWPY-SEL-004",
|
|
3455
|
+
category: "selectors",
|
|
3456
|
+
severity: "warning",
|
|
3457
|
+
title: "Legacy query_selector API",
|
|
3458
|
+
description: `query_selector()/query_selector_all() returns an ElementHandle on line ${queryAPI[0]}.`,
|
|
3459
|
+
impact: "ElementHandles are resolved once and do not auto-wait or re-query, so they go stale after a re-render.",
|
|
3460
|
+
fix: `# Before
|
|
3461
|
+
el = page.query_selector(".row")
|
|
3462
|
+
# After
|
|
3463
|
+
row = page.locator(".row")
|
|
3464
|
+
expect(row).to_be_visible()`,
|
|
3465
|
+
line: queryAPI[0],
|
|
3466
|
+
reference: "https://playwright.dev/python/docs/locators"
|
|
3467
|
+
});
|
|
3468
|
+
const networkIdle = lineMatches2(content, /wait_until\s*=\s*["']networkidle["']|wait_for_load_state\s*\(\s*["']networkidle["']/);
|
|
3469
|
+
if (networkIdle.length) add({
|
|
3470
|
+
ruleId: "PWPY-REL-005",
|
|
3471
|
+
category: "reliability",
|
|
3472
|
+
severity: "warning",
|
|
3473
|
+
title: "Waiting on networkidle",
|
|
3474
|
+
description: `A networkidle wait is used on line ${networkIdle[0]}.`,
|
|
3475
|
+
impact: "Discouraged by Playwright: polling, analytics beacons or websockets keep the network busy and the wait times out or resolves arbitrarily.",
|
|
3476
|
+
fix: `# Before
|
|
3477
|
+
page.goto("/dashboard", wait_until="networkidle")
|
|
3478
|
+
# After
|
|
3479
|
+
page.goto("/dashboard")
|
|
3480
|
+
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()`,
|
|
3481
|
+
line: networkIdle[0],
|
|
3482
|
+
reference: "https://playwright.dev/python/docs/api/class-page#page-goto"
|
|
3483
|
+
});
|
|
3484
|
+
const defaultTimeout = lineMatches2(content, /set_default_(?:navigation_)?timeout\s*\(/);
|
|
3485
|
+
if (defaultTimeout.length) add({
|
|
3486
|
+
ruleId: "PWPY-REL-006",
|
|
3487
|
+
category: "reliability",
|
|
3488
|
+
severity: "warning",
|
|
3489
|
+
title: "Timeout tuned inside the test file",
|
|
3490
|
+
description: `set_default_timeout()/set_default_navigation_timeout() is called on line ${defaultTimeout[0]}.`,
|
|
3491
|
+
impact: "Per-file timeout inflation hides real slowness and drifts out of sync with the project's CI budget.",
|
|
3492
|
+
fix: `# pytest.ini / pyproject.toml
|
|
3493
|
+
# [tool.pytest.ini_options]
|
|
3494
|
+
# ... configure timeout centrally, then rely on auto-waiting assertions
|
|
3495
|
+
expect(page.get_by_role("table")).to_be_visible()`,
|
|
3496
|
+
line: defaultTimeout[0],
|
|
3497
|
+
reference: "https://playwright.dev/python/docs/test-timeouts"
|
|
3498
|
+
});
|
|
3499
|
+
const mixedApi = /from\s+playwright\.sync_api\s+import|playwright\.sync_api\./.test(content) && /from\s+playwright\.async_api\s+import|playwright\.async_api\./.test(content);
|
|
3500
|
+
const isAsyncApi = !mixedApi && /async\s+def\s+test_/.test(content);
|
|
3501
|
+
const missingAwait = isAsyncApi ? lines.map((l, i) => [l, i + 1]).filter(([l]) => /(?:\bexpect\s*\(|\bpage\.(?:goto|click|fill|press|check|uncheck|select_option|wait_for_url|set_input_files)\s*\()/.test(l)).filter(([l]) => !/\bawait\b/.test(l) && !/^\s*(?:#|from\b|import\b|def\s|async\s+def\s)/.test(l)).map(([, n]) => n) : [];
|
|
3502
|
+
if (missingAwait.length) add({
|
|
3503
|
+
ruleId: "PWPY-REL-007",
|
|
3504
|
+
category: "reliability",
|
|
3505
|
+
severity: "critical",
|
|
3506
|
+
title: "Coroutine not awaited in async test",
|
|
3507
|
+
description: `Line ${missingAwait[0]} calls an async Playwright API without await.`,
|
|
3508
|
+
impact: "The coroutine is never scheduled, so the action or assertion silently does nothing and the test passes vacuously.",
|
|
3509
|
+
fix: `# Before
|
|
3510
|
+
expect(page.get_by_role("alert")).to_be_visible()
|
|
3511
|
+
# After
|
|
3512
|
+
await expect(page.get_by_role("alert")).to_be_visible()`,
|
|
3513
|
+
line: missingAwait[0],
|
|
3514
|
+
reference: "https://playwright.dev/python/docs/library#async-api"
|
|
3515
|
+
});
|
|
3516
|
+
if (mixedApi) add({
|
|
3517
|
+
ruleId: "PWPY-REL-008",
|
|
3518
|
+
category: "reliability",
|
|
3519
|
+
severity: "critical",
|
|
3520
|
+
title: "sync_api and async_api mixed in one module",
|
|
3521
|
+
description: "The module imports from both playwright.sync_api and playwright.async_api.",
|
|
3522
|
+
impact: "Sync calls inside a running event loop raise 'Sync API inside asyncio loop'; the two object graphs are not interchangeable.",
|
|
3523
|
+
fix: `# Pick one API per module \u2014 for pytest-playwright, stay on the sync API
|
|
3524
|
+
from playwright.sync_api import Page, expect`,
|
|
3525
|
+
line: lineMatches2(content, /playwright\.async_api/)[0] ?? null,
|
|
3526
|
+
reference: "https://playwright.dev/python/docs/library"
|
|
3527
|
+
});
|
|
3528
|
+
const testStarts = [];
|
|
3529
|
+
lines.forEach((l, i) => {
|
|
3530
|
+
if (/^\s*(?:async\s+)?def\s+test_/.test(l)) testStarts.push(i);
|
|
3531
|
+
});
|
|
3532
|
+
const blockOf = (k) => lines.slice(testStarts[k], k + 1 < testStarts.length ? testStarts[k + 1] : lines.length).join("\n");
|
|
3533
|
+
const noAssertTests = /\bexpect\s*\(/.test(content) ? testStarts.filter((_, k) => !/\bexpect\s*\(|(?:^|\n)\s*assert\b/.test(blockOf(k))).map((i) => i + 1) : [];
|
|
3534
|
+
if (noAssertTests.length) add({
|
|
3535
|
+
ruleId: "PWPY-AST-003",
|
|
3536
|
+
category: "assertions",
|
|
3537
|
+
severity: "warning",
|
|
3538
|
+
title: "Test function with no assertion",
|
|
3539
|
+
description: `The test starting on line ${noAssertTests[0]} performs actions but never asserts.`,
|
|
3540
|
+
impact: "It only fails on an exception, so a silently broken page still reports green.",
|
|
3541
|
+
fix: `def test_checkout(page: Page) -> None:
|
|
3542
|
+
page.get_by_role("button", name="Pay").click()
|
|
3543
|
+
expect(page.get_by_role("heading", name="Order confirmed")).to_be_visible()`,
|
|
3544
|
+
line: noAssertTests[0],
|
|
3545
|
+
reference: "https://playwright.dev/python/docs/best-practices"
|
|
3546
|
+
});
|
|
3547
|
+
const perTestLaunch = testStarts.filter((_, k) => /sync_playwright\s*\(\s*\)|\.launch\s*\(/.test(blockOf(k))).map((i) => i + 1);
|
|
3548
|
+
if (perTestLaunch.length) add({
|
|
3549
|
+
ruleId: "PWPY-PER-002",
|
|
3550
|
+
category: "performance",
|
|
3551
|
+
severity: "warning",
|
|
3552
|
+
title: "Browser launched inside a test",
|
|
3553
|
+
description: `The test starting on line ${perTestLaunch[0]} calls sync_playwright()/launch() itself instead of using the page fixture.`,
|
|
3554
|
+
impact: "Each test pays full browser startup cost and loses pytest-playwright's tracing, video and parallel-worker handling.",
|
|
3555
|
+
fix: `def test_login(page: Page) -> None: # pytest-playwright injects a managed page
|
|
3556
|
+
page.goto("/login")`,
|
|
3557
|
+
line: perTestLaunch[0],
|
|
3558
|
+
reference: "https://playwright.dev/python/docs/test-runners"
|
|
3559
|
+
});
|
|
3560
|
+
const skipped = lineMatches2(content, /@pytest\.mark\.(?:skip|skipif|xfail)\b|(?:^|\s)pytest\.skip\s*\(/);
|
|
3561
|
+
if (skipped.length) add({
|
|
3562
|
+
ruleId: "PWPY-STR-002",
|
|
3563
|
+
category: "structure",
|
|
3564
|
+
severity: "info",
|
|
3565
|
+
title: "Skipped or xfail test",
|
|
3566
|
+
description: `A test is skipped/xfailed at line ${skipped[0]}.`,
|
|
3567
|
+
impact: "Permanently disabled tests rot silently and give false coverage confidence.",
|
|
3568
|
+
fix: `@pytest.mark.skip(reason="blocked by BUG-1234, re-enable when fixed") # always give a reason + ticket`,
|
|
3569
|
+
line: skipped[0],
|
|
3570
|
+
reference: "https://docs.pytest.org/en/stable/how-to/skipping.html"
|
|
3571
|
+
});
|
|
3572
|
+
const rawPageCalls = countMatches2(content, /\bpage\.(?:goto|locator|get_by_\w+|click|fill|press|check|select_option)\s*\(/g);
|
|
3573
|
+
if (hasTests && rawPageCalls >= 15 && !/class\s+\w*Page\b|from\s+\S*pages?\b|import\s+\S*pages?\b/.test(content))
|
|
3574
|
+
add({
|
|
3575
|
+
ruleId: "PWPY-STR-003",
|
|
3576
|
+
category: "structure",
|
|
3577
|
+
severity: "info",
|
|
3578
|
+
title: "No page-object abstraction",
|
|
3579
|
+
description: `${rawPageCalls} raw page.* interactions with no page-object class or pages module import.`,
|
|
3580
|
+
impact: "Selectors and flows are duplicated across tests, so one UI change means edits in many files.",
|
|
3581
|
+
fix: `class LoginPage:
|
|
3582
|
+
def __init__(self, page: Page) -> None:
|
|
3583
|
+
self.page = page
|
|
3584
|
+
self.submit = page.get_by_role("button", name="Sign in")
|
|
3585
|
+
|
|
3586
|
+
def login(self, user: str, pwd: str) -> None: ...`,
|
|
3587
|
+
line: null,
|
|
3588
|
+
reference: "https://playwright.dev/python/docs/pom"
|
|
3589
|
+
});
|
|
3590
|
+
const insecureTls = lineMatches2(content, /ignore_https_errors\s*=\s*True/i);
|
|
3591
|
+
if (insecureTls.length) add({
|
|
3592
|
+
ruleId: "PWPY-SEC-002",
|
|
3593
|
+
category: "security",
|
|
3594
|
+
severity: "warning",
|
|
3595
|
+
title: "TLS validation disabled",
|
|
3596
|
+
description: `ignore_https_errors=True on line ${insecureTls[0]} turns off certificate checking.`,
|
|
3597
|
+
impact: "The suite passes against a misconfigured or MITM'd endpoint, so certificate regressions reach production unnoticed.",
|
|
3598
|
+
fix: `context = browser.new_context() # trust real certificates; install the CA in CI if needed`,
|
|
3599
|
+
line: insecureTls[0],
|
|
3600
|
+
reference: "https://playwright.dev/python/docs/api/class-browser#browser-new-context"
|
|
3601
|
+
});
|
|
3602
|
+
const uiLogin = lineMatches2(content, /(?:get_by_label|get_by_placeholder|get_by_test_id|locator)\s*\(\s*["'][^"']*(?:password|passwd)[^"']*["']\s*\)\s*\.fill\s*\(|\.fill\s*\(\s*os\.environ\[\s*["'][^"']*PASSWORD/i);
|
|
3603
|
+
if (uiLogin.length && !/storage_state/.test(content))
|
|
3604
|
+
add({
|
|
3605
|
+
ruleId: "PWPY-SEC-003",
|
|
3606
|
+
category: "security",
|
|
3607
|
+
severity: "warning",
|
|
3608
|
+
title: "UI login repeated without storage_state reuse",
|
|
3609
|
+
description: `Credentials are typed into the UI on line ${uiLogin[0]} and no storage_state is saved or loaded.`,
|
|
3610
|
+
impact: "Every test replays the login form \u2014 slow, and it spreads real credentials across each worker's traces and videos.",
|
|
3611
|
+
fix: `# conftest.py: authenticate once, then reuse
|
|
3612
|
+
context = browser.new_context(storage_state="auth.json")`,
|
|
3613
|
+
line: uiLogin[0],
|
|
3614
|
+
reference: "https://playwright.dev/python/docs/auth"
|
|
3615
|
+
});
|
|
3616
|
+
const swallowed = [];
|
|
3617
|
+
lines.forEach((l, i) => {
|
|
3618
|
+
const bare = /^\s*except\s*:/.test(l);
|
|
3619
|
+
const inlinePass = /^\s*except\b.*:\s*pass\s*$/.test(l);
|
|
3620
|
+
const nextPass = /^\s*except\b.*:\s*$/.test(l) && /^\s*pass\s*$/.test(lines[i + 1] ?? "");
|
|
3621
|
+
if (bare || inlinePass || nextPass) swallowed.push(i + 1);
|
|
3622
|
+
});
|
|
3623
|
+
if (swallowed.length) add({
|
|
3624
|
+
ruleId: "PWPY-STD-004",
|
|
3625
|
+
category: "coding_standards",
|
|
3626
|
+
severity: "warning",
|
|
3627
|
+
title: "Exception swallowed in test",
|
|
3628
|
+
description: `A bare or pass-only except block appears on line ${swallowed[0]}.`,
|
|
3629
|
+
impact: "Real failures are converted into passes, which is how a broken flow stays green for weeks.",
|
|
3630
|
+
fix: `# Assert the expected state instead of guarding with try/except
|
|
3631
|
+
expect(page.get_by_role("alert")).to_have_text("Saved")`,
|
|
3632
|
+
line: swallowed[0],
|
|
3633
|
+
reference: "https://playwright.dev/python/docs/best-practices"
|
|
3634
|
+
});
|
|
3635
|
+
const headed = lineMatches2(content, /headless\s*=\s*False/);
|
|
3636
|
+
if (headed.length) add({
|
|
3637
|
+
ruleId: "PWPY-CI-002",
|
|
3638
|
+
category: "ci_config",
|
|
3639
|
+
severity: "warning",
|
|
3640
|
+
title: "Headed mode hardcoded",
|
|
3641
|
+
description: `headless=False is committed on line ${headed[0]}.`,
|
|
3642
|
+
impact: "CI agents have no display, so the run hangs or crashes; locally it just makes the suite slower.",
|
|
3643
|
+
fix: `browser = playwright.chromium.launch() # use --headed on the CLI when debugging`,
|
|
3644
|
+
line: headed[0],
|
|
3645
|
+
reference: "https://playwright.dev/python/docs/running-tests"
|
|
3646
|
+
});
|
|
3647
|
+
const newContext = lineMatches2(content, /new_context\s*\(/);
|
|
3648
|
+
if (newContext.length && !/tracing\.start|record_video_dir|record_har_path/.test(content))
|
|
3649
|
+
add({
|
|
3650
|
+
ruleId: "PWPY-CI-003",
|
|
3651
|
+
category: "ci_config",
|
|
3652
|
+
severity: "info",
|
|
3653
|
+
title: "Browser context without failure artefacts",
|
|
3654
|
+
description: `new_context() on line ${newContext[0]} configures no tracing, video or HAR capture.`,
|
|
3655
|
+
impact: "A CI-only failure leaves nothing to debug with, so the flake gets retried instead of fixed.",
|
|
3656
|
+
fix: `context = browser.new_context(record_video_dir="videos/")
|
|
3657
|
+
context.tracing.start(screenshots=True, snapshots=True)`,
|
|
3658
|
+
line: newContext[0],
|
|
3659
|
+
reference: "https://playwright.dev/python/docs/trace-viewer"
|
|
3660
|
+
});
|
|
3661
|
+
{
|
|
3662
|
+
const hasMarker = /@pytest\.mark\.\w+/.test(content);
|
|
3663
|
+
if (/def\s+test_/.test(content) && !hasMarker) add({
|
|
3664
|
+
ruleId: "PWPY-STD-005",
|
|
3665
|
+
category: "coding_standards",
|
|
3666
|
+
severity: "info",
|
|
3667
|
+
title: "Tests carry no @pytest.mark marker",
|
|
3668
|
+
description: "No @pytest.mark.* marker appears in this file, so its tests cannot be selected with -m.",
|
|
3669
|
+
impact: "CI must run the whole suite every time \u2014 no smoke subset, and a flaky test can only be excluded by deleting or skipping it outright.",
|
|
3670
|
+
fix: `@pytest.mark.smoke
|
|
3671
|
+
def test_dashboard_loads(page):
|
|
3672
|
+
...
|
|
3673
|
+
|
|
3674
|
+
# then: pytest -m smoke
|
|
3675
|
+
# register it in pytest.ini to avoid PytestUnknownMarkWarning:
|
|
3676
|
+
# [pytest]
|
|
3677
|
+
# markers = smoke: fast critical-path checks`,
|
|
3678
|
+
line: lineMatches2(content, /def\s+test_/)[0] ?? null,
|
|
3679
|
+
reference: "https://docs.pytest.org/en/stable/example/markers.html"
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
2999
3682
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3000
3683
|
return buildAuditResult({
|
|
3001
3684
|
filename,
|
|
@@ -3011,6 +3694,7 @@ var CATEGORY_IDS6 = AUDIT_STACKS.ts_frontend.categories.map((c) => c.id);
|
|
|
3011
3694
|
function analyseTsFrontendLocally(filename, content, options = {}) {
|
|
3012
3695
|
const disabled = options.disabledRuleIds ?? /* @__PURE__ */ new Set();
|
|
3013
3696
|
const findings = [];
|
|
3697
|
+
const lines = content.split(/\r?\n/);
|
|
3014
3698
|
const add = (r) => pushFinding(findings, r, disabled);
|
|
3015
3699
|
const dxss = lineMatches2(content, /dangerouslySetInnerHTML|\.innerHTML\s*=/);
|
|
3016
3700
|
if (dxss.length) add({
|
|
@@ -3128,6 +3812,235 @@ function analyseTsFrontendLocally(filename, content, options = {}) {
|
|
|
3128
3812
|
fix: "Resolve or link to an issue.",
|
|
3129
3813
|
line: todo[0]
|
|
3130
3814
|
});
|
|
3815
|
+
const evalUse = lineMatches2(content, /\beval\s*\(|\bnew\s+Function\s*\(/);
|
|
3816
|
+
if (evalUse.length) add({
|
|
3817
|
+
ruleId: "TSF-SEC-003",
|
|
3818
|
+
category: "security",
|
|
3819
|
+
severity: "critical",
|
|
3820
|
+
title: "Dynamic code execution",
|
|
3821
|
+
description: `eval()/new Function() executes strings as code (line ${evalUse[0]}).`,
|
|
3822
|
+
impact: "Any attacker-controlled string becomes executable code in the user's session.",
|
|
3823
|
+
fix: `// Before
|
|
3824
|
+
const value = eval(expression);
|
|
3825
|
+
// After
|
|
3826
|
+
const value = SAFE_OPS[expression]?.() ?? null;`,
|
|
3827
|
+
line: evalUse[0],
|
|
3828
|
+
reference: "OWASP Code Injection"
|
|
3829
|
+
});
|
|
3830
|
+
const hardSecret = lineMatches2(content, /\b(?:api[-_]?key|password|secret|access[-_]?token|auth[-_]?token|client[-_]?secret)\s*[:=]\s*["'`][^"'`\s]{8,}["'`]/i).filter((ln) => !/process\.env|import\.meta\.env|getenv/.test(lines[ln - 1] || ""));
|
|
3831
|
+
if (hardSecret.length) add({
|
|
3832
|
+
ruleId: "TSF-SEC-004",
|
|
3833
|
+
category: "security",
|
|
3834
|
+
severity: "critical",
|
|
3835
|
+
title: "Hardcoded credential in client code",
|
|
3836
|
+
description: `A literal key/password/token is assigned on line ${hardSecret[0]}.`,
|
|
3837
|
+
impact: "Bundled client code is public; the credential is readable by anyone loading the app.",
|
|
3838
|
+
fix: `const apiKey = import.meta.env.VITE_PUBLIC_API_KEY; // non-secret, injected at build time`,
|
|
3839
|
+
line: hardSecret[0],
|
|
3840
|
+
reference: "OWASP Secrets Management"
|
|
3841
|
+
});
|
|
3842
|
+
const asyncEffect = lineMatches2(content, /useEffect\s*\(\s*async\b/);
|
|
3843
|
+
if (asyncEffect.length) add({
|
|
3844
|
+
ruleId: "TSF-HOOK-003",
|
|
3845
|
+
category: "hooks",
|
|
3846
|
+
severity: "warning",
|
|
3847
|
+
title: "async function passed to useEffect",
|
|
3848
|
+
description: `useEffect receives an async callback on line ${asyncEffect[0]}; React treats the returned Promise as a cleanup function.`,
|
|
3849
|
+
impact: "Cleanup never runs and React logs a warning; unmounted components can still set state.",
|
|
3850
|
+
fix: `useEffect(() => {
|
|
3851
|
+
let active = true;
|
|
3852
|
+
(async () => {
|
|
3853
|
+
const data = await load();
|
|
3854
|
+
if (active) setData(data);
|
|
3855
|
+
})();
|
|
3856
|
+
return () => { active = false; };
|
|
3857
|
+
}, [load]);`,
|
|
3858
|
+
line: asyncEffect[0],
|
|
3859
|
+
reference: "https://react.dev/reference/react/useEffect"
|
|
3860
|
+
});
|
|
3861
|
+
const effectStarts = lineMatches2(content, /useEffect\s*\(/);
|
|
3862
|
+
const noCleanup = effectStarts.filter((ln) => {
|
|
3863
|
+
const win = lines.slice(ln - 1, ln + 24);
|
|
3864
|
+
const endIdx = win.findIndex((l, i) => i > 0 && /^\s*\}\s*(?:,\s*\[[^\]]*\])?\s*\)\s*;?\s*$/.test(l));
|
|
3865
|
+
const body = (endIdx === -1 ? win : win.slice(0, endIdx + 1)).join("\n");
|
|
3866
|
+
const subscribes = /addEventListener\s*\(|setInterval\s*\(|\.subscribe\s*\(|new\s+WebSocket\s*\(|new\s+(?:Resize|Intersection|Mutation)Observer\s*\(/.test(body);
|
|
3867
|
+
return subscribes && !/\breturn\b/.test(body);
|
|
3868
|
+
});
|
|
3869
|
+
if (noCleanup.length) add({
|
|
3870
|
+
ruleId: "TSF-HOOK-004",
|
|
3871
|
+
category: "hooks",
|
|
3872
|
+
severity: "warning",
|
|
3873
|
+
title: "Effect subscribes without cleanup",
|
|
3874
|
+
description: `The effect at line ${noCleanup[0]} registers a listener/interval/observer but returns no cleanup function.`,
|
|
3875
|
+
impact: "Handlers accumulate on every re-render and keep running after unmount \u2014 memory leaks and duplicate work.",
|
|
3876
|
+
fix: `useEffect(() => {
|
|
3877
|
+
const id = setInterval(tick, 1000);
|
|
3878
|
+
return () => clearInterval(id);
|
|
3879
|
+
}, [tick]);`,
|
|
3880
|
+
line: noCleanup[0],
|
|
3881
|
+
reference: "https://react.dev/reference/react/useEffect#connecting-to-an-external-system"
|
|
3882
|
+
});
|
|
3883
|
+
const stateNames = [...content.matchAll(/const\s*\[\s*([A-Za-z_$][\w$]*)\s*,\s*set[\w$]*\s*\]\s*=\s*useState/g)].map((m) => m[1]);
|
|
3884
|
+
const mutatedState = stateNames.length ? lineMatches2(content, new RegExp(`\\b(?:${stateNames.join("|")})\\s*(?:\\.(?:push|pop|shift|unshift|splice|sort|reverse)\\s*\\(|(?:\\.[\\w$]+|\\[[^\\]]+\\])\\s*=(?!=))`)) : [];
|
|
3885
|
+
if (mutatedState.length) add({
|
|
3886
|
+
ruleId: "TSF-HOOK-005",
|
|
3887
|
+
category: "hooks",
|
|
3888
|
+
severity: "critical",
|
|
3889
|
+
title: "State mutated directly instead of via its setter",
|
|
3890
|
+
description: `A useState value is mutated in place on line ${mutatedState[0]} rather than replaced through set*().`,
|
|
3891
|
+
impact: "React compares state by reference, so the mutation does not re-render \u2014 the UI silently shows stale data.",
|
|
3892
|
+
fix: `// Before
|
|
3893
|
+
items.push(next);
|
|
3894
|
+
// After
|
|
3895
|
+
setItems((prev) => [...prev, next]);`,
|
|
3896
|
+
line: mutatedState[0],
|
|
3897
|
+
reference: "https://react.dev/learn/updating-objects-in-state"
|
|
3898
|
+
});
|
|
3899
|
+
const propNames = /* @__PURE__ */ new Set();
|
|
3900
|
+
for (const m of content.matchAll(/(?:function\s+[A-Z][\w$]*\s*\(|=\s*)\(?\s*\{([^}]{0,300})\}\s*(?::[^)]*)?\)?\s*(?:=>|\{)/g)) {
|
|
3901
|
+
for (const p of m[1].split(",")) {
|
|
3902
|
+
const name = p.split(/[:=]/)[0].trim();
|
|
3903
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) propNames.add(name);
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
const propState = lineMatches2(content, /useState(?:<[^>]*>)?\s*\(\s*props\.[\w$]+\s*\)/).concat(propNames.size ? lineMatches2(content, new RegExp(`useState(?:<[^>]*>)?\\s*\\(\\s*(?:${[...propNames].join("|")})\\s*\\)`)) : []);
|
|
3907
|
+
if (propState.length) add({
|
|
3908
|
+
ruleId: "TSF-HOOK-006",
|
|
3909
|
+
category: "hooks",
|
|
3910
|
+
severity: "warning",
|
|
3911
|
+
title: "useState initialised from a prop",
|
|
3912
|
+
description: `State on line ${propState[0]} is seeded from a prop, which is only read on the first render.`,
|
|
3913
|
+
impact: "Later prop updates are ignored, so the component renders a stale copy of its parent's data.",
|
|
3914
|
+
fix: `// Derive instead of copying
|
|
3915
|
+
const displayName = props.name;
|
|
3916
|
+
// \u2026or key the component so it remounts when the prop identity changes
|
|
3917
|
+
<Profile key={userId} name={name} />`,
|
|
3918
|
+
line: propState[0],
|
|
3919
|
+
reference: "https://react.dev/learn/choosing-the-state-structure#avoid-duplication-in-state"
|
|
3920
|
+
});
|
|
3921
|
+
const iconBtnRe = /<button\b(?:(?!aria-label|aria-labelledby)[^>])*>\s*(?:\{\s*)?<(?:svg|Icon|[A-Z][\w$]*Icon)\b/g;
|
|
3922
|
+
const iconBtn = [...content.matchAll(iconBtnRe)].map((m) => content.slice(0, m.index).split(/\r?\n/).length);
|
|
3923
|
+
if (iconBtn.length) add({
|
|
3924
|
+
ruleId: "TSF-A11Y-003",
|
|
3925
|
+
category: "accessibility",
|
|
3926
|
+
severity: "warning",
|
|
3927
|
+
title: "Icon-only button without an accessible name",
|
|
3928
|
+
description: `The button at line ${iconBtn[0]} contains only an icon and carries no aria-label.`,
|
|
3929
|
+
impact: "Screen readers announce it as an unlabelled button, so the action is unusable without sight.",
|
|
3930
|
+
fix: `<button aria-label="Delete item" onClick={onDelete}>
|
|
3931
|
+
<TrashIcon aria-hidden="true" />
|
|
3932
|
+
</button>`,
|
|
3933
|
+
line: iconBtn[0],
|
|
3934
|
+
reference: "WCAG 4.1.2 Name, Role, Value"
|
|
3935
|
+
});
|
|
3936
|
+
const unlabelledInput = lineMatches2(content, /<input\b(?![^>]*\b(?:aria-label|aria-labelledby|id)\s*=)(?![^>]*\btype\s*=\s*["'](?:hidden|submit|button|reset)["'])[^>]*>/i);
|
|
3937
|
+
if (unlabelledInput.length) add({
|
|
3938
|
+
ruleId: "TSF-A11Y-004",
|
|
3939
|
+
category: "accessibility",
|
|
3940
|
+
severity: "warning",
|
|
3941
|
+
title: "Form input with no label association",
|
|
3942
|
+
description: `The <input> on line ${unlabelledInput[0]} has no id, aria-label or aria-labelledby to tie it to a label.`,
|
|
3943
|
+
impact: "Assistive tech reads an anonymous field, and clicking the visible label does not focus it.",
|
|
3944
|
+
fix: `<label htmlFor="email">Email</label>
|
|
3945
|
+
<input id="email" type="email" value={email} onChange={onChange} />`,
|
|
3946
|
+
line: unlabelledInput[0],
|
|
3947
|
+
reference: "WCAG 3.3.2 Labels or Instructions"
|
|
3948
|
+
});
|
|
3949
|
+
const stableHandlers = new Set(
|
|
3950
|
+
[...content.matchAll(/(?:const|let)\s+([\w$]+)\s*=\s*(?:useCallback|useMemo)\s*\(/g)].map((m) => m[1])
|
|
3951
|
+
);
|
|
3952
|
+
const mapStarts = lineMatches2(content, /\.map\s*\(/);
|
|
3953
|
+
const inlineHandlerInList = mapStarts.filter((ln) => lines.slice(ln - 1, ln + 14).some((l) => {
|
|
3954
|
+
const m = /\bon[A-Z][\w$]*\s*=\s*\{\s*(?:\([^)]*\)|[\w$]+)\s*=>\s*([\w$]+)?/.exec(l);
|
|
3955
|
+
return m !== null && !(m[1] && stableHandlers.has(m[1]));
|
|
3956
|
+
}));
|
|
3957
|
+
if (inlineHandlerInList.length) add({
|
|
3958
|
+
ruleId: "TSF-PER-002",
|
|
3959
|
+
category: "performance",
|
|
3960
|
+
severity: "info",
|
|
3961
|
+
title: "Inline arrow handler inside a list render",
|
|
3962
|
+
description: `The .map() starting at line ${inlineHandlerInList[0]} creates a new function for every row on every render.`,
|
|
3963
|
+
impact: "Every child gets a fresh prop identity, defeating memoisation and re-rendering the whole list.",
|
|
3964
|
+
fix: `const handleSelect = useCallback((id: string) => select(id), [select]);
|
|
3965
|
+
items.map((it) => <Row key={it.id} id={it.id} onSelect={handleSelect} />)`,
|
|
3966
|
+
line: inlineHandlerInList[0],
|
|
3967
|
+
reference: "https://react.dev/reference/react/useCallback"
|
|
3968
|
+
});
|
|
3969
|
+
const inlineObjProp = lineMatches2(content, /\s[a-zA-Z][\w-]*\s*=\s*\{\{/);
|
|
3970
|
+
if (inlineObjProp.length) add({
|
|
3971
|
+
ruleId: "TSF-PER-003",
|
|
3972
|
+
category: "performance",
|
|
3973
|
+
severity: "info",
|
|
3974
|
+
title: "Inline object literal passed as a prop",
|
|
3975
|
+
description: `An object literal is constructed inline as a prop on line ${inlineObjProp[0]}.`,
|
|
3976
|
+
impact: "A new reference each render breaks React.memo and shallow prop comparison in the child.",
|
|
3977
|
+
fix: `const rowStyle = useMemo(() => ({ padding: 8 }), []);
|
|
3978
|
+
<Row style={rowStyle} />`,
|
|
3979
|
+
line: inlineObjProp[0],
|
|
3980
|
+
reference: "https://react.dev/reference/react/memo"
|
|
3981
|
+
});
|
|
3982
|
+
const oversized = lines.length > 300;
|
|
3983
|
+
if (oversized) add({
|
|
3984
|
+
ruleId: "TSF-PER-004",
|
|
3985
|
+
category: "performance",
|
|
3986
|
+
severity: "info",
|
|
3987
|
+
title: "Oversized component file",
|
|
3988
|
+
description: `${filename} is ${lines.length} lines long.`,
|
|
3989
|
+
impact: "Large files re-render as a unit and are hard to memoise, test, or code-split.",
|
|
3990
|
+
fix: "Extract sub-components and hooks into their own modules, then lazy-load the heavy branches.",
|
|
3991
|
+
line: null,
|
|
3992
|
+
reference: "Team standards"
|
|
3993
|
+
});
|
|
3994
|
+
const dbg = lineMatches2(content, /\bdebugger\b/);
|
|
3995
|
+
if (dbg.length) add({
|
|
3996
|
+
ruleId: "TSF-STD-003",
|
|
3997
|
+
category: "standards",
|
|
3998
|
+
severity: "warning",
|
|
3999
|
+
title: "debugger statement committed",
|
|
4000
|
+
description: `A debugger statement remains on line ${dbg[0]}.`,
|
|
4001
|
+
impact: "Execution halts whenever devtools are open, and the statement ships to production builds.",
|
|
4002
|
+
fix: "Remove the debugger statement before committing.",
|
|
4003
|
+
line: dbg[0]
|
|
4004
|
+
});
|
|
4005
|
+
const blockingDialog = lineMatches2(content, /\b(?:window\.)?(?:alert|confirm)\s*\(/);
|
|
4006
|
+
if (blockingDialog.length) add({
|
|
4007
|
+
ruleId: "TSF-STD-004",
|
|
4008
|
+
category: "standards",
|
|
4009
|
+
severity: "info",
|
|
4010
|
+
title: "Native blocking dialog",
|
|
4011
|
+
description: `alert()/confirm() is called on line ${blockingDialog[0]}.`,
|
|
4012
|
+
impact: "Blocks the main thread, cannot be styled or tested, and is suppressed in some embedded contexts.",
|
|
4013
|
+
fix: `setConfirmOpen(true); // render an accessible <Dialog /> instead`,
|
|
4014
|
+
line: blockingDialog[0]
|
|
4015
|
+
});
|
|
4016
|
+
const tsIgnore = lineMatches2(content, /@ts-(?:ignore|nocheck)\b/);
|
|
4017
|
+
if (tsIgnore.length) add({
|
|
4018
|
+
ruleId: "TSF-TYP-002",
|
|
4019
|
+
category: "type_safety",
|
|
4020
|
+
severity: "warning",
|
|
4021
|
+
title: "@ts-ignore / @ts-nocheck suppression",
|
|
4022
|
+
description: `Type checking is suppressed on line ${tsIgnore[0]}.`,
|
|
4023
|
+
impact: "Silences the error permanently, including new errors introduced later on the same line.",
|
|
4024
|
+
fix: `// @ts-expect-error - remove once upstream types ship (TICKET-123)`,
|
|
4025
|
+
line: tsIgnore[0],
|
|
4026
|
+
reference: "https://www.typescriptlang.org/tsconfig#allowJs"
|
|
4027
|
+
});
|
|
4028
|
+
const nonNull = lineMatches2(content, /[\w$)\]]!\s*\./);
|
|
4029
|
+
if (nonNull.length) add({
|
|
4030
|
+
ruleId: "TSF-TYP-003",
|
|
4031
|
+
category: "type_safety",
|
|
4032
|
+
severity: "info",
|
|
4033
|
+
title: "Non-null assertion operator",
|
|
4034
|
+
description: `A ! assertion overrides the null check on line ${nonNull[0]}.`,
|
|
4035
|
+
impact: "The compiler stops guarding the access, so a real null becomes a runtime TypeError.",
|
|
4036
|
+
fix: `// Before
|
|
4037
|
+
const name = user!.name;
|
|
4038
|
+
// After
|
|
4039
|
+
if (!user) return null;
|
|
4040
|
+
const name = user.name;`,
|
|
4041
|
+
line: nonNull[0],
|
|
4042
|
+
reference: "https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#non-null-assertion-operator-postfix-"
|
|
4043
|
+
});
|
|
3131
4044
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3132
4045
|
return buildAuditResult({
|
|
3133
4046
|
filename,
|
|
@@ -3282,6 +4195,219 @@ order = CreateOrder(**await request.json())`,
|
|
|
3282
4195
|
fix: "Resolve or link to an issue.",
|
|
3283
4196
|
line: todo[0]
|
|
3284
4197
|
});
|
|
4198
|
+
const pickleLoad = lineMatches2(content, /\b(?:pickle|cPickle|_pickle|marshal|dill)\.loads?\s*\(/);
|
|
4199
|
+
if (pickleLoad.length) add({
|
|
4200
|
+
ruleId: "PY-SEC-005",
|
|
4201
|
+
category: "security",
|
|
4202
|
+
severity: "critical",
|
|
4203
|
+
title: "Deserialising untrusted data with pickle",
|
|
4204
|
+
description: `pickle/marshal deserialisation at line ${pickleLoad[0]} executes arbitrary objects while unpickling.`,
|
|
4205
|
+
impact: "A crafted payload gives remote code execution.",
|
|
4206
|
+
fix: `data = json.loads(raw) # use a data-only format for untrusted input`,
|
|
4207
|
+
line: pickleLoad[0],
|
|
4208
|
+
reference: "https://docs.python.org/3/library/pickle.html#restricting-globals"
|
|
4209
|
+
});
|
|
4210
|
+
const unsafeYaml = lineMatches2(content, /yaml\.load\s*\((?![^)]*(?:SafeLoader|CSafeLoader))/);
|
|
4211
|
+
if (unsafeYaml.length) add({
|
|
4212
|
+
ruleId: "PY-SEC-006",
|
|
4213
|
+
category: "security",
|
|
4214
|
+
severity: "critical",
|
|
4215
|
+
title: "yaml.load without a safe loader",
|
|
4216
|
+
description: `yaml.load at line ${unsafeYaml[0]} uses the full loader, which can construct arbitrary Python objects.`,
|
|
4217
|
+
impact: "YAML input becomes code execution.",
|
|
4218
|
+
fix: `config = yaml.safe_load(raw)`,
|
|
4219
|
+
line: unsafeYaml[0],
|
|
4220
|
+
reference: "https://pyyaml.org/wiki/PyYAMLDocumentation"
|
|
4221
|
+
});
|
|
4222
|
+
const noVerify = lineMatches2(content, /verify\s*=\s*False/);
|
|
4223
|
+
if (noVerify.length) add({
|
|
4224
|
+
ruleId: "PY-SEC-007",
|
|
4225
|
+
category: "security",
|
|
4226
|
+
severity: "critical",
|
|
4227
|
+
title: "TLS certificate verification disabled",
|
|
4228
|
+
description: `verify=False at line ${noVerify[0]} turns off certificate validation.`,
|
|
4229
|
+
impact: "Traffic can be intercepted by a man-in-the-middle.",
|
|
4230
|
+
fix: `requests.get(url, timeout=5, verify=True) # or verify="/path/to/ca-bundle.pem"`,
|
|
4231
|
+
line: noVerify[0],
|
|
4232
|
+
reference: "https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification"
|
|
4233
|
+
});
|
|
4234
|
+
const debugTrue = lineMatches2(content, /^\s*DEBUG\s*=\s*True\b|\bdebug\s*=\s*True\b/);
|
|
4235
|
+
if (debugTrue.length) add({
|
|
4236
|
+
ruleId: "PY-SEC-008",
|
|
4237
|
+
category: "security",
|
|
4238
|
+
severity: "critical",
|
|
4239
|
+
title: "Debug mode enabled",
|
|
4240
|
+
description: `Debug mode is switched on at line ${debugTrue[0]}.`,
|
|
4241
|
+
impact: "Stack traces, settings and an interactive console leak to users in production.",
|
|
4242
|
+
fix: `DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"`,
|
|
4243
|
+
line: debugTrue[0],
|
|
4244
|
+
reference: "https://docs.djangoproject.com/en/stable/ref/settings/#debug"
|
|
4245
|
+
});
|
|
4246
|
+
const wildcardHosts = lineMatches2(content, /ALLOWED_HOSTS\s*=\s*\[\s*["']\*["']/);
|
|
4247
|
+
if (wildcardHosts.length) add({
|
|
4248
|
+
ruleId: "PY-SEC-009",
|
|
4249
|
+
category: "security",
|
|
4250
|
+
severity: "warning",
|
|
4251
|
+
title: "ALLOWED_HOSTS accepts any host",
|
|
4252
|
+
description: `ALLOWED_HOSTS is ['*'] at line ${wildcardHosts[0]}.`,
|
|
4253
|
+
impact: "Host-header poisoning enables cache poisoning and password-reset link hijacking.",
|
|
4254
|
+
fix: `ALLOWED_HOSTS = ["api.example.com", "www.example.com"]`,
|
|
4255
|
+
line: wildcardHosts[0],
|
|
4256
|
+
reference: "https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts"
|
|
4257
|
+
});
|
|
4258
|
+
const runtimeAssert = lineMatches2(content, /^\s*assert\s+/);
|
|
4259
|
+
if (runtimeAssert.length) add({
|
|
4260
|
+
ruleId: "PY-SEC-010",
|
|
4261
|
+
category: "security",
|
|
4262
|
+
severity: "warning",
|
|
4263
|
+
title: "assert used for runtime validation",
|
|
4264
|
+
description: `assert statement at line ${runtimeAssert[0]} is used outside of tests.`,
|
|
4265
|
+
impact: "Python run with -O strips asserts, so the check silently disappears in production.",
|
|
4266
|
+
fix: `if not user.is_admin:
|
|
4267
|
+
raise PermissionError("admin required")`,
|
|
4268
|
+
line: runtimeAssert[0],
|
|
4269
|
+
reference: "https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement"
|
|
4270
|
+
});
|
|
4271
|
+
const openCors = lineMatches2(content, /allow_origins\s*=\s*\[\s*["']\*["']|CORS_ALLOW_ALL_ORIGINS\s*=\s*True|CORS_ORIGIN_ALLOW_ALL\s*=\s*True|["']Access-Control-Allow-Origin["']\s*[:,]\s*["']\*["']/);
|
|
4272
|
+
if (openCors.length) add({
|
|
4273
|
+
ruleId: "PY-SEC-011",
|
|
4274
|
+
category: "security",
|
|
4275
|
+
severity: "warning",
|
|
4276
|
+
title: "CORS open to every origin",
|
|
4277
|
+
description: `Wildcard CORS origin configured at line ${openCors[0]}.`,
|
|
4278
|
+
impact: "Any site can call the API with the user's credentials.",
|
|
4279
|
+
fix: `app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True)`,
|
|
4280
|
+
line: openCors[0],
|
|
4281
|
+
reference: "https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS"
|
|
4282
|
+
});
|
|
4283
|
+
const rawQuery = lineMatches2(content, /\.objects\.raw\s*\(|\.extra\s*\(\s*(?:select|where|tables|params)\s*=/);
|
|
4284
|
+
if (rawQuery.length) add({
|
|
4285
|
+
ruleId: "PY-DAT-002",
|
|
4286
|
+
category: "data_access",
|
|
4287
|
+
severity: "warning",
|
|
4288
|
+
title: "Django .raw() / .extra() escape hatch",
|
|
4289
|
+
description: `Raw ORM escape hatch used at line ${rawQuery[0]}.`,
|
|
4290
|
+
impact: "Bypasses the ORM's parameterisation and query planning; .extra() is deprecated.",
|
|
4291
|
+
fix: `Order.objects.filter(status="open").values("id", "total")`,
|
|
4292
|
+
line: rawQuery[0],
|
|
4293
|
+
reference: "https://docs.djangoproject.com/en/stable/ref/models/querysets/#extra"
|
|
4294
|
+
});
|
|
4295
|
+
const nPlusOne = lineMatches2(content, /for\s+\w+\s+in\s+\w+\.objects\.(?:all|filter|exclude)\s*\(/).filter(() => !/select_related|prefetch_related|\.only\(|\.values\(/.test(content));
|
|
4296
|
+
if (nPlusOne.length) add({
|
|
4297
|
+
ruleId: "PY-DAT-003",
|
|
4298
|
+
category: "data_access",
|
|
4299
|
+
severity: "warning",
|
|
4300
|
+
title: "Probable N+1 query",
|
|
4301
|
+
description: `Line ${nPlusOne[0]} iterates a queryset with no select_related/prefetch_related anywhere in the file.`,
|
|
4302
|
+
impact: "One extra query per row; latency grows linearly with data size.",
|
|
4303
|
+
fix: `for order in Order.objects.select_related("customer").prefetch_related("items"):
|
|
4304
|
+
...`,
|
|
4305
|
+
line: nPlusOne[0],
|
|
4306
|
+
reference: "https://docs.djangoproject.com/en/stable/ref/models/querysets/#select-related"
|
|
4307
|
+
});
|
|
4308
|
+
const unbounded = lineMatches2(content, /\.objects\.all\s*\(\s*\)|\.query\.all\s*\(\s*\)|session\.query\([^)]*\)\.all\s*\(\s*\)/).filter(() => !/paginate|Paginator|LimitOffset|PageNumber|\.limit\(|\[\s*offset|\[\s*skip/i.test(content));
|
|
4309
|
+
if (unbounded.length) add({
|
|
4310
|
+
ruleId: "PY-PER-002",
|
|
4311
|
+
category: "performance",
|
|
4312
|
+
severity: "warning",
|
|
4313
|
+
title: "Unbounded list query without pagination",
|
|
4314
|
+
description: `Line ${unbounded[0]} fetches an entire table and the file has no pagination.`,
|
|
4315
|
+
impact: "Memory and response size grow without limit as the table grows.",
|
|
4316
|
+
fix: `orders = Order.objects.all()[offset : offset + limit] # or use a Paginator`,
|
|
4317
|
+
line: unbounded[0],
|
|
4318
|
+
reference: "https://docs.djangoproject.com/en/stable/topics/pagination/"
|
|
4319
|
+
});
|
|
4320
|
+
const blockingHttp = /async\s+def/.test(content) ? lineMatches2(content, /\brequests\.(?:get|post|put|delete|patch|head)\s*\(|\burllib\.request\.urlopen\s*\(/) : [];
|
|
4321
|
+
if (blockingHttp.length) add({
|
|
4322
|
+
ruleId: "PY-ASY-002",
|
|
4323
|
+
category: "async_io",
|
|
4324
|
+
severity: "warning",
|
|
4325
|
+
title: "Blocking HTTP call in async module",
|
|
4326
|
+
description: `Synchronous requests/urllib call at line ${blockingHttp[0]} in a module that defines async handlers.`,
|
|
4327
|
+
impact: "Blocks the event loop, serialising every concurrent request.",
|
|
4328
|
+
fix: `async with httpx.AsyncClient(timeout=5) as client:
|
|
4329
|
+
resp = await client.get(url)`,
|
|
4330
|
+
line: blockingHttp[0],
|
|
4331
|
+
reference: "https://fastapi.tiangolo.com/async/"
|
|
4332
|
+
});
|
|
4333
|
+
const genericRaise = lineMatches2(content, /raise\s+(?:Exception|BaseException)\s*\(/);
|
|
4334
|
+
if (genericRaise.length) add({
|
|
4335
|
+
ruleId: "PY-ERR-002",
|
|
4336
|
+
category: "error_handling",
|
|
4337
|
+
severity: "warning",
|
|
4338
|
+
title: "Generic Exception raised",
|
|
4339
|
+
description: `raise Exception(...) at line ${genericRaise[0]}.`,
|
|
4340
|
+
impact: "Callers cannot catch the specific failure without catching everything.",
|
|
4341
|
+
fix: `class OrderNotFound(LookupError): ...
|
|
4342
|
+
raise OrderNotFound(order_id)`,
|
|
4343
|
+
line: genericRaise[0],
|
|
4344
|
+
reference: "https://peps.python.org/pep-0008/#programming-recommendations"
|
|
4345
|
+
});
|
|
4346
|
+
const leakDetail = lineMatches2(content, /(?:return|detail\s*=|content\s*=|jsonify\s*\()[^\n]*\bstr\s*\(\s*(?:e|ex|exc|err|error)\s*\)/);
|
|
4347
|
+
if (leakDetail.length) add({
|
|
4348
|
+
ruleId: "PY-ERR-003",
|
|
4349
|
+
category: "error_handling",
|
|
4350
|
+
severity: "warning",
|
|
4351
|
+
title: "Internal exception text returned to the client",
|
|
4352
|
+
description: `Line ${leakDetail[0]} puts str(exception) into the HTTP response.`,
|
|
4353
|
+
impact: "Leaks table names, file paths and library internals to attackers.",
|
|
4354
|
+
fix: `logger.exception("order create failed")
|
|
4355
|
+
raise HTTPException(status_code=500, detail="Internal server error")`,
|
|
4356
|
+
line: leakDetail[0],
|
|
4357
|
+
reference: "https://owasp.org/www-community/Improper_Error_Handling"
|
|
4358
|
+
});
|
|
4359
|
+
const uncheckedCast = lineMatches2(content, /\b(?:int|float)\s*\(\s*request\.(?:args|form|json|data|GET|POST|query_params)/);
|
|
4360
|
+
if (uncheckedCast.length) add({
|
|
4361
|
+
ruleId: "PY-VAL-002",
|
|
4362
|
+
category: "validation",
|
|
4363
|
+
severity: "warning",
|
|
4364
|
+
title: "Unchecked numeric cast of request input",
|
|
4365
|
+
description: `Line ${uncheckedCast[0]} casts request data to a number with no guard.`,
|
|
4366
|
+
impact: "Non-numeric input raises ValueError and returns a 500 instead of a 400.",
|
|
4367
|
+
fix: `page = request.args.get("page", type=int, default=1)
|
|
4368
|
+
if page is None or page < 1:
|
|
4369
|
+
abort(400, "page must be a positive integer")`,
|
|
4370
|
+
line: uncheckedCast[0],
|
|
4371
|
+
reference: "https://docs.pydantic.dev/latest/concepts/validators/"
|
|
4372
|
+
});
|
|
4373
|
+
const verbInPath = lineMatches2(content, /@\w+\.(?:route|get|post|put|delete|patch)\s*\(\s*["'][^"']*\/(?:get|create|update|delete|fetch|add|remove)[A-Z_]/);
|
|
4374
|
+
if (verbInPath.length) add({
|
|
4375
|
+
ruleId: "PY-API-001",
|
|
4376
|
+
category: "api_design",
|
|
4377
|
+
severity: "info",
|
|
4378
|
+
title: "Verb in REST route path",
|
|
4379
|
+
description: `Route at line ${verbInPath[0]} encodes an action in the URL instead of using the HTTP method.`,
|
|
4380
|
+
impact: "Non-RESTful surface; caching and client tooling assumptions break.",
|
|
4381
|
+
fix: `@app.post("/orders") # instead of /createOrder
|
|
4382
|
+
@app.delete("/orders/{id}") # instead of /deleteOrder`,
|
|
4383
|
+
line: verbInPath[0],
|
|
4384
|
+
reference: "https://restfulapi.net/resource-naming/"
|
|
4385
|
+
});
|
|
4386
|
+
const looseCompare = lineMatches2(content, /(?:==|!=)\s*(?:None|True|False)\b|\btype\s*\([^)]*\)\s*==/);
|
|
4387
|
+
if (looseCompare.length) add({
|
|
4388
|
+
ruleId: "PY-STD-004",
|
|
4389
|
+
category: "standards",
|
|
4390
|
+
severity: "info",
|
|
4391
|
+
title: "Identity/type comparison written with ==",
|
|
4392
|
+
description: `Line ${looseCompare[0]} compares against None/True/False or uses type(x) ==.`,
|
|
4393
|
+
impact: "Works only by accident with overloaded __eq__ and subclasses.",
|
|
4394
|
+
fix: `if value is None: ...
|
|
4395
|
+
if isinstance(value, str): ...`,
|
|
4396
|
+
line: looseCompare[0],
|
|
4397
|
+
reference: "https://peps.python.org/pep-0008/#programming-recommendations"
|
|
4398
|
+
});
|
|
4399
|
+
const starImport = lineMatches2(content, /^\s*from\s+[\w.]+\s+import\s+\*/);
|
|
4400
|
+
if (starImport.length) add({
|
|
4401
|
+
ruleId: "PY-STD-005",
|
|
4402
|
+
category: "standards",
|
|
4403
|
+
severity: "info",
|
|
4404
|
+
title: "Wildcard import",
|
|
4405
|
+
description: `Line ${starImport[0]} imports with *.`,
|
|
4406
|
+
impact: "Namespace pollution; shadowed names and unresolvable symbols for tooling.",
|
|
4407
|
+
fix: `from myapp.models import Order, Customer`,
|
|
4408
|
+
line: starImport[0],
|
|
4409
|
+
reference: "https://peps.python.org/pep-0008/#imports"
|
|
4410
|
+
});
|
|
3285
4411
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3286
4412
|
return buildAuditResult({
|
|
3287
4413
|
filename,
|
|
@@ -3357,6 +4483,148 @@ function analysePythonFrontendLocally(filename, content, options = {}) {
|
|
|
3357
4483
|
fix: "Resolve or link to an issue.",
|
|
3358
4484
|
line: todo[0]
|
|
3359
4485
|
});
|
|
4486
|
+
const lineOf = (index) => content.slice(0, index).split(/\r?\n/).length;
|
|
4487
|
+
const blankTarget = lineMatches2(content, /<a\b(?=[^>]*target\s*=\s*["']_blank["'])(?![^>]*rel\s*=\s*["'][^"']*noopener)[^>]*>/i);
|
|
4488
|
+
if (blankTarget.length) add({
|
|
4489
|
+
ruleId: "PYF-SEC-003",
|
|
4490
|
+
category: "security",
|
|
4491
|
+
severity: "warning",
|
|
4492
|
+
title: 'target="_blank" without rel="noopener"',
|
|
4493
|
+
description: `Link at line ${blankTarget[0]} opens a new tab without rel="noopener".`,
|
|
4494
|
+
impact: "The opened page can rewrite window.opener.location and phish the user (reverse tabnabbing).",
|
|
4495
|
+
fix: `<a href="{{ url }}" target="_blank" rel="noopener noreferrer">Details</a>`,
|
|
4496
|
+
line: blankTarget[0],
|
|
4497
|
+
reference: "https://owasp.org/www-community/attacks/Reverse_Tabnabbing"
|
|
4498
|
+
});
|
|
4499
|
+
const noSri = lineMatches2(content, /<script\b(?=[^>]*\bsrc\s*=\s*["']https?:\/\/)(?![^>]*\bintegrity\s*=)[^>]*>/i);
|
|
4500
|
+
if (noSri.length) add({
|
|
4501
|
+
ruleId: "PYF-SEC-004",
|
|
4502
|
+
category: "security",
|
|
4503
|
+
severity: "warning",
|
|
4504
|
+
title: "Third-party script without subresource integrity",
|
|
4505
|
+
description: `Remote <script> at line ${noSri[0]} has no integrity/crossorigin attributes.`,
|
|
4506
|
+
impact: "A compromised CDN can serve arbitrary JavaScript into every page.",
|
|
4507
|
+
fix: `<script src="https://cdn.example.com/lib.js" integrity="sha384-\u2026" crossorigin="anonymous"></script>`,
|
|
4508
|
+
line: noSri[0],
|
|
4509
|
+
reference: "https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity"
|
|
4510
|
+
});
|
|
4511
|
+
let scriptInterp = 0;
|
|
4512
|
+
for (const m of content.matchAll(/<script\b(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/gi)) {
|
|
4513
|
+
const type = /\btype\s*=\s*["']([^"']+)["']/i.exec(m[1]);
|
|
4514
|
+
if (type && !/javascript|module|ecmascript/i.test(type[1])) continue;
|
|
4515
|
+
if (/\{\{|\{%/.test(m[2])) {
|
|
4516
|
+
scriptInterp = lineOf(m.index + m[0].indexOf(m[2]));
|
|
4517
|
+
break;
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
if (scriptInterp) add({
|
|
4521
|
+
ruleId: "PYF-SEC-005",
|
|
4522
|
+
category: "security",
|
|
4523
|
+
severity: "critical",
|
|
4524
|
+
title: "Template variable interpolated into inline JavaScript",
|
|
4525
|
+
description: `Inline <script> near line ${scriptInterp} embeds template output directly in JS.`,
|
|
4526
|
+
impact: "HTML escaping does not protect a JS string context, so user data can break out and execute.",
|
|
4527
|
+
fix: `<script id="cfg" type="application/json">{{ data|json_script_safe }}</script>
|
|
4528
|
+
<script>const cfg = JSON.parse(document.getElementById("cfg").textContent);</script>`,
|
|
4529
|
+
line: scriptInterp,
|
|
4530
|
+
reference: "https://owasp.org/www-community/attacks/xss/"
|
|
4531
|
+
});
|
|
4532
|
+
let unlabelled = 0;
|
|
4533
|
+
for (const m of content.matchAll(/<(input|select|textarea)\b[^>]*>/gi)) {
|
|
4534
|
+
const tag = m[0];
|
|
4535
|
+
if (/type\s*=\s*["'](?:hidden|submit|button|reset|image)["']/i.test(tag)) continue;
|
|
4536
|
+
if (/\b(?:aria-label|aria-labelledby|title|placeholder)\s*=/i.test(tag)) continue;
|
|
4537
|
+
const before = content.slice(0, m.index);
|
|
4538
|
+
if (before.lastIndexOf("<label") > before.lastIndexOf("</label")) continue;
|
|
4539
|
+
const id = /\bid\s*=\s*["']([^"']+)["']/i.exec(tag);
|
|
4540
|
+
if (id && new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${id[1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`, "i").test(content)) continue;
|
|
4541
|
+
unlabelled = lineOf(m.index);
|
|
4542
|
+
break;
|
|
4543
|
+
}
|
|
4544
|
+
if (unlabelled) add({
|
|
4545
|
+
ruleId: "PYF-A11Y-002",
|
|
4546
|
+
category: "accessibility",
|
|
4547
|
+
severity: "warning",
|
|
4548
|
+
title: "Form control without an associated label",
|
|
4549
|
+
description: `Control at line ${unlabelled} has no <label for=\u2026>, aria-label or aria-labelledby.`,
|
|
4550
|
+
impact: "Screen-reader users hear an unnamed field and cannot tell what to enter.",
|
|
4551
|
+
fix: `<label for="email">Email</label>
|
|
4552
|
+
<input id="email" name="email" type="email">`,
|
|
4553
|
+
line: unlabelled,
|
|
4554
|
+
reference: "https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html"
|
|
4555
|
+
});
|
|
4556
|
+
const htmlTag = lineMatches2(content, /<html\b(?![^>]*\blang\s*=)[^>]*>/i);
|
|
4557
|
+
if (htmlTag.length) add({
|
|
4558
|
+
ruleId: "PYF-A11Y-003",
|
|
4559
|
+
category: "accessibility",
|
|
4560
|
+
severity: "warning",
|
|
4561
|
+
title: "<html> without a lang attribute",
|
|
4562
|
+
description: `The root <html> element at line ${htmlTag[0]} declares no language.`,
|
|
4563
|
+
impact: "Screen readers pick the wrong pronunciation rules and translation tooling misfires.",
|
|
4564
|
+
fix: `<html lang="en">`,
|
|
4565
|
+
line: htmlTag[0],
|
|
4566
|
+
reference: "https://www.w3.org/WAI/WCAG21/Understanding/language-of-page.html"
|
|
4567
|
+
});
|
|
4568
|
+
const positiveTabindex = lineMatches2(content, /tabindex\s*=\s*["']?[1-9]/i);
|
|
4569
|
+
if (positiveTabindex.length) add({
|
|
4570
|
+
ruleId: "PYF-A11Y-004",
|
|
4571
|
+
category: "accessibility",
|
|
4572
|
+
severity: "warning",
|
|
4573
|
+
title: "Positive tabindex value",
|
|
4574
|
+
description: `tabindex greater than 0 at line ${positiveTabindex[0]}.`,
|
|
4575
|
+
impact: "Forces an element ahead of every natural tab stop, scrambling keyboard order for the whole page.",
|
|
4576
|
+
fix: `<button type="button">Save</button> <!-- rely on DOM order; use tabindex="0" or "-1" only -->`,
|
|
4577
|
+
line: positiveTabindex[0],
|
|
4578
|
+
reference: "https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html"
|
|
4579
|
+
});
|
|
4580
|
+
const inlineStyle = lineMatches2(content, /<[a-z][^>]*\sstyle\s*=\s*["'][^"']+["']/i);
|
|
4581
|
+
if (inlineStyle.length) add({
|
|
4582
|
+
ruleId: "PYF-STD-003",
|
|
4583
|
+
category: "standards",
|
|
4584
|
+
severity: "info",
|
|
4585
|
+
title: "Inline style attribute",
|
|
4586
|
+
description: `Inline style at line ${inlineStyle[0]}.`,
|
|
4587
|
+
impact: "Cannot be themed or overridden, and requires 'unsafe-inline' in the style CSP directive.",
|
|
4588
|
+
fix: `<div class="card card--highlight">\u2026</div> <!-- move rules into a stylesheet -->`,
|
|
4589
|
+
line: inlineStyle[0],
|
|
4590
|
+
reference: "https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP"
|
|
4591
|
+
});
|
|
4592
|
+
const deprecatedLoad = lineMatches2(content, /\{%\s*load\s+[^%]*\b(?:staticfiles|admin_static|future|adminmedia)\b/i);
|
|
4593
|
+
if (deprecatedLoad.length) add({
|
|
4594
|
+
ruleId: "PYF-STD-004",
|
|
4595
|
+
category: "standards",
|
|
4596
|
+
severity: "warning",
|
|
4597
|
+
title: "Deprecated template tag library loaded",
|
|
4598
|
+
description: `{% load %} of a removed Django tag library at line ${deprecatedLoad[0]}.`,
|
|
4599
|
+
impact: "staticfiles/admin_static/future were removed in Django 3.0; the template raises TemplateSyntaxError after upgrade.",
|
|
4600
|
+
fix: `{% load static %}`,
|
|
4601
|
+
line: deprecatedLoad[0],
|
|
4602
|
+
reference: "https://docs.djangoproject.com/en/stable/releases/3.0/#features-removed-in-3-0"
|
|
4603
|
+
});
|
|
4604
|
+
const hardcodedStatic = lineMatches2(content, /(?:src|href)\s*=\s*["']\/?static\//i);
|
|
4605
|
+
if (hardcodedStatic.length) add({
|
|
4606
|
+
ruleId: "PYF-STD-005",
|
|
4607
|
+
category: "standards",
|
|
4608
|
+
severity: "info",
|
|
4609
|
+
title: "Hardcoded static asset path",
|
|
4610
|
+
description: `Asset URL written literally at line ${hardcodedStatic[0]} instead of via {% static %}.`,
|
|
4611
|
+
impact: "Breaks under a CDN, a STATIC_URL change, or hashed/cache-busted filenames.",
|
|
4612
|
+
fix: `<img src="{% static 'img/logo.png' %}" alt="Logo">`,
|
|
4613
|
+
line: hardcodedStatic[0],
|
|
4614
|
+
reference: "https://docs.djangoproject.com/en/stable/ref/templates/builtins/#static"
|
|
4615
|
+
});
|
|
4616
|
+
const deprecatedTag = lineMatches2(content, /<\/?(?:center|font|marquee|blink|big|strike|frame|frameset|acronym)\b/i);
|
|
4617
|
+
if (deprecatedTag.length) add({
|
|
4618
|
+
ruleId: "PYF-STD-006",
|
|
4619
|
+
category: "standards",
|
|
4620
|
+
severity: "info",
|
|
4621
|
+
title: "Obsolete HTML element",
|
|
4622
|
+
description: `Presentational/obsolete element at line ${deprecatedTag[0]}.`,
|
|
4623
|
+
impact: "Not in the HTML living standard; rendering is browser-dependent and assistive tech may ignore it.",
|
|
4624
|
+
fix: `<p class="text-center">\u2026</p> <!-- replace <center>/<font> with CSS -->`,
|
|
4625
|
+
line: deprecatedTag[0],
|
|
4626
|
+
reference: "https://developer.mozilla.org/en-US/docs/Web/HTML/Element#obsolete_and_deprecated_elements"
|
|
4627
|
+
});
|
|
3360
4628
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3361
4629
|
return buildAuditResult({
|
|
3362
4630
|
filename,
|
|
@@ -3372,6 +4640,7 @@ var CATEGORY_IDS9 = AUDIT_STACKS.java_frontend.categories.map((c) => c.id);
|
|
|
3372
4640
|
function analyseJavaCoreLocally(filename, content, options = {}) {
|
|
3373
4641
|
const disabled = options.disabledRuleIds ?? /* @__PURE__ */ new Set();
|
|
3374
4642
|
const findings = [];
|
|
4643
|
+
const lines = content.split(/\r?\n/);
|
|
3375
4644
|
const add = (r) => pushFinding(findings, r, disabled);
|
|
3376
4645
|
const pst = lineMatches2(content, /printStackTrace\s*\(/);
|
|
3377
4646
|
if (pst.length) add({
|
|
@@ -3450,26 +4719,214 @@ function analyseJavaCoreLocally(filename, content, options = {}) {
|
|
|
3450
4719
|
fix: `List<Order> orders = new ArrayList<>();`,
|
|
3451
4720
|
line: rawList[0]
|
|
3452
4721
|
});
|
|
3453
|
-
if (/public\s+class\s+\w+\s*\{[\s\S]{2500,}/.test(content)) add({
|
|
3454
|
-
ruleId: "JVF-MNT-001",
|
|
3455
|
-
category: "maintainability",
|
|
3456
|
-
severity: "info",
|
|
3457
|
-
title: "Large class \u2014 consider splitting",
|
|
3458
|
-
description: "Very long class body often mixes responsibilities.",
|
|
3459
|
-
impact: "Harder reviews and testing.",
|
|
3460
|
-
fix: "Extract cohesive responsibilities into separate classes.",
|
|
3461
|
-
line: 1
|
|
4722
|
+
if (/public\s+class\s+\w+\s*\{[\s\S]{2500,}/.test(content)) add({
|
|
4723
|
+
ruleId: "JVF-MNT-001",
|
|
4724
|
+
category: "maintainability",
|
|
4725
|
+
severity: "info",
|
|
4726
|
+
title: "Large class \u2014 consider splitting",
|
|
4727
|
+
description: "Very long class body often mixes responsibilities.",
|
|
4728
|
+
impact: "Harder reviews and testing.",
|
|
4729
|
+
fix: "Extract cohesive responsibilities into separate classes.",
|
|
4730
|
+
line: 1
|
|
4731
|
+
});
|
|
4732
|
+
const todo = lineMatches2(content, /\/\/\s*(TODO|FIXME)|\/\*\s*(TODO|FIXME)/i);
|
|
4733
|
+
if (todo.length) add({
|
|
4734
|
+
ruleId: "JVF-STD-004",
|
|
4735
|
+
category: "coding_standards",
|
|
4736
|
+
severity: "info",
|
|
4737
|
+
title: "Unresolved TODO/FIXME",
|
|
4738
|
+
description: "Leftover markers indicate unfinished work.",
|
|
4739
|
+
impact: "Unfinished logic ships.",
|
|
4740
|
+
fix: "Resolve or link to an issue.",
|
|
4741
|
+
line: todo[0]
|
|
4742
|
+
});
|
|
4743
|
+
const catchThrowable = lineMatches2(content, /catch\s*\(\s*(?:final\s+)?(?:java\.lang\.)?(?:Throwable|Error)\s+\w+\s*\)/);
|
|
4744
|
+
if (catchThrowable.length) add({
|
|
4745
|
+
ruleId: "JVF-ERR-003",
|
|
4746
|
+
category: "error_handling",
|
|
4747
|
+
severity: "warning",
|
|
4748
|
+
title: "catch (Throwable) / catch (Error)",
|
|
4749
|
+
description: `Line ${catchThrowable[0]} catches Throwable or Error.`,
|
|
4750
|
+
impact: "Swallows OutOfMemoryError, StackOverflowError and thread-death, leaving the JVM in an undefined state.",
|
|
4751
|
+
fix: `} catch (IOException e) {
|
|
4752
|
+
log.error("read failed", e);
|
|
4753
|
+
throw new StorageException(e);
|
|
4754
|
+
}`,
|
|
4755
|
+
line: catchThrowable[0],
|
|
4756
|
+
reference: "https://wiki.sei.cmu.edu/confluence/display/java/ERR08-J.+Do+not+catch+NullPointerException+or+any+of+its+ancestors"
|
|
4757
|
+
});
|
|
4758
|
+
const messageOnly = lineMatches2(content, /\.(?:error|warn)\s*\([^;]*\.getMessage\s*\(\s*\)\s*\)\s*;/);
|
|
4759
|
+
if (messageOnly.length) add({
|
|
4760
|
+
ruleId: "JVF-ERR-004",
|
|
4761
|
+
category: "error_handling",
|
|
4762
|
+
severity: "warning",
|
|
4763
|
+
title: "Exception logged as getMessage() only",
|
|
4764
|
+
description: `Line ${messageOnly[0]} logs the message text and discards the throwable.`,
|
|
4765
|
+
impact: 'No stack trace and no cause chain, so the failure cannot be located; NPEs log as "null".',
|
|
4766
|
+
fix: `log.error("failed to load order {}", orderId, e); // pass the throwable itself`,
|
|
4767
|
+
line: messageOnly[0],
|
|
4768
|
+
reference: "https://www.slf4j.org/faq.html#paramException"
|
|
4769
|
+
});
|
|
4770
|
+
const procExec = lineMatches2(content, /Runtime\.getRuntime\s*\(\s*\)\s*\.exec\s*\(|new\s+ProcessBuilder\s*\(/);
|
|
4771
|
+
if (procExec.length) add({
|
|
4772
|
+
ruleId: "JVF-SEC-002",
|
|
4773
|
+
category: "security",
|
|
4774
|
+
severity: "warning",
|
|
4775
|
+
title: "External process execution",
|
|
4776
|
+
description: `Line ${procExec[0]} spawns an OS process.`,
|
|
4777
|
+
impact: "If any argument derives from user input this becomes command injection.",
|
|
4778
|
+
fix: `new ProcessBuilder(List.of("/usr/bin/convert", inputPath, outputPath)).start(); // never build one shell string`,
|
|
4779
|
+
line: procExec[0],
|
|
4780
|
+
reference: "https://owasp.org/www-community/attacks/Command_Injection"
|
|
4781
|
+
});
|
|
4782
|
+
const javaSecret = lineMatches2(content, /\b(?:password|passwd|pwd|secret|apiKey|api_key|accessKey|authToken|token)\s*=\s*"[^"]{4,}"/i);
|
|
4783
|
+
if (javaSecret.length) add({
|
|
4784
|
+
ruleId: "JVF-SEC-003",
|
|
4785
|
+
category: "security",
|
|
4786
|
+
severity: "critical",
|
|
4787
|
+
title: "Hardcoded credential",
|
|
4788
|
+
description: `A password/token literal is assigned at line ${javaSecret[0]}.`,
|
|
4789
|
+
impact: "The secret is in version control and in every decompiled build artifact.",
|
|
4790
|
+
fix: `String apiKey = System.getenv("API_KEY");`,
|
|
4791
|
+
line: javaSecret[0],
|
|
4792
|
+
reference: "https://cwe.mitre.org/data/definitions/798.html"
|
|
4793
|
+
});
|
|
4794
|
+
const weakHash = lineMatches2(content, /MessageDigest\.getInstance\s*\(\s*"(?:MD2|MD5|SHA-?1)"|DigestUtils\.(?:md5|sha1)\w*\s*\(/i);
|
|
4795
|
+
if (weakHash.length) add({
|
|
4796
|
+
ruleId: "JVF-SEC-004",
|
|
4797
|
+
category: "security",
|
|
4798
|
+
severity: "warning",
|
|
4799
|
+
title: "Broken hash algorithm (MD5/SHA-1)",
|
|
4800
|
+
description: `Line ${weakHash[0]} uses MD5 or SHA-1.`,
|
|
4801
|
+
impact: "Both are collision-broken and unsuitable for signatures, integrity checks or password storage.",
|
|
4802
|
+
fix: `MessageDigest.getInstance("SHA-256"); // for passwords use BCrypt/Argon2, not a raw digest`,
|
|
4803
|
+
line: weakHash[0],
|
|
4804
|
+
reference: "https://cwe.mitre.org/data/definitions/327.html"
|
|
4805
|
+
});
|
|
4806
|
+
const sqlConcat = lineMatches2(content, /(?:executeQuery|executeUpdate|prepareStatement|createQuery|createNativeQuery)\s*\([^;]*"\s*\+|String\s+\w*(?:sql|query|Sql|Query)\w*\s*=\s*"[^"]*"\s*\+/);
|
|
4807
|
+
if (sqlConcat.length) add({
|
|
4808
|
+
ruleId: "JVF-SEC-005",
|
|
4809
|
+
category: "security",
|
|
4810
|
+
severity: "critical",
|
|
4811
|
+
title: "SQL built by string concatenation",
|
|
4812
|
+
description: `Line ${sqlConcat[0]} concatenates values into a SQL string.`,
|
|
4813
|
+
impact: "Direct SQL injection: an attacker can read or destroy the database.",
|
|
4814
|
+
fix: `PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
|
|
4815
|
+
ps.setLong(1, userId);`,
|
|
4816
|
+
line: sqlConcat[0],
|
|
4817
|
+
reference: "https://owasp.org/www-community/attacks/SQL_Injection"
|
|
4818
|
+
});
|
|
4819
|
+
const legacyCollections = lineMatches2(content, /\bnew\s+(?:Vector|Hashtable|StringBuffer)\s*[<(]|\b(?:Vector|Hashtable)\s*<[^>]*>\s+\w+\s*[=;]/);
|
|
4820
|
+
if (legacyCollections.length) add({
|
|
4821
|
+
ruleId: "JVF-PER-002",
|
|
4822
|
+
category: "performance",
|
|
4823
|
+
severity: "info",
|
|
4824
|
+
title: "Legacy synchronized collection / StringBuffer",
|
|
4825
|
+
description: `Line ${legacyCollections[0]} uses Vector, Hashtable or StringBuffer.`,
|
|
4826
|
+
impact: "Pays for method-level locking that single-threaded code never needs, and the locking is too coarse to make callers thread-safe anyway.",
|
|
4827
|
+
fix: `List<Order> orders = new ArrayList<>();
|
|
4828
|
+
Map<String, Order> byId = new HashMap<>(); // ConcurrentHashMap if shared
|
|
4829
|
+
StringBuilder sb = new StringBuilder();`,
|
|
4830
|
+
line: legacyCollections[0],
|
|
4831
|
+
reference: "https://docs.oracle.com/javase/8/docs/api/java/util/Vector.html"
|
|
4832
|
+
});
|
|
4833
|
+
const explicitGc = lineMatches2(content, /System\.gc\s*\(\s*\)|Runtime\.getRuntime\s*\(\s*\)\s*\.gc\s*\(/);
|
|
4834
|
+
if (explicitGc.length) add({
|
|
4835
|
+
ruleId: "JVF-PER-003",
|
|
4836
|
+
category: "performance",
|
|
4837
|
+
severity: "warning",
|
|
4838
|
+
title: "Explicit System.gc() call",
|
|
4839
|
+
description: `Line ${explicitGc[0]} requests a garbage collection.`,
|
|
4840
|
+
impact: "Can force a full stop-the-world collection and defeat the collector's own heuristics.",
|
|
4841
|
+
fix: "Delete the call; release references and let the JVM manage collection.",
|
|
4842
|
+
line: explicitGc[0],
|
|
4843
|
+
reference: "https://wiki.sei.cmu.edu/confluence/display/java/MET12-J.+Do+not+use+finalizers"
|
|
4844
|
+
});
|
|
4845
|
+
const unclosed = lineMatches2(content, /=\s*new\s+(?:FileInputStream|FileOutputStream|FileReader|FileWriter|BufferedReader|BufferedWriter|Scanner|Socket|RandomAccessFile)\s*\(/).filter((ln) => !/\btry\s*\(/.test(lines[ln - 1] || "") && !/\btry\s*\($/.test((lines[ln - 2] || "").trim()));
|
|
4846
|
+
if (unclosed.length) add({
|
|
4847
|
+
ruleId: "JVF-PER-004",
|
|
4848
|
+
category: "performance",
|
|
4849
|
+
severity: "warning",
|
|
4850
|
+
title: "Resource opened outside try-with-resources",
|
|
4851
|
+
description: `Line ${unclosed[0]} opens a stream/reader/socket that is not managed by try-with-resources.`,
|
|
4852
|
+
impact: "On an exception the file handle or socket leaks until GC, exhausting descriptors under load.",
|
|
4853
|
+
fix: `try (BufferedReader r = new BufferedReader(new FileReader(path))) {
|
|
4854
|
+
return r.readLine();
|
|
4855
|
+
}`,
|
|
4856
|
+
line: unclosed[0],
|
|
4857
|
+
reference: "https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html"
|
|
4858
|
+
});
|
|
4859
|
+
const equalsNoHash = /(?:public|protected)\s+boolean\s+equals\s*\(\s*Object\s/.test(content) && !/\bint\s+hashCode\s*\(\s*\)/.test(content) ? lineMatches2(content, /(?:public|protected)\s+boolean\s+equals\s*\(\s*Object\s/) : [];
|
|
4860
|
+
if (equalsNoHash.length) add({
|
|
4861
|
+
ruleId: "JVF-MNT-002",
|
|
4862
|
+
category: "maintainability",
|
|
4863
|
+
severity: "warning",
|
|
4864
|
+
title: "equals() without hashCode()",
|
|
4865
|
+
description: `equals is overridden at line ${equalsNoHash[0]} but hashCode is not.`,
|
|
4866
|
+
impact: "Equal objects get different hashes, so HashMap/HashSet lookups silently miss.",
|
|
4867
|
+
fix: `@Override public int hashCode() { return Objects.hash(id, name); }`,
|
|
4868
|
+
line: equalsNoHash[0],
|
|
4869
|
+
reference: "https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#hashCode--"
|
|
4870
|
+
});
|
|
4871
|
+
const mutableStatic = lineMatches2(content, /public\s+static\s+(?!final\b)(?!void\b)[\w.]+(?:\s*<[^>]*>)?(?:\s*\[\s*\])?\s+\w+\s*[=;]/);
|
|
4872
|
+
if (mutableStatic.length) add({
|
|
4873
|
+
ruleId: "JVF-MNT-003",
|
|
4874
|
+
category: "maintainability",
|
|
4875
|
+
severity: "warning",
|
|
4876
|
+
title: "Mutable public static field",
|
|
4877
|
+
description: `Line ${mutableStatic[0]} exposes a non-final public static field.`,
|
|
4878
|
+
impact: "Global mutable state: any class can reassign it, and concurrent writes are unsynchronised.",
|
|
4879
|
+
fix: `private static final Config CONFIG = Config.load();
|
|
4880
|
+
public static Config config() { return CONFIG; }`,
|
|
4881
|
+
line: mutableStatic[0],
|
|
4882
|
+
reference: "https://wiki.sei.cmu.edu/confluence/display/java/OBJ10-J.+Do+not+use+public+static+nonfinal+fields"
|
|
4883
|
+
});
|
|
4884
|
+
const finalizer = lineMatches2(content, /(?:protected|public)\s+void\s+finalize\s*\(\s*\)/);
|
|
4885
|
+
if (finalizer.length) add({
|
|
4886
|
+
ruleId: "JVF-MNT-004",
|
|
4887
|
+
category: "maintainability",
|
|
4888
|
+
severity: "warning",
|
|
4889
|
+
title: "finalize() override",
|
|
4890
|
+
description: `finalize is overridden at line ${finalizer[0]}.`,
|
|
4891
|
+
impact: "Deprecated since Java 9 and never guaranteed to run; it delays collection and can resurrect objects.",
|
|
4892
|
+
fix: `class Handle implements AutoCloseable {
|
|
4893
|
+
@Override public void close() { release(); }
|
|
4894
|
+
}`,
|
|
4895
|
+
line: finalizer[0],
|
|
4896
|
+
reference: "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Object.html#finalize()"
|
|
4897
|
+
});
|
|
4898
|
+
const boxedCtor = lineMatches2(content, /new\s+(?:Integer|Boolean|Double|Long|Float|Short|Byte|Character)\s*\(/);
|
|
4899
|
+
if (boxedCtor.length) add({
|
|
4900
|
+
ruleId: "JVF-STD-005",
|
|
4901
|
+
category: "coding_standards",
|
|
4902
|
+
severity: "warning",
|
|
4903
|
+
title: "Deprecated boxed-primitive constructor",
|
|
4904
|
+
description: `Line ${boxedCtor[0]} calls a wrapper constructor such as new Integer(...).`,
|
|
4905
|
+
impact: "Deprecated for removal since Java 9; allocates a new object every time and breaks == identity assumptions.",
|
|
4906
|
+
fix: `Integer count = Integer.valueOf(text); // or just: int count = Integer.parseInt(text);`,
|
|
4907
|
+
line: boxedCtor[0],
|
|
4908
|
+
reference: "https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Integer.html#%3Cinit%3E(int)"
|
|
4909
|
+
});
|
|
4910
|
+
const missingOverride = lineMatches2(content, /(?:public|protected)\s+(?:final\s+)?(?:String|boolean|int)\s+(?:toString|equals|hashCode)\s*\(/).filter((ln) => {
|
|
4911
|
+
if ((lines[ln - 1] || "").includes("@Override")) return false;
|
|
4912
|
+
for (let i = ln - 2; i >= 0; i--) {
|
|
4913
|
+
const prev = (lines[i] || "").trim();
|
|
4914
|
+
if (!prev || prev.startsWith("//") || prev.startsWith("*") || prev.startsWith("/*")) continue;
|
|
4915
|
+
return !prev.includes("@Override");
|
|
4916
|
+
}
|
|
4917
|
+
return true;
|
|
3462
4918
|
});
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
ruleId: "JVF-STD-004",
|
|
4919
|
+
if (missingOverride.length) add({
|
|
4920
|
+
ruleId: "JVF-STD-006",
|
|
3466
4921
|
category: "coding_standards",
|
|
3467
4922
|
severity: "info",
|
|
3468
|
-
title: "
|
|
3469
|
-
description:
|
|
3470
|
-
impact: "
|
|
3471
|
-
fix:
|
|
3472
|
-
|
|
4923
|
+
title: "Overridden method without @Override",
|
|
4924
|
+
description: `toString/equals/hashCode at line ${missingOverride[0]} is not annotated with @Override.`,
|
|
4925
|
+
impact: "A signature typo silently defines a new method instead of overriding, and the compiler cannot warn.",
|
|
4926
|
+
fix: `@Override
|
|
4927
|
+
public String toString() { return "Order[" + id + "]"; }`,
|
|
4928
|
+
line: missingOverride[0],
|
|
4929
|
+
reference: "https://docs.oracle.com/javase/8/docs/api/java/lang/Override.html"
|
|
3473
4930
|
});
|
|
3474
4931
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3475
4932
|
return buildAuditResult({
|
|
@@ -3592,6 +5049,178 @@ given().spec(spec)\u2026`,
|
|
|
3592
5049
|
fix: "Resolve or link to an issue.",
|
|
3593
5050
|
line: todo[0]
|
|
3594
5051
|
});
|
|
5052
|
+
const statusCalls = lineMatches2(content, /statusCode\s*\(/);
|
|
5053
|
+
const hasNegativeStatus = /statusCode\s*\(\s*(?:HttpStatus\.)?(?:SC_)?(?:4\d\d|5\d\d|BAD_REQUEST|UNAUTHORIZED|FORBIDDEN|NOT_FOUND|CONFLICT|UNPROCESSABLE_ENTITY|TOO_MANY_REQUESTS|INTERNAL_SERVER_ERROR)/i.test(content) || /statusCode\s*\(\s*(?:anyOf|is|oneOf)\s*\([^)]*[45]\d\d/.test(content);
|
|
5054
|
+
if (statusCalls.length && !hasNegativeStatus)
|
|
5055
|
+
add({
|
|
5056
|
+
ruleId: "RA-AST-003",
|
|
5057
|
+
category: "assertions",
|
|
5058
|
+
severity: "warning",
|
|
5059
|
+
title: "No negative-path coverage",
|
|
5060
|
+
description: `Every statusCode(...) assertion in this file expects a 2xx/3xx result (first at line ${statusCalls[0]}); no 4xx/5xx case is asserted anywhere.`,
|
|
5061
|
+
impact: "Error handling, validation and auth failures are completely untested \u2014 the API can start returning 200 for invalid input and the suite stays green.",
|
|
5062
|
+
fix: `@Test
|
|
5063
|
+
void rejectsUnknownUser() {
|
|
5064
|
+
given().spec(spec)
|
|
5065
|
+
.when().get("/users/999999")
|
|
5066
|
+
.then().statusCode(404).body("error", equalTo("NOT_FOUND"));
|
|
5067
|
+
}`,
|
|
5068
|
+
line: statusCalls[0],
|
|
5069
|
+
reference: "https://rest-assured.io/#usage"
|
|
5070
|
+
});
|
|
5071
|
+
if (/\.body\s*\(/.test(content) && !/matchesJsonSchema/.test(content))
|
|
5072
|
+
add({
|
|
5073
|
+
ruleId: "RA-AST-004",
|
|
5074
|
+
category: "assertions",
|
|
5075
|
+
severity: "info",
|
|
5076
|
+
title: "No JSON-schema contract validation",
|
|
5077
|
+
description: "Body assertions cherry-pick individual fields but the payload is never validated against a JSON schema.",
|
|
5078
|
+
impact: "Removed, renamed or re-typed fields that nobody asserts on go unnoticed until a consumer breaks.",
|
|
5079
|
+
fix: `.then().statusCode(200)
|
|
5080
|
+
.body(matchesJsonSchemaInClasspath("schemas/user.json"));`,
|
|
5081
|
+
line: lineMatches2(content, /\.body\s*\(/)[0],
|
|
5082
|
+
reference: "https://github.com/rest-assured/rest-assured/wiki/Usage#json-schema-validation"
|
|
5083
|
+
});
|
|
5084
|
+
if (hasTests && !/\btime\s*\(|ResponseTime|timeIn\s*\(|getTimeIn/.test(content))
|
|
5085
|
+
add({
|
|
5086
|
+
ruleId: "RA-AST-005",
|
|
5087
|
+
category: "assertions",
|
|
5088
|
+
severity: "info",
|
|
5089
|
+
title: "Response time / SLA never asserted",
|
|
5090
|
+
description: "No .time(...) assertion anywhere; the suite never checks how long the API takes to respond.",
|
|
5091
|
+
impact: "Performance regressions ship silently because only correctness is gated.",
|
|
5092
|
+
fix: `.then().statusCode(200)
|
|
5093
|
+
.time(lessThan(1500L), TimeUnit.MILLISECONDS);`,
|
|
5094
|
+
line: null,
|
|
5095
|
+
reference: "https://github.com/rest-assured/rest-assured/wiki/Usage#measuring-response-time"
|
|
5096
|
+
});
|
|
5097
|
+
const verboseLog = lineMatches2(content, /\.log\s*\(\s*\)\s*\.(?:all|everything|body|headers)\s*\(|\.prettyPrint\s*\(\s*\)|\.prettyPeek\s*\(\s*\)|\.peek\s*\(\s*\)/);
|
|
5098
|
+
if (verboseLog.length) add({
|
|
5099
|
+
ruleId: "RA-SEC-003",
|
|
5100
|
+
category: "security",
|
|
5101
|
+
severity: "warning",
|
|
5102
|
+
title: "Full request/response logged unconditionally",
|
|
5103
|
+
description: `log().all() / prettyPrint() at line ${verboseLog[0]} dumps every header and body, including Authorization, Set-Cookie and PII.`,
|
|
5104
|
+
impact: "Credentials and personal data end up in CI build logs, which are usually retained and broadly readable.",
|
|
5105
|
+
fix: `given().log().ifValidationFails()
|
|
5106
|
+
.when().get("/users/1")
|
|
5107
|
+
.then().log().ifValidationFails().statusCode(200);`,
|
|
5108
|
+
line: verboseLog[0],
|
|
5109
|
+
reference: "https://github.com/rest-assured/rest-assured/wiki/Usage#logging"
|
|
5110
|
+
});
|
|
5111
|
+
const inlineAuth = lineMatches2(content, /\.(?:basic|digest|form|oauth2|oauth)\s*\(\s*"[^"]{2,}"/);
|
|
5112
|
+
if (inlineAuth.length) add({
|
|
5113
|
+
ruleId: "RA-SEC-004",
|
|
5114
|
+
category: "security",
|
|
5115
|
+
severity: "critical",
|
|
5116
|
+
title: "Credentials inlined in auth() call",
|
|
5117
|
+
description: `An auth helper at line ${inlineAuth[0]} is called with string literals instead of injected values.`,
|
|
5118
|
+
impact: "Real usernames/passwords or OAuth tokens are committed to the repository and cannot be rotated without a code change.",
|
|
5119
|
+
fix: `given().auth().preemptive().basic(System.getenv("API_USER"), System.getenv("API_PASSWORD"))`,
|
|
5120
|
+
line: inlineAuth[0],
|
|
5121
|
+
reference: "https://github.com/rest-assured/rest-assured/wiki/Usage#authentication"
|
|
5122
|
+
});
|
|
5123
|
+
if (hasTests && !/CONNECTION_TIMEOUT|SO_TIMEOUT|socket\.timeout|connectTimeout|readTimeout|HttpClientConfig|http\.connection\.timeout/i.test(content))
|
|
5124
|
+
add({
|
|
5125
|
+
ruleId: "RA-REL-002",
|
|
5126
|
+
category: "reliability",
|
|
5127
|
+
severity: "warning",
|
|
5128
|
+
title: "No HTTP connect/read timeout configured",
|
|
5129
|
+
description: "No HttpClientConfig / CONNECTION_TIMEOUT / SO_TIMEOUT setting is present, so requests fall back to the client default (often unbounded).",
|
|
5130
|
+
impact: "A hung or black-holed endpoint blocks the build until the CI job-level timeout kills it, hiding the real failure.",
|
|
5131
|
+
fix: `RestAssured.config = RestAssured.config().httpClient(
|
|
5132
|
+
HttpClientConfig.httpClientConfig()
|
|
5133
|
+
.setParam("http.connection.timeout", 5000)
|
|
5134
|
+
.setParam("http.socket.timeout", 10000));`,
|
|
5135
|
+
line: null,
|
|
5136
|
+
reference: "https://github.com/rest-assured/rest-assured/wiki/Usage#connection-timeout"
|
|
5137
|
+
});
|
|
5138
|
+
const swallow = lineMatches2(content, /catch\s*\(\s*(?:final\s+)?[\w.]*(?:Exception|Throwable)\b/);
|
|
5139
|
+
if (swallow.length && !/\bfail\s*\(|\bthrow\s+new\s+|assertThrows\s*\(/.test(content))
|
|
5140
|
+
add({
|
|
5141
|
+
ruleId: "RA-REL-003",
|
|
5142
|
+
category: "reliability",
|
|
5143
|
+
severity: "warning",
|
|
5144
|
+
title: "Exception swallowed inside test",
|
|
5145
|
+
description: `A catch block at line ${swallow[0]} has no fail(...), rethrow or assertThrows(...).`,
|
|
5146
|
+
impact: "A genuine request/parse failure is silently absorbed and the test reports success \u2014 a permanently green, permanently useless test.",
|
|
5147
|
+
fix: `// let it propagate, or assert on it explicitly
|
|
5148
|
+
assertThrows(SocketTimeoutException.class, () -> given().spec(spec).get("/slow"));`,
|
|
5149
|
+
line: swallow[0]
|
|
5150
|
+
});
|
|
5151
|
+
const ordered = lineMatches2(content, /@TestMethodOrder|@FixMethodOrder|@Order\s*\(|dependsOnMethods|@Stepwise/);
|
|
5152
|
+
if (ordered.length) add({
|
|
5153
|
+
ruleId: "RA-STR-002",
|
|
5154
|
+
category: "structure",
|
|
5155
|
+
severity: "warning",
|
|
5156
|
+
title: "Order-dependent tests",
|
|
5157
|
+
description: `Explicit execution ordering is declared at line ${ordered[0]}, so tests rely on running in sequence.`,
|
|
5158
|
+
impact: "Tests cannot run in isolation or in parallel, and one early failure cascades into misleading downstream failures.",
|
|
5159
|
+
fix: `// Make each test self-sufficient via a fixture instead of ordering:
|
|
5160
|
+
@BeforeEach
|
|
5161
|
+
void seed() { createdId = createUser(); }`,
|
|
5162
|
+
line: ordered[0]
|
|
5163
|
+
});
|
|
5164
|
+
const mutableStatic = lineMatches2(content, /^\s*(?:public|private|protected)?\s*static\s+(?!final\b)[A-Za-z_][\w.<>,[\]]*(?:<[^>]*>)?\s+\w+\s*(?:=[^=]|;)/);
|
|
5165
|
+
if (hasTests && mutableStatic.length) add({
|
|
5166
|
+
ruleId: "RA-STR-003",
|
|
5167
|
+
category: "structure",
|
|
5168
|
+
severity: "warning",
|
|
5169
|
+
title: "Shared mutable static state between tests",
|
|
5170
|
+
description: `A non-final static field is declared at line ${mutableStatic[0]} and is written by tests.`,
|
|
5171
|
+
impact: "Tests leak state into each other, so results depend on execution order and break under parallel execution.",
|
|
5172
|
+
fix: `// Scope the state to the test, or make it immutable:
|
|
5173
|
+
private static final RequestSpecification SPEC = buildSpec();
|
|
5174
|
+
private int createdId; // per-instance, reset by JUnit each test`,
|
|
5175
|
+
line: mutableStatic[0]
|
|
5176
|
+
});
|
|
5177
|
+
const loginCalls = lineMatches2(content, /\.(?:post|get)\s*\([^\n]*"[^"\n]*(?:\/login|\/oauth\/token|\/token|\/authenticate)\b/i);
|
|
5178
|
+
if (loginCalls.length > 1 && !/@BeforeAll|@BeforeClass/.test(content))
|
|
5179
|
+
add({
|
|
5180
|
+
ruleId: "RA-STR-004",
|
|
5181
|
+
category: "structure",
|
|
5182
|
+
severity: "info",
|
|
5183
|
+
title: "Auth token re-fetched per test",
|
|
5184
|
+
description: `${loginCalls.length} separate authentication calls (first at line ${loginCalls[0]}) with no @BeforeAll to fetch the token once.`,
|
|
5185
|
+
impact: "Every test pays a login round-trip and can trip the identity provider's rate limits, making the suite slow and flaky.",
|
|
5186
|
+
fix: `private static String token;
|
|
5187
|
+
|
|
5188
|
+
@BeforeAll
|
|
5189
|
+
static void authenticate() {
|
|
5190
|
+
token = given().spec(spec).body(creds).post("/login").jsonPath().getString("token");
|
|
5191
|
+
}`,
|
|
5192
|
+
line: loginCalls[0]
|
|
5193
|
+
});
|
|
5194
|
+
const disabledTests = lineMatches2(content, /@Disabled\b|@Ignore\b/);
|
|
5195
|
+
if (disabledTests.length) add({
|
|
5196
|
+
ruleId: "RA-STD-003",
|
|
5197
|
+
category: "coding_standards",
|
|
5198
|
+
severity: "warning",
|
|
5199
|
+
title: "Disabled / ignored test",
|
|
5200
|
+
description: `@Disabled or @Ignore at line ${disabledTests[0]} keeps the test in the file but out of the run.`,
|
|
5201
|
+
impact: "Coverage looks intact while the scenario is actually unverified, and muted tests tend to stay muted indefinitely.",
|
|
5202
|
+
fix: `@Disabled("PROJ-1234: re-enable once /users/bulk is deployed to staging")
|
|
5203
|
+
// \u2026or delete the test if the behaviour is gone.`,
|
|
5204
|
+
line: disabledTests[0]
|
|
5205
|
+
});
|
|
5206
|
+
{
|
|
5207
|
+
const hasTag = /@Tag\s*\(|@Category\s*\(|groups\s*=\s*[{"']/.test(content);
|
|
5208
|
+
if (/@Test\b/.test(content) && !hasTag) add({
|
|
5209
|
+
ruleId: "RA-STD-004",
|
|
5210
|
+
category: "coding_standards",
|
|
5211
|
+
severity: "info",
|
|
5212
|
+
title: "Tests carry no @Tag or TestNG group",
|
|
5213
|
+
description: "No @Tag, @Category or groups= appears in this file, so its tests cannot be selected by the runner.",
|
|
5214
|
+
impact: "Contract and smoke suites cannot be run separately, so every pipeline stage pays for the full API suite.",
|
|
5215
|
+
fix: `@Test
|
|
5216
|
+
@Tag("contract")
|
|
5217
|
+
void userSchemaIsStable() { }
|
|
5218
|
+
|
|
5219
|
+
// then: mvn test -Dgroups=contract`,
|
|
5220
|
+
line: lineMatches2(content, /@Test\b/)[0] ?? null,
|
|
5221
|
+
reference: "https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering"
|
|
5222
|
+
});
|
|
5223
|
+
}
|
|
3595
5224
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3596
5225
|
return buildAuditResult({
|
|
3597
5226
|
filename,
|
|
@@ -3705,6 +5334,140 @@ Given url baseUrl`,
|
|
|
3705
5334
|
fix: "Resolve or link to an issue.",
|
|
3706
5335
|
line: todo[0]
|
|
3707
5336
|
});
|
|
5337
|
+
const statusLines = lineMatches2(content, /(?:Then|And|\*)\s+status\s+\d{3}/);
|
|
5338
|
+
if (statusLines.length && !/(?:Then|And|\*)\s+status\s+[45]\d\d/.test(content))
|
|
5339
|
+
add({
|
|
5340
|
+
ruleId: "KA-AST-003",
|
|
5341
|
+
category: "assertions",
|
|
5342
|
+
severity: "info",
|
|
5343
|
+
title: "No negative-path coverage",
|
|
5344
|
+
description: `Every 'status' assertion in this feature expects a 2xx/3xx code (first at line ${statusLines[0]}); no 4xx/5xx scenario exists.`,
|
|
5345
|
+
impact: "Validation, auth and not-found handling are untested, so the API can start accepting bad input without any scenario failing.",
|
|
5346
|
+
fix: `Scenario: rejects an unknown user
|
|
5347
|
+
Given path 'users', 999999
|
|
5348
|
+
When method get
|
|
5349
|
+
Then status 404
|
|
5350
|
+
And match response.error == 'NOT_FOUND'`,
|
|
5351
|
+
line: statusLines[0],
|
|
5352
|
+
reference: "https://github.com/karatelabs/karate#status"
|
|
5353
|
+
});
|
|
5354
|
+
if (/match\s+response/.test(content) && !/#(?:string|number|boolean|array|object|notnull|present|uuid|regex|null|ignore|\()/.test(content))
|
|
5355
|
+
add({
|
|
5356
|
+
ruleId: "KA-AST-004",
|
|
5357
|
+
category: "assertions",
|
|
5358
|
+
severity: "info",
|
|
5359
|
+
title: "No fuzzy-match / schema validation",
|
|
5360
|
+
description: "match is only used on concrete values; no fuzzy markers (#string, #number, #array, #notnull) validate the payload's shape.",
|
|
5361
|
+
impact: "Type changes and dropped fields slip through because only the handful of hard-coded values are checked.",
|
|
5362
|
+
fix: `And match response ==
|
|
5363
|
+
"""
|
|
5364
|
+
{ id: '#number', name: '#string', tags: '#[] #string', createdAt: '#notnull' }
|
|
5365
|
+
"""`,
|
|
5366
|
+
line: lineMatches2(content, /match\s+response/)[0],
|
|
5367
|
+
reference: "https://github.com/karatelabs/karate#fuzzy-matching"
|
|
5368
|
+
});
|
|
5369
|
+
const rawAssert = lineMatches2(content, /(?:\*|And|Then)\s+assert\s+.*\bresponse\b/);
|
|
5370
|
+
if (rawAssert.length) add({
|
|
5371
|
+
ruleId: "KA-AST-005",
|
|
5372
|
+
category: "assertions",
|
|
5373
|
+
severity: "info",
|
|
5374
|
+
title: "assert used where match belongs",
|
|
5375
|
+
description: `Line ${rawAssert[0]} compares the response with 'assert' (a raw JS truthiness check) instead of 'match'.`,
|
|
5376
|
+
impact: "You lose Karate's deep comparison, fuzzy markers and its detailed diff output \u2014 a failure just says the expression was false.",
|
|
5377
|
+
fix: `# instead of: * assert response.id == 1
|
|
5378
|
+
And match response.id == 1`,
|
|
5379
|
+
line: rawAssert[0],
|
|
5380
|
+
reference: "https://github.com/karatelabs/karate#match"
|
|
5381
|
+
});
|
|
5382
|
+
const sslOff = lineMatches2(content, /configure\s+ssl\s*=\s*true|relaxedHTTPSValidation/i);
|
|
5383
|
+
if (sslOff.length) add({
|
|
5384
|
+
ruleId: "KA-SEC-002",
|
|
5385
|
+
category: "security",
|
|
5386
|
+
severity: "warning",
|
|
5387
|
+
title: "TLS certificate validation disabled",
|
|
5388
|
+
description: `'configure ssl = true' at line ${sslOff[0]} makes Karate trust every certificate.`,
|
|
5389
|
+
impact: "The suite happily passes against a misconfigured, expired or man-in-the-middled endpoint, so TLS breakage is never caught before production.",
|
|
5390
|
+
fix: "Remove the override and install the environment's real CA certificate in the JVM truststore.",
|
|
5391
|
+
line: sslOff[0],
|
|
5392
|
+
reference: "https://github.com/karatelabs/karate#configure"
|
|
5393
|
+
});
|
|
5394
|
+
const defUrl = lineMatches2(content, /\*\s*def\s+\w*(?:[Uu]rl|[Hh]ost|[Ee]ndpoint)\w*\s*=\s*['"]https?:\/\//);
|
|
5395
|
+
if (defUrl.length) add({
|
|
5396
|
+
ruleId: "KA-CFG-002",
|
|
5397
|
+
category: "ci_config",
|
|
5398
|
+
severity: "warning",
|
|
5399
|
+
title: "Environment URL defined inside the feature",
|
|
5400
|
+
description: `Line ${defUrl[0]} defines a host/base URL with '* def' in the feature file rather than in karate-config.js.`,
|
|
5401
|
+
impact: "The feature is pinned to one environment, so the same scenarios cannot be reused across local, staging and CI runs.",
|
|
5402
|
+
fix: `// karate-config.js
|
|
5403
|
+
var config = { baseUrl: 'https://api-' + karate.env + '.example.com' };
|
|
5404
|
+
# feature
|
|
5405
|
+
Given url baseUrl`,
|
|
5406
|
+
line: defUrl[0],
|
|
5407
|
+
reference: "https://github.com/karatelabs/karate#karate-configjs"
|
|
5408
|
+
});
|
|
5409
|
+
const inlineConfigure = lineMatches2(content, /\*\s*configure\s+(?:headers|proxy|connectTimeout|readTimeout|charset|followRedirects|logPrettyRequest|logPrettyResponse|report)\b/);
|
|
5410
|
+
if (inlineConfigure.length) add({
|
|
5411
|
+
ruleId: "KA-STR-002",
|
|
5412
|
+
category: "structure",
|
|
5413
|
+
severity: "warning",
|
|
5414
|
+
title: "Global 'configure' inside a feature",
|
|
5415
|
+
description: `Line ${inlineConfigure[0]} sets global HTTP configuration in the feature instead of karate-config.js.`,
|
|
5416
|
+
impact: "The setting is duplicated per feature and drifts; worse, it can leak into other features in the same run and cause order-dependent behaviour.",
|
|
5417
|
+
fix: `// karate-config.js
|
|
5418
|
+
karate.configure('connectTimeout', 5000);
|
|
5419
|
+
karate.configure('readTimeout', 10000);`,
|
|
5420
|
+
line: inlineConfigure[0],
|
|
5421
|
+
reference: "https://github.com/karatelabs/karate#configure"
|
|
5422
|
+
});
|
|
5423
|
+
if (/Scenario Outline:/.test(content) && !/Examples:/.test(content))
|
|
5424
|
+
add({
|
|
5425
|
+
ruleId: "KA-STR-003",
|
|
5426
|
+
category: "structure",
|
|
5427
|
+
severity: "warning",
|
|
5428
|
+
title: "Scenario Outline without an Examples table",
|
|
5429
|
+
description: "A 'Scenario Outline:' is declared but no 'Examples:' data table follows it.",
|
|
5430
|
+
impact: "The outline's <placeholders> are never substituted \u2014 the scenario either fails to run or runs once with literal placeholder text, so the intended data-driven coverage does not exist.",
|
|
5431
|
+
fix: `Scenario Outline: reject invalid ids
|
|
5432
|
+
Given path 'users', '<id>'
|
|
5433
|
+
When method get
|
|
5434
|
+
Then status <status>
|
|
5435
|
+
|
|
5436
|
+
Examples:
|
|
5437
|
+
| id | status |
|
|
5438
|
+
| 0 | 400 |
|
|
5439
|
+
| 999999 | 404 |`,
|
|
5440
|
+
line: lineMatches2(content, /Scenario Outline:/)[0],
|
|
5441
|
+
reference: "https://github.com/karatelabs/karate#scenario-outline"
|
|
5442
|
+
});
|
|
5443
|
+
if (statusLines.length && !/responseTime/.test(content))
|
|
5444
|
+
add({
|
|
5445
|
+
ruleId: "KA-REL-002",
|
|
5446
|
+
category: "reliability",
|
|
5447
|
+
severity: "info",
|
|
5448
|
+
title: "Response time / SLA never asserted",
|
|
5449
|
+
description: "Scenarios assert status and body but never check responseTime.",
|
|
5450
|
+
impact: "A steadily degrading endpoint keeps passing until it breaches a real user-facing timeout.",
|
|
5451
|
+
fix: `And assert responseTime < 1500`,
|
|
5452
|
+
line: statusLines[0],
|
|
5453
|
+
reference: "https://github.com/karatelabs/karate#responsetime"
|
|
5454
|
+
});
|
|
5455
|
+
if (hasScenario && !/^\s*@[\w-]+/m.test(content))
|
|
5456
|
+
add({
|
|
5457
|
+
ruleId: "KA-STD-003",
|
|
5458
|
+
category: "coding_standards",
|
|
5459
|
+
severity: "info",
|
|
5460
|
+
title: "No tags on Feature or Scenarios",
|
|
5461
|
+
description: "The feature carries no @tags, so scenarios can't be selected or excluded by the runner.",
|
|
5462
|
+
impact: "CI has to run everything, all the time \u2014 no smoke subset, no way to quarantine a known-broken scenario.",
|
|
5463
|
+
fix: `@api @smoke
|
|
5464
|
+
Feature: users API
|
|
5465
|
+
|
|
5466
|
+
@regression
|
|
5467
|
+
Scenario: get a user`,
|
|
5468
|
+
line: lineMatches2(content, /Feature:/)[0] ?? null,
|
|
5469
|
+
reference: "https://github.com/karatelabs/karate#tags"
|
|
5470
|
+
});
|
|
3708
5471
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3709
5472
|
return buildAuditResult({
|
|
3710
5473
|
filename,
|
|
@@ -3837,6 +5600,168 @@ def client():
|
|
|
3837
5600
|
fix: "Resolve or link to an issue.",
|
|
3838
5601
|
line: todo[0]
|
|
3839
5602
|
});
|
|
5603
|
+
const statusLines = lineMatches2(content, /status_code/);
|
|
5604
|
+
const hasNegativePath = /status_code\s*(?:==|!=|in|>=|<)\s*[^\n]*\b[45]\d\d\b|\b[45]\d\d\b\s*==\s*[^\n]*status_code|pytest\.raises\s*\([^)]*(?:HTTPError|HTTPStatusError|ResponseError)/.test(content);
|
|
5605
|
+
if (statusLines.length && !hasNegativePath)
|
|
5606
|
+
add({
|
|
5607
|
+
ruleId: "PYA-AST-003",
|
|
5608
|
+
category: "assertions",
|
|
5609
|
+
severity: "warning",
|
|
5610
|
+
title: "No negative-path coverage",
|
|
5611
|
+
description: `Every status_code assertion expects a 2xx/3xx result (first at line ${statusLines[0]}); no 4xx/5xx case is asserted anywhere.`,
|
|
5612
|
+
impact: "Validation, auth and not-found handling are untested, so the API can start returning 200 for invalid input with the suite still green.",
|
|
5613
|
+
fix: `def test_unknown_user_returns_404(client):
|
|
5614
|
+
resp = client.get("/users/999999")
|
|
5615
|
+
assert resp.status_code == 404
|
|
5616
|
+
assert resp.json()["error"] == "NOT_FOUND"`,
|
|
5617
|
+
line: statusLines[0]
|
|
5618
|
+
});
|
|
5619
|
+
const truthyAssert = lineMatches2(content, /assert\s+[\w.]*\.ok\b|assert\s+(?:resp|response|r)\s*(?:#.*)?$/);
|
|
5620
|
+
if (truthyAssert.length) add({
|
|
5621
|
+
ruleId: "PYA-AST-004",
|
|
5622
|
+
category: "assertions",
|
|
5623
|
+
severity: "warning",
|
|
5624
|
+
title: "Truthiness assertion instead of an explicit status",
|
|
5625
|
+
description: `Line ${truthyAssert[0]} asserts on the response object or .ok rather than a specific status code.`,
|
|
5626
|
+
impact: "`.ok` is true for any 2xx/3xx, and a bare Response object is always truthy \u2014 a 204, a 302 redirect to a login page, or any response at all passes.",
|
|
5627
|
+
fix: `assert resp.status_code == 201`,
|
|
5628
|
+
line: truthyAssert[0]
|
|
5629
|
+
});
|
|
5630
|
+
const noVerify = lineMatches2(content, /verify\s*=\s*False/);
|
|
5631
|
+
if (noVerify.length) add({
|
|
5632
|
+
ruleId: "PYA-SEC-002",
|
|
5633
|
+
category: "security",
|
|
5634
|
+
severity: "critical",
|
|
5635
|
+
title: "TLS verification disabled",
|
|
5636
|
+
description: `verify=False at line ${noVerify[0]} turns off certificate validation for the request.`,
|
|
5637
|
+
impact: "Tests pass against expired, self-signed or intercepted certificates, so a TLS misconfiguration reaches production unnoticed \u2014 and the pattern gets copied into application code.",
|
|
5638
|
+
fix: `resp = client.get("/users/1", timeout=5) # trust the real CA
|
|
5639
|
+
# if an internal CA is needed: verify="/etc/ssl/certs/internal-ca.pem"`,
|
|
5640
|
+
line: noVerify[0],
|
|
5641
|
+
reference: "https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification"
|
|
5642
|
+
});
|
|
5643
|
+
const leakyLog = lineMatches2(content, /(?:print|(?:logger|logging|log|LOG)\.\w+)\s*\([^)]*\b\w*(?:resp|response)\w*\.(?:text|content|headers|json\s*\(\s*\))/i);
|
|
5644
|
+
if (leakyLog.length) add({
|
|
5645
|
+
ruleId: "PYA-SEC-003",
|
|
5646
|
+
category: "security",
|
|
5647
|
+
severity: "warning",
|
|
5648
|
+
title: "Full response body/headers logged",
|
|
5649
|
+
description: `Line ${leakyLog[0]} logs the raw response text, content or headers.`,
|
|
5650
|
+
impact: "Access tokens, Set-Cookie values and PII from the response land in CI logs, which are retained and widely readable.",
|
|
5651
|
+
fix: `logger.debug("GET /users/1 -> %s", resp.status_code) # status only`,
|
|
5652
|
+
line: leakyLog[0]
|
|
5653
|
+
});
|
|
5654
|
+
const ordered = lineMatches2(content, /@pytest\.mark\.(?:dependency|order|run)\b|^\s*global\s+\w+/);
|
|
5655
|
+
if (ordered.length) add({
|
|
5656
|
+
ruleId: "PYA-REL-002",
|
|
5657
|
+
category: "reliability",
|
|
5658
|
+
severity: "warning",
|
|
5659
|
+
title: "Order-dependent / shared mutable state",
|
|
5660
|
+
description: `Line ${ordered[0]} declares an execution-order dependency or mutates a module-level global.`,
|
|
5661
|
+
impact: "Tests can't run in isolation, under -p no:randomly, or with pytest-xdist; one early failure cascades into misleading downstream failures.",
|
|
5662
|
+
fix: `@pytest.fixture
|
|
5663
|
+
def created_user(client):
|
|
5664
|
+
resp = client.post("/users", json=payload)
|
|
5665
|
+
yield resp.json()
|
|
5666
|
+
client.delete(f"/users/{resp.json()['id']}")`,
|
|
5667
|
+
line: ordered[0]
|
|
5668
|
+
});
|
|
5669
|
+
const swallowed = [];
|
|
5670
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5671
|
+
if (/^\s*except\b[^\n]*:\s*(?:pass|\.\.\.)\s*(?:#.*)?$/.test(lines[i])) swallowed.push(i + 1);
|
|
5672
|
+
else if (/^\s*except\b[^\n]*:\s*(?:#.*)?$/.test(lines[i]) && /^\s*(?:pass|\.\.\.)\s*(?:#.*)?$/.test(lines[i + 1] || "")) swallowed.push(i + 1);
|
|
5673
|
+
}
|
|
5674
|
+
if (swallowed.length) add({
|
|
5675
|
+
ruleId: "PYA-REL-003",
|
|
5676
|
+
category: "reliability",
|
|
5677
|
+
severity: "warning",
|
|
5678
|
+
title: "Exception silently swallowed",
|
|
5679
|
+
description: `The except block at line ${swallowed[0]} does nothing but pass.`,
|
|
5680
|
+
impact: "A real connection error, timeout or JSON decode failure is absorbed and the test reports success \u2014 a permanently green, permanently useless test.",
|
|
5681
|
+
fix: `with pytest.raises(httpx.ConnectTimeout):
|
|
5682
|
+
client.get("/slow", timeout=0.001)`,
|
|
5683
|
+
line: swallowed[0]
|
|
5684
|
+
});
|
|
5685
|
+
if (hasTests && !/\.elapsed\b|response_time|perf_counter|time\.monotonic/.test(content))
|
|
5686
|
+
add({
|
|
5687
|
+
ruleId: "PYA-PER-002",
|
|
5688
|
+
category: "performance",
|
|
5689
|
+
severity: "info",
|
|
5690
|
+
title: "Response time / SLA never asserted",
|
|
5691
|
+
description: "No test measures how long a request took (resp.elapsed, perf_counter).",
|
|
5692
|
+
impact: "A steadily degrading endpoint stays green until it breaches a real user-facing timeout.",
|
|
5693
|
+
fix: `assert resp.elapsed.total_seconds() < 1.5`,
|
|
5694
|
+
line: null
|
|
5695
|
+
});
|
|
5696
|
+
if (/\.json\s*\(\)/.test(content) && !/headers\s*\[|\.headers\.get|content[-_]type/i.test(content))
|
|
5697
|
+
add({
|
|
5698
|
+
ruleId: "PYA-VAL-002",
|
|
5699
|
+
category: "validation",
|
|
5700
|
+
severity: "info",
|
|
5701
|
+
title: "Response headers never validated",
|
|
5702
|
+
description: "The body is parsed as JSON but no test checks Content-Type or any other response header.",
|
|
5703
|
+
impact: "A service that starts returning HTML error pages, or drops cache/CORS/security headers, passes unnoticed.",
|
|
5704
|
+
fix: `assert resp.headers["content-type"].startswith("application/json")`,
|
|
5705
|
+
line: lineMatches2(content, /\.json\s*\(\)/)[0]
|
|
5706
|
+
});
|
|
5707
|
+
const directCalls = countMatches2(content, /requests\.(?:get|post|put|delete|patch|head)\s*\(/g);
|
|
5708
|
+
if (directCalls >= 3 && !/requests\.Session\s*\(|httpx\.(?:Client|AsyncClient)\s*\(/.test(content))
|
|
5709
|
+
add({
|
|
5710
|
+
ruleId: "PYA-STR-002",
|
|
5711
|
+
category: "structure",
|
|
5712
|
+
severity: "info",
|
|
5713
|
+
title: "New connection per request (no Session)",
|
|
5714
|
+
description: `${directCalls} module-level requests.* calls with no requests.Session() / httpx.Client().`,
|
|
5715
|
+
impact: "Every call re-does DNS, TCP and the TLS handshake and cannot share auth headers or cookies \u2014 slow suites and duplicated setup.",
|
|
5716
|
+
fix: `@pytest.fixture(scope="session")
|
|
5717
|
+
def client():
|
|
5718
|
+
with requests.Session() as s:
|
|
5719
|
+
s.headers.update({"Authorization": f"Bearer {token}"})
|
|
5720
|
+
yield s`,
|
|
5721
|
+
line: lineMatches2(content, /requests\.(?:get|post|put|delete|patch|head)\s*\(/)[0]
|
|
5722
|
+
});
|
|
5723
|
+
const loginCalls = lineMatches2(content, /\.(?:get|post|request)\s*\([^\n]*["'][^"'\n]*(?:\/login|\/token|\/oauth|\/authenticate|\/signin)\b/i);
|
|
5724
|
+
if (loginCalls.length > 1 && !/@pytest\.fixture\s*\(\s*scope\s*=\s*["'](?:session|module|package)["']/.test(content))
|
|
5725
|
+
add({
|
|
5726
|
+
ruleId: "PYA-STR-003",
|
|
5727
|
+
category: "structure",
|
|
5728
|
+
severity: "warning",
|
|
5729
|
+
title: "Auth token re-fetched per test",
|
|
5730
|
+
description: `${loginCalls.length} authentication calls (first at line ${loginCalls[0]}) with no session/module-scoped fixture caching the token.`,
|
|
5731
|
+
impact: "Every test pays a login round-trip and can trip the identity provider's rate limits, making the suite slow and intermittently 429-flaky.",
|
|
5732
|
+
fix: `@pytest.fixture(scope="session")
|
|
5733
|
+
def token(client):
|
|
5734
|
+
return client.post("/oauth/token", json=creds).json()["access_token"]`,
|
|
5735
|
+
line: loginCalls[0]
|
|
5736
|
+
});
|
|
5737
|
+
const skipped = lineMatches2(content, /@pytest\.mark\.(?:skip|skipif|xfail)\b|pytest\.skip\s*\(/);
|
|
5738
|
+
if (skipped.length) add({
|
|
5739
|
+
ruleId: "PYA-STD-004",
|
|
5740
|
+
category: "coding_standards",
|
|
5741
|
+
severity: "warning",
|
|
5742
|
+
title: "Skipped / xfailed test",
|
|
5743
|
+
description: `Line ${skipped[0]} disables a test with a skip, skipif or xfail marker.`,
|
|
5744
|
+
impact: "The file still looks like it covers the scenario while nothing is actually verified, and muted tests tend to stay muted for good.",
|
|
5745
|
+
fix: `@pytest.mark.skip(reason="PROJ-1234: re-enable once /users/bulk ships")
|
|
5746
|
+
# \u2026or delete the test if the behaviour is gone.`,
|
|
5747
|
+
line: skipped[0]
|
|
5748
|
+
});
|
|
5749
|
+
if (hasTests && !/@pytest\.mark\.\w+/.test(content))
|
|
5750
|
+
add({
|
|
5751
|
+
ruleId: "PYA-STD-005",
|
|
5752
|
+
category: "coding_standards",
|
|
5753
|
+
severity: "info",
|
|
5754
|
+
title: "Tests carry no @pytest.mark marker",
|
|
5755
|
+
description: "No @pytest.mark.* marker appears in this file, so its tests cannot be selected with -m.",
|
|
5756
|
+
impact: "CI runs everything on every commit \u2014 no smoke subset, and a flaky endpoint test can only be excluded by deleting it.",
|
|
5757
|
+
fix: `@pytest.mark.contract
|
|
5758
|
+
def test_user_schema(client):
|
|
5759
|
+
...
|
|
5760
|
+
|
|
5761
|
+
# then: pytest -m contract`,
|
|
5762
|
+
line: lineMatches2(content, /def\s+test_/)[0] ?? null,
|
|
5763
|
+
reference: "https://docs.pytest.org/en/stable/example/markers.html"
|
|
5764
|
+
});
|
|
3840
5765
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3841
5766
|
return buildAuditResult({
|
|
3842
5767
|
filename,
|
|
@@ -3926,6 +5851,126 @@ function analysePostmanLocally(filename, content, options = {}) {
|
|
|
3926
5851
|
fix: "Remove debug logging before committing.",
|
|
3927
5852
|
line: clog[0]
|
|
3928
5853
|
});
|
|
5854
|
+
const requestCount = countMatches2(content, /"request"\s*:/g);
|
|
5855
|
+
const testCount = countMatches2(content, /pm\.test\s*\(/g);
|
|
5856
|
+
if (/pm\.response\.to\.have\.status|pm\.response\.code|responseCode\.code/.test(content) && !/pm\.response\.json\s*\(/.test(content))
|
|
5857
|
+
add({
|
|
5858
|
+
ruleId: "PM-AST-003",
|
|
5859
|
+
category: "assertions",
|
|
5860
|
+
severity: "info",
|
|
5861
|
+
title: "Status asserted but body never read",
|
|
5862
|
+
description: "Tests check the HTTP status code but never call pm.response.json() to inspect the payload.",
|
|
5863
|
+
impact: "An endpoint that returns 200 with an empty, malformed or error-shaped body passes every test.",
|
|
5864
|
+
fix: `pm.test("returns the requested user", () => {
|
|
5865
|
+
const body = pm.response.json();
|
|
5866
|
+
pm.expect(body.id).to.eql(1);
|
|
5867
|
+
pm.expect(body).to.have.property("email");
|
|
5868
|
+
});`,
|
|
5869
|
+
line: lineMatches2(content, /pm\.response\.to\.have\.status|pm\.response\.code/)[0],
|
|
5870
|
+
reference: "https://learning.postman.com/docs/tests-and-scripts/write-scripts/test-examples/"
|
|
5871
|
+
});
|
|
5872
|
+
const anyStatusAssert = /\.to\.have\.status\s*\(\s*\d{3}|(?:pm\.response\.code|responseCode\.code)\s*[=!]==?\s*\d{3}|\.code\s*\)?\s*\.to\.eql\s*\(\s*\d{3}/.test(content);
|
|
5873
|
+
const negStatusAssert = /\.to\.have\.status\s*\(\s*[45]\d\d|(?:pm\.response\.code|responseCode\.code)\s*[=!]==?\s*[45]\d\d|\.code\s*\)?\s*\.to\.eql\s*\(\s*[45]\d\d|\.to\.be\.oneOf\s*\(\s*\[[^\]]*[45]\d\d/.test(content);
|
|
5874
|
+
if (anyStatusAssert && !negStatusAssert)
|
|
5875
|
+
add({
|
|
5876
|
+
ruleId: "PM-AST-004",
|
|
5877
|
+
category: "assertions",
|
|
5878
|
+
severity: "warning",
|
|
5879
|
+
title: "No negative-path coverage",
|
|
5880
|
+
description: "Every status assertion in the collection expects a 2xx/3xx code; nothing asserts a 4xx or 5xx response.",
|
|
5881
|
+
impact: "Auth rejection, validation errors and not-found handling are untested, so the API can silently start accepting invalid requests.",
|
|
5882
|
+
fix: `pm.test("unknown user returns 404", () => {
|
|
5883
|
+
pm.response.to.have.status(404);
|
|
5884
|
+
pm.expect(pm.response.json().error).to.eql("NOT_FOUND");
|
|
5885
|
+
});`,
|
|
5886
|
+
line: lineMatches2(content, /\.to\.have\.status\s*\(/)[0]
|
|
5887
|
+
});
|
|
5888
|
+
if (requestCount > 0 && testCount > 0 && testCount < requestCount)
|
|
5889
|
+
add({
|
|
5890
|
+
ruleId: "PM-AST-005",
|
|
5891
|
+
category: "assertions",
|
|
5892
|
+
severity: "warning",
|
|
5893
|
+
title: "Requests outnumber test scripts",
|
|
5894
|
+
description: `The collection has ${requestCount} request(s) but only ${testCount} pm.test(...) block(s), so some requests are executed with nothing asserted.`,
|
|
5895
|
+
impact: "Newman reports those requests as 'passed' purely because they were sent \u2014 regressions on the untested endpoints are invisible.",
|
|
5896
|
+
fix: `// Add at least one pm.test to every request's Tests tab:
|
|
5897
|
+
pm.test("status is 200", () => pm.response.to.have.status(200));`,
|
|
5898
|
+
line: lineMatches2(content, /"request"\s*:/)[0]
|
|
5899
|
+
});
|
|
5900
|
+
const envSecret = lineMatches2(content, /pm\.(?:environment|globals|collectionVariables)\.set\s*\(\s*\\?["'][^"'\\]*(?:token|secret|password|apikey|api_key|credential)/i);
|
|
5901
|
+
if (envSecret.length) add({
|
|
5902
|
+
ruleId: "PM-SEC-002",
|
|
5903
|
+
category: "security",
|
|
5904
|
+
severity: "warning",
|
|
5905
|
+
title: "Secret written into a persisted variable",
|
|
5906
|
+
description: `A script at line ${envSecret[0]} stores a credential with pm.environment.set / pm.globals.set.`,
|
|
5907
|
+
impact: "Postman persists these to the environment/globals file, which is routinely exported, synced and committed \u2014 the live token leaves the process.",
|
|
5908
|
+
fix: `// Keep it in memory for this run only:
|
|
5909
|
+
pm.variables.set("authToken", pm.response.json().access_token);`,
|
|
5910
|
+
line: envSecret[0],
|
|
5911
|
+
reference: "https://learning.postman.com/docs/sending-requests/variables/variables/"
|
|
5912
|
+
});
|
|
5913
|
+
const evals = lineMatches2(content, /(?:^|[^\w.$])eval\s*\(/);
|
|
5914
|
+
if (evals.length) add({
|
|
5915
|
+
ruleId: "PM-SEC-003",
|
|
5916
|
+
category: "security",
|
|
5917
|
+
severity: "critical",
|
|
5918
|
+
title: "eval() in a collection script",
|
|
5919
|
+
description: `A pre-request or test script calls eval() at line ${evals[0]}.`,
|
|
5920
|
+
impact: "Any response or variable that reaches that call becomes executable code on the runner \u2014 a compromised or spoofed API can run arbitrary commands in CI.",
|
|
5921
|
+
fix: `// Parse instead of executing:
|
|
5922
|
+
const payload = pm.response.json();`,
|
|
5923
|
+
line: evals[0],
|
|
5924
|
+
reference: "https://learning.postman.com/docs/tests-and-scripts/write-scripts/postman-sandbox-api/"
|
|
5925
|
+
});
|
|
5926
|
+
const sslOff = lineMatches2(content, /"strictSSL"\s*:\s*false|"insecureHTTPParser"\s*:\s*true|"disabledSystemHeaders"\s*:\s*\{\s*"host"\s*:\s*true/);
|
|
5927
|
+
if (sslOff.length) add({
|
|
5928
|
+
ruleId: "PM-SEC-004",
|
|
5929
|
+
category: "security",
|
|
5930
|
+
severity: "warning",
|
|
5931
|
+
title: "TLS verification disabled in the collection",
|
|
5932
|
+
description: `protocolProfileBehavior turns off certificate validation at line ${sslOff[0]}.`,
|
|
5933
|
+
impact: "Requests succeed against expired, self-signed or intercepted certificates, so the suite can never catch a TLS misconfiguration.",
|
|
5934
|
+
fix: `Remove "strictSSL": false and trust the environment's real CA instead.`,
|
|
5935
|
+
line: sslOff[0],
|
|
5936
|
+
reference: "https://learning.postman.com/docs/sending-requests/requests/"
|
|
5937
|
+
});
|
|
5938
|
+
if (hasRequests && !/"auth"\s*:/.test(content))
|
|
5939
|
+
add({
|
|
5940
|
+
ruleId: "PM-SEC-005",
|
|
5941
|
+
category: "security",
|
|
5942
|
+
severity: "info",
|
|
5943
|
+
title: "No auth defined anywhere in the collection",
|
|
5944
|
+
description: 'Neither the collection nor any request declares an "auth" block, so authentication is presumably pasted into raw headers or missing entirely.',
|
|
5945
|
+
impact: "Auth cannot be rotated or switched per environment in one place, and requests drift toward hand-written Authorization headers with literal tokens.",
|
|
5946
|
+
fix: `"auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{authToken}}", "type": "string" }] }`,
|
|
5947
|
+
line: lineMatches2(content, /"request"\s*:/)[0],
|
|
5948
|
+
reference: "https://learning.postman.com/docs/sending-requests/authorization/authorization/"
|
|
5949
|
+
});
|
|
5950
|
+
const localHost = lineMatches2(content, /"(?:raw|host)"\s*:\s*(?:\[\s*)?"(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\d{1,3}(?:\.\d{1,3}){3})/).concat(lineMatches2(content, /^\s*"(?:localhost|127\.0\.0\.1|0\.0\.0\.0)"\s*,?\s*$/));
|
|
5951
|
+
if (localHost.length) add({
|
|
5952
|
+
ruleId: "PM-CFG-002",
|
|
5953
|
+
category: "ci_config",
|
|
5954
|
+
severity: "warning",
|
|
5955
|
+
title: "Request pinned to localhost / a raw IP",
|
|
5956
|
+
description: `A request URL targets a machine-local or literal-IP host at line ${localHost[0]}.`,
|
|
5957
|
+
impact: "The collection only works on the author's laptop \u2014 in CI or on a teammate's machine the run fails with connection refused.",
|
|
5958
|
+
fix: `Use {{baseUrl}}/users/1 and set baseUrl per environment (http://localhost:3000 locally, the real host in CI).`,
|
|
5959
|
+
line: localHost[0]
|
|
5960
|
+
});
|
|
5961
|
+
const legacyApi = lineMatches2(content, /tests\s*\[\s*\\?["']|responseCode\.code|postman\.setEnvironmentVariable|postman\.setGlobalVariable/);
|
|
5962
|
+
if (legacyApi.length) add({
|
|
5963
|
+
ruleId: "PM-STD-002",
|
|
5964
|
+
category: "coding_standards",
|
|
5965
|
+
severity: "info",
|
|
5966
|
+
title: "Deprecated Postman sandbox API",
|
|
5967
|
+
description: `Line ${legacyApi[0]} uses the pre-v2 sandbox (tests[...], responseCode.code, postman.setEnvironmentVariable).`,
|
|
5968
|
+
impact: "These globals are deprecated, produce no per-assertion reporting in Newman, and will break on a future sandbox upgrade.",
|
|
5969
|
+
fix: `pm.test("status is 200", () => pm.response.to.have.status(200));
|
|
5970
|
+
pm.environment.set("userId", pm.response.json().id);`,
|
|
5971
|
+
line: legacyApi[0],
|
|
5972
|
+
reference: "https://learning.postman.com/docs/tests-and-scripts/write-scripts/postman-sandbox-api/"
|
|
5973
|
+
});
|
|
3929
5974
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
3930
5975
|
return buildAuditResult({
|
|
3931
5976
|
filename,
|
|
@@ -4404,6 +6449,179 @@ WebDriverWait wait = new WebDriverWait(driver, DEFAULT_TIMEOUT);`,
|
|
|
4404
6449
|
line: magicTimeout[0],
|
|
4405
6450
|
reference: "Team standards"
|
|
4406
6451
|
}, disabledRuleIds);
|
|
6452
|
+
const jsClick = lineMatches2(content, /JavascriptExecutor[\s\S]{0,40}?executeScript\s*\(\s*["']arguments\[0\]\.click|executeScript\s*\(\s*["']arguments\[0\]\.click/);
|
|
6453
|
+
if (jsClick.length) pushFinding(findings, {
|
|
6454
|
+
ruleId: "SEL-J-REL-005",
|
|
6455
|
+
category: "reliability",
|
|
6456
|
+
severity: "warning",
|
|
6457
|
+
title: "JavascriptExecutor click instead of a native click",
|
|
6458
|
+
description: `Line ${jsClick[0]} clicks via executeScript rather than WebElement.click().`,
|
|
6459
|
+
impact: "A JS click fires even when the element is covered, disabled, or off-screen \u2014 so the test passes on a page a real user could not operate.",
|
|
6460
|
+
fix: `// Wait for real actionability instead of forcing the click
|
|
6461
|
+
new WebDriverWait(driver, Duration.ofSeconds(10))
|
|
6462
|
+
.until(ExpectedConditions.elementToBeClickable(locator))
|
|
6463
|
+
.click();`,
|
|
6464
|
+
line: jsClick[0],
|
|
6465
|
+
reference: "https://www.selenium.dev/documentation/webdriver/elements/interactions/"
|
|
6466
|
+
}, disabledRuleIds);
|
|
6467
|
+
const actionsNoPerform = [];
|
|
6468
|
+
lines.forEach((line, idx) => {
|
|
6469
|
+
if (!/new\s+Actions\s*\(|\bactions\s*\./.test(line)) return;
|
|
6470
|
+
const window = lines.slice(idx, idx + 4).join(" ");
|
|
6471
|
+
if (!/\.perform\s*\(\s*\)|\.build\s*\(\s*\)\s*\.perform/.test(window)) actionsNoPerform.push(idx + 1);
|
|
6472
|
+
});
|
|
6473
|
+
if (actionsNoPerform.length) pushFinding(findings, {
|
|
6474
|
+
ruleId: "SEL-J-REL-006",
|
|
6475
|
+
category: "reliability",
|
|
6476
|
+
severity: "critical",
|
|
6477
|
+
title: "Actions chain never executed (missing perform())",
|
|
6478
|
+
description: `An Actions sequence at line ${actionsNoPerform[0]} is built but .perform() is never called on it.`,
|
|
6479
|
+
impact: "The interaction silently does nothing, so the assertion after it tests the un-interacted page \u2014 a test that passes while covering nothing.",
|
|
6480
|
+
fix: `new Actions(driver)
|
|
6481
|
+
.moveToElement(menu)
|
|
6482
|
+
.click(item)
|
|
6483
|
+
.perform(); // <- required`,
|
|
6484
|
+
line: actionsNoPerform[0],
|
|
6485
|
+
reference: "https://www.selenium.dev/documentation/webdriver/actions_api/"
|
|
6486
|
+
}, disabledRuleIds);
|
|
6487
|
+
const frameIn = countMatches2(content, /switchTo\s*\(\s*\)\s*\.frame\s*\(/g);
|
|
6488
|
+
const frameOut = countMatches2(content, /switchTo\s*\(\s*\)\s*\.defaultContent\s*\(|switchTo\s*\(\s*\)\s*\.parentFrame\s*\(/g);
|
|
6489
|
+
if (frameIn > frameOut) pushFinding(findings, {
|
|
6490
|
+
ruleId: "SEL-J-REL-007",
|
|
6491
|
+
category: "reliability",
|
|
6492
|
+
severity: "warning",
|
|
6493
|
+
title: "Frame entered but never exited",
|
|
6494
|
+
description: `switchTo().frame() is called ${frameIn} time(s) but defaultContent()/parentFrame() only ${frameOut}.`,
|
|
6495
|
+
impact: "The driver stays scoped to the iframe, so every later findElement looks in the wrong document and fails with NoSuchElement.",
|
|
6496
|
+
fix: `driver.switchTo().frame("payment");
|
|
6497
|
+
// ... interact inside the frame ...
|
|
6498
|
+
driver.switchTo().defaultContent();`,
|
|
6499
|
+
line: lineMatches2(content, /switchTo\s*\(\s*\)\s*\.frame\s*\(/)[0] ?? null
|
|
6500
|
+
}, disabledRuleIds);
|
|
6501
|
+
const sendKeysNoClear = lineMatches2(content, /\.sendKeys\s*\(/).filter((ln) => {
|
|
6502
|
+
const prev = lines.slice(Math.max(0, ln - 3), ln).join(" ");
|
|
6503
|
+
return !/\.clear\s*\(\s*\)/.test(prev) && !/Keys\.(?:ENTER|TAB|RETURN|ESCAPE)/.test(lines[ln - 1] || "");
|
|
6504
|
+
});
|
|
6505
|
+
if (sendKeysNoClear.length) pushFinding(findings, {
|
|
6506
|
+
ruleId: "SEL-J-REL-008",
|
|
6507
|
+
category: "reliability",
|
|
6508
|
+
severity: "info",
|
|
6509
|
+
title: "sendKeys() without clear()",
|
|
6510
|
+
description: `Line ${sendKeysNoClear[0]} types into a field without clearing it first.`,
|
|
6511
|
+
impact: "Autofilled or retained values are appended rather than replaced, producing values like 'oldnew' on reruns.",
|
|
6512
|
+
fix: `WebElement email = driver.findElement(emailLocator);
|
|
6513
|
+
email.clear();
|
|
6514
|
+
email.sendKeys("user@example.com");`,
|
|
6515
|
+
line: sendKeysNoClear[0]
|
|
6516
|
+
}, disabledRuleIds);
|
|
6517
|
+
const pageSourceAssert = lineMatches2(content, /getPageSource\s*\(\s*\)\s*\.contains\s*\(/);
|
|
6518
|
+
if (pageSourceAssert.length) pushFinding(findings, {
|
|
6519
|
+
ruleId: "SEL-J-ASS-005",
|
|
6520
|
+
category: "assertions",
|
|
6521
|
+
severity: "warning",
|
|
6522
|
+
title: "Assertion against getPageSource()",
|
|
6523
|
+
description: `Line ${pageSourceAssert[0]} asserts on raw page HTML with a substring check.`,
|
|
6524
|
+
impact: "Matches text in hidden nodes, script blocks and attributes, so it passes when the user can see nothing \u2014 and breaks on unrelated markup changes.",
|
|
6525
|
+
fix: `WebElement banner = wait.until(
|
|
6526
|
+
ExpectedConditions.visibilityOfElementLocated(By.cssSelector("[data-testid='success']")));
|
|
6527
|
+
assertEquals("Payment complete", banner.getText());`,
|
|
6528
|
+
line: pageSourceAssert[0]
|
|
6529
|
+
}, disabledRuleIds);
|
|
6530
|
+
const driverProp = lineMatches2(content, /System\.setProperty\s*\(\s*["']webdriver\./);
|
|
6531
|
+
if (driverProp.length) pushFinding(findings, {
|
|
6532
|
+
ruleId: "SEL-J-CFG-001",
|
|
6533
|
+
category: "browser_mgmt",
|
|
6534
|
+
severity: "warning",
|
|
6535
|
+
title: "Manual driver binary path (Selenium Manager makes this obsolete)",
|
|
6536
|
+
description: `Line ${driverProp[0]} sets a webdriver.* system property to a local binary path.`,
|
|
6537
|
+
impact: "The path is machine-specific, so the suite fails on CI and on any teammate's machine, and the binary drifts out of sync with the browser.",
|
|
6538
|
+
fix: `// Selenium 4.6+ resolves the driver automatically \u2014 delete the setProperty line
|
|
6539
|
+
WebDriver driver = new ChromeDriver();`,
|
|
6540
|
+
line: driverProp[0],
|
|
6541
|
+
reference: "https://www.selenium.dev/documentation/selenium_manager/"
|
|
6542
|
+
}, disabledRuleIds);
|
|
6543
|
+
const desiredCaps = lineMatches2(content, /DesiredCapabilities|\.merge\s*\(\s*capabilities|new\s+ChromeDriver\s*\(\s*capabilities\s*\)/);
|
|
6544
|
+
if (desiredCaps.length) pushFinding(findings, {
|
|
6545
|
+
ruleId: "SEL-J-CFG-002",
|
|
6546
|
+
category: "browser_mgmt",
|
|
6547
|
+
severity: "critical",
|
|
6548
|
+
title: "DesiredCapabilities \u2014 removed in Selenium 4",
|
|
6549
|
+
description: `Line ${desiredCaps[0]} uses DesiredCapabilities, which Selenium 4 removed in favour of browser-specific Options classes.`,
|
|
6550
|
+
impact: "The code will not compile or run against Selenium 4, blocking the upgrade.",
|
|
6551
|
+
fix: `ChromeOptions options = new ChromeOptions();
|
|
6552
|
+
options.addArguments("--headless=new");
|
|
6553
|
+
options.setAcceptInsecureCerts(true);
|
|
6554
|
+
WebDriver driver = new ChromeDriver(options);`,
|
|
6555
|
+
line: desiredCaps[0],
|
|
6556
|
+
reference: "https://www.selenium.dev/documentation/webdriver/getting_started/upgrade_to_selenium_4/"
|
|
6557
|
+
}, disabledRuleIds);
|
|
6558
|
+
const hasImplicit = /implicitlyWait/.test(content);
|
|
6559
|
+
const hasExplicit = /WebDriverWait|FluentWait/.test(content);
|
|
6560
|
+
if (hasImplicit && hasExplicit) pushFinding(findings, {
|
|
6561
|
+
ruleId: "SEL-J-WAI-005",
|
|
6562
|
+
category: "waits",
|
|
6563
|
+
severity: "critical",
|
|
6564
|
+
title: "Implicit and explicit waits mixed",
|
|
6565
|
+
description: "The file configures an implicit wait and also uses WebDriverWait/FluentWait.",
|
|
6566
|
+
impact: "Selenium documents this combination as producing unpredictable wait times \u2014 a 10s explicit wait can block far longer, and negative conditions like invisibility become unreliable.",
|
|
6567
|
+
fix: `// Drop the implicit wait entirely and rely on explicit waits
|
|
6568
|
+
// driver.manage().timeouts().implicitlyWait(...); <- remove
|
|
6569
|
+
new WebDriverWait(driver, Duration.ofSeconds(10))
|
|
6570
|
+
.until(ExpectedConditions.visibilityOfElementLocated(locator));`,
|
|
6571
|
+
line: lineMatches2(content, /implicitlyWait/)[0] ?? null,
|
|
6572
|
+
reference: "https://www.selenium.dev/documentation/webdriver/waits/#implicit-wait"
|
|
6573
|
+
}, disabledRuleIds);
|
|
6574
|
+
const swallowedDisplayed = [];
|
|
6575
|
+
lines.forEach((line, idx) => {
|
|
6576
|
+
if (!/try\s*\{/.test(line)) return;
|
|
6577
|
+
const window = lines.slice(idx, idx + 6).join(" ");
|
|
6578
|
+
if (/isDisplayed\s*\(\s*\)|isEnabled\s*\(\s*\)/.test(window) && /catch\s*\(/.test(window) && /return\s+false|;\s*\}/.test(window)) {
|
|
6579
|
+
swallowedDisplayed.push(idx + 1);
|
|
6580
|
+
}
|
|
6581
|
+
});
|
|
6582
|
+
if (swallowedDisplayed.length) pushFinding(findings, {
|
|
6583
|
+
ruleId: "SEL-J-REL-009",
|
|
6584
|
+
category: "reliability",
|
|
6585
|
+
severity: "warning",
|
|
6586
|
+
title: "isDisplayed() wrapped in try/catch as an existence check",
|
|
6587
|
+
description: `Line ${swallowedDisplayed[0]} swallows an exception to decide whether an element is present.`,
|
|
6588
|
+
impact: "A genuine failure \u2014 wrong page, timeout, crashed driver \u2014 is indistinguishable from 'not present', so the test skips its real verification and still passes.",
|
|
6589
|
+
fix: `// Ask the driver directly instead of catching
|
|
6590
|
+
boolean present = !driver.findElements(locator).isEmpty();`,
|
|
6591
|
+
line: swallowedDisplayed[0]
|
|
6592
|
+
}, disabledRuleIds);
|
|
6593
|
+
const chainedFind = lineMatches2(content, /findElement\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*findElement\s*\(/);
|
|
6594
|
+
if (chainedFind.length) pushFinding(findings, {
|
|
6595
|
+
ruleId: "SEL-J-LOC-006",
|
|
6596
|
+
category: "locators",
|
|
6597
|
+
severity: "info",
|
|
6598
|
+
title: "Chained findElement() calls",
|
|
6599
|
+
description: `Line ${chainedFind[0]} chains findElement into another findElement.`,
|
|
6600
|
+
impact: "Each hop is a separate round trip that can go stale mid-chain, and the locator now encodes two levels of DOM structure.",
|
|
6601
|
+
fix: `// One locator scoped with a CSS descendant selector
|
|
6602
|
+
driver.findElement(By.cssSelector("[data-testid='cart'] .line-item__price"));`,
|
|
6603
|
+
line: chainedFind[0]
|
|
6604
|
+
}, disabledRuleIds);
|
|
6605
|
+
{
|
|
6606
|
+
const hasJUnitTag = /@Tag\s*\(|@Category\s*\(/.test(content);
|
|
6607
|
+
const hasTestNgGroup = /groups\s*=\s*[{"']/.test(content);
|
|
6608
|
+
if (hasTests && !hasJUnitTag && !hasTestNgGroup) pushFinding(findings, {
|
|
6609
|
+
ruleId: "SEL-J-STD-005",
|
|
6610
|
+
category: "coding_standards",
|
|
6611
|
+
severity: "info",
|
|
6612
|
+
title: "Tests carry no @Tag or TestNG group",
|
|
6613
|
+
description: "No @Tag (JUnit 5), @Category (JUnit 4) or groups= (TestNG) appears in this file, so its tests cannot be selected by the runner.",
|
|
6614
|
+
impact: "CI must run the whole suite every time \u2014 no smoke subset, no way to quarantine a flaky test without deleting or commenting it out.",
|
|
6615
|
+
fix: `@Test
|
|
6616
|
+
@Tag("smoke")
|
|
6617
|
+
@Tag("checkout")
|
|
6618
|
+
void completesCheckout() { }
|
|
6619
|
+
|
|
6620
|
+
// then: mvn test -Dgroups=smoke`,
|
|
6621
|
+
line: lineMatches2(content, /@Test\b/)[0] ?? null,
|
|
6622
|
+
reference: "https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering"
|
|
6623
|
+
}, disabledRuleIds);
|
|
6624
|
+
}
|
|
4407
6625
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
4408
6626
|
const summary = crit > 0 ? `Selenium (Java) scan of ${filename}: ${findings.length} finding(s), ${crit} critical.` : `Selenium (Java) scan of ${filename}: ${findings.length} finding(s) from standard rules.`;
|
|
4409
6627
|
return buildAuditResult({
|
|
@@ -4873,6 +7091,175 @@ var wait = new WebDriverWait(driver, DefaultTimeout);`,
|
|
|
4873
7091
|
line: magicTimeout[0],
|
|
4874
7092
|
reference: "Team standards"
|
|
4875
7093
|
}, disabledRuleIds);
|
|
7094
|
+
const jsClick = lineMatches2(content, /ExecuteScript\s*\(\s*["']arguments\[0\]\.click/i);
|
|
7095
|
+
if (jsClick.length) pushFinding(findings, {
|
|
7096
|
+
ruleId: "SEL-CS-REL-005",
|
|
7097
|
+
category: "reliability",
|
|
7098
|
+
severity: "warning",
|
|
7099
|
+
title: "ExecuteScript click instead of a native click",
|
|
7100
|
+
description: `Line ${jsClick[0]} clicks via ExecuteScript rather than IWebElement.Click().`,
|
|
7101
|
+
impact: "A JS click fires even when the element is covered, disabled, or off-screen \u2014 so the test passes on a page a real user could not operate.",
|
|
7102
|
+
fix: `var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
|
|
7103
|
+
wait.Until(ExpectedConditions.ElementToBeClickable(locator)).Click();`,
|
|
7104
|
+
line: jsClick[0],
|
|
7105
|
+
reference: "https://www.selenium.dev/documentation/webdriver/elements/interactions/"
|
|
7106
|
+
}, disabledRuleIds);
|
|
7107
|
+
const actionsNoPerform = [];
|
|
7108
|
+
lines.forEach((line, idx) => {
|
|
7109
|
+
if (!/new\s+Actions\s*\(|\bactions\s*\./i.test(line)) return;
|
|
7110
|
+
const window = lines.slice(idx, idx + 4).join(" ");
|
|
7111
|
+
if (!/\.Perform\s*\(\s*\)|\.Build\s*\(\s*\)\s*\.Perform/i.test(window)) actionsNoPerform.push(idx + 1);
|
|
7112
|
+
});
|
|
7113
|
+
if (actionsNoPerform.length) pushFinding(findings, {
|
|
7114
|
+
ruleId: "SEL-CS-REL-006",
|
|
7115
|
+
category: "reliability",
|
|
7116
|
+
severity: "critical",
|
|
7117
|
+
title: "Actions chain never executed (missing Perform())",
|
|
7118
|
+
description: `An Actions sequence at line ${actionsNoPerform[0]} is built but .Perform() is never called on it.`,
|
|
7119
|
+
impact: "The interaction silently does nothing, so the assertion after it tests the un-interacted page \u2014 a test that passes while covering nothing.",
|
|
7120
|
+
fix: `new Actions(driver)
|
|
7121
|
+
.MoveToElement(menu)
|
|
7122
|
+
.Click(item)
|
|
7123
|
+
.Perform(); // <- required`,
|
|
7124
|
+
line: actionsNoPerform[0],
|
|
7125
|
+
reference: "https://www.selenium.dev/documentation/webdriver/actions_api/"
|
|
7126
|
+
}, disabledRuleIds);
|
|
7127
|
+
const frameIn = countMatches2(content, /SwitchTo\s*\(\s*\)\s*\.Frame\s*\(/gi);
|
|
7128
|
+
const frameOut = countMatches2(content, /SwitchTo\s*\(\s*\)\s*\.DefaultContent\s*\(|SwitchTo\s*\(\s*\)\s*\.ParentFrame\s*\(/gi);
|
|
7129
|
+
if (frameIn > frameOut) pushFinding(findings, {
|
|
7130
|
+
ruleId: "SEL-CS-REL-007",
|
|
7131
|
+
category: "reliability",
|
|
7132
|
+
severity: "warning",
|
|
7133
|
+
title: "Frame entered but never exited",
|
|
7134
|
+
description: `SwitchTo().Frame() is called ${frameIn} time(s) but DefaultContent()/ParentFrame() only ${frameOut}.`,
|
|
7135
|
+
impact: "The driver stays scoped to the iframe, so every later FindElement looks in the wrong document and fails with NoSuchElement.",
|
|
7136
|
+
fix: `driver.SwitchTo().Frame("payment");
|
|
7137
|
+
// ... interact inside the frame ...
|
|
7138
|
+
driver.SwitchTo().DefaultContent();`,
|
|
7139
|
+
line: lineMatches2(content, /SwitchTo\s*\(\s*\)\s*\.Frame\s*\(/i)[0] ?? null
|
|
7140
|
+
}, disabledRuleIds);
|
|
7141
|
+
const sendKeysNoClear = lineMatches2(content, /\.SendKeys\s*\(/i).filter((ln) => {
|
|
7142
|
+
const prev = lines.slice(Math.max(0, ln - 3), ln).join(" ");
|
|
7143
|
+
return !/\.Clear\s*\(\s*\)/i.test(prev) && !/Keys\.(?:Enter|Tab|Return|Escape)/i.test(lines[ln - 1] || "");
|
|
7144
|
+
});
|
|
7145
|
+
if (sendKeysNoClear.length) pushFinding(findings, {
|
|
7146
|
+
ruleId: "SEL-CS-REL-008",
|
|
7147
|
+
category: "reliability",
|
|
7148
|
+
severity: "info",
|
|
7149
|
+
title: "SendKeys() without Clear()",
|
|
7150
|
+
description: `Line ${sendKeysNoClear[0]} types into a field without clearing it first.`,
|
|
7151
|
+
impact: "Autofilled or retained values are appended rather than replaced, producing values like 'oldnew' on reruns.",
|
|
7152
|
+
fix: `var email = driver.FindElement(emailLocator);
|
|
7153
|
+
email.Clear();
|
|
7154
|
+
email.SendKeys("user@example.com");`,
|
|
7155
|
+
line: sendKeysNoClear[0]
|
|
7156
|
+
}, disabledRuleIds);
|
|
7157
|
+
const pageSourceAssert = lineMatches2(content, /PageSource\s*\.\s*Contains\s*\(/i);
|
|
7158
|
+
if (pageSourceAssert.length) pushFinding(findings, {
|
|
7159
|
+
ruleId: "SEL-CS-ASS-005",
|
|
7160
|
+
category: "assertions",
|
|
7161
|
+
severity: "warning",
|
|
7162
|
+
title: "Assertion against PageSource",
|
|
7163
|
+
description: `Line ${pageSourceAssert[0]} asserts on raw page HTML with a substring check.`,
|
|
7164
|
+
impact: "Matches text in hidden nodes, script blocks and attributes, so it passes when the user can see nothing \u2014 and breaks on unrelated markup changes.",
|
|
7165
|
+
fix: `var banner = wait.Until(
|
|
7166
|
+
ExpectedConditions.ElementIsVisible(By.CssSelector("[data-testid='success']")));
|
|
7167
|
+
Assert.AreEqual("Payment complete", banner.Text);`,
|
|
7168
|
+
line: pageSourceAssert[0]
|
|
7169
|
+
}, disabledRuleIds);
|
|
7170
|
+
const driverPath = lineMatches2(content, /new\s+(?:Chrome|Firefox|Edge)Driver\s*\(\s*["'][A-Za-z]:[\\/]|new\s+(?:Chrome|Firefox|Edge)DriverService|DriverService\.Create/i);
|
|
7171
|
+
if (driverPath.length) pushFinding(findings, {
|
|
7172
|
+
ruleId: "SEL-CS-CFG-001",
|
|
7173
|
+
category: "browser_mgmt",
|
|
7174
|
+
severity: "warning",
|
|
7175
|
+
title: "Manual driver binary path (Selenium Manager makes this obsolete)",
|
|
7176
|
+
description: `Line ${driverPath[0]} points the driver at an explicit binary path or DriverService.`,
|
|
7177
|
+
impact: "The path is machine-specific, so the suite fails on CI and on any teammate's machine, and the binary drifts out of sync with the browser.",
|
|
7178
|
+
fix: `// Selenium 4.6+ resolves the driver automatically
|
|
7179
|
+
IWebDriver driver = new ChromeDriver();`,
|
|
7180
|
+
line: driverPath[0],
|
|
7181
|
+
reference: "https://www.selenium.dev/documentation/selenium_manager/"
|
|
7182
|
+
}, disabledRuleIds);
|
|
7183
|
+
const desiredCaps = lineMatches2(content, /DesiredCapabilities/i);
|
|
7184
|
+
if (desiredCaps.length) pushFinding(findings, {
|
|
7185
|
+
ruleId: "SEL-CS-CFG-002",
|
|
7186
|
+
category: "browser_mgmt",
|
|
7187
|
+
severity: "critical",
|
|
7188
|
+
title: "DesiredCapabilities \u2014 removed in Selenium 4",
|
|
7189
|
+
description: `Line ${desiredCaps[0]} uses DesiredCapabilities, which Selenium 4 removed in favour of browser-specific Options classes.`,
|
|
7190
|
+
impact: "The code will not compile or run against Selenium 4, blocking the upgrade.",
|
|
7191
|
+
fix: `var options = new ChromeOptions();
|
|
7192
|
+
options.AddArgument("--headless=new");
|
|
7193
|
+
options.AcceptInsecureCertificates = true;
|
|
7194
|
+
IWebDriver driver = new ChromeDriver(options);`,
|
|
7195
|
+
line: desiredCaps[0],
|
|
7196
|
+
reference: "https://www.selenium.dev/documentation/webdriver/getting_started/upgrade_to_selenium_4/"
|
|
7197
|
+
}, disabledRuleIds);
|
|
7198
|
+
const hasImplicit = /ImplicitWait/i.test(content);
|
|
7199
|
+
const hasExplicit = /WebDriverWait|DefaultWait/i.test(content);
|
|
7200
|
+
if (hasImplicit && hasExplicit) pushFinding(findings, {
|
|
7201
|
+
ruleId: "SEL-CS-WAI-005",
|
|
7202
|
+
category: "waits",
|
|
7203
|
+
severity: "critical",
|
|
7204
|
+
title: "Implicit and explicit waits mixed",
|
|
7205
|
+
description: "The file configures an implicit wait and also uses WebDriverWait/DefaultWait.",
|
|
7206
|
+
impact: "Selenium documents this combination as producing unpredictable wait times \u2014 a 10s explicit wait can block far longer, and negative conditions like invisibility become unreliable.",
|
|
7207
|
+
fix: `// Drop the implicit wait entirely and rely on explicit waits
|
|
7208
|
+
// driver.Manage().Timeouts().ImplicitWait = ...; <- remove
|
|
7209
|
+
new WebDriverWait(driver, TimeSpan.FromSeconds(10))
|
|
7210
|
+
.Until(ExpectedConditions.ElementIsVisible(locator));`,
|
|
7211
|
+
line: lineMatches2(content, /ImplicitWait/i)[0] ?? null,
|
|
7212
|
+
reference: "https://www.selenium.dev/documentation/webdriver/waits/#implicit-wait"
|
|
7213
|
+
}, disabledRuleIds);
|
|
7214
|
+
const swallowedDisplayed = [];
|
|
7215
|
+
lines.forEach((line, idx) => {
|
|
7216
|
+
if (!/try\s*\{/.test(line)) return;
|
|
7217
|
+
const window = lines.slice(idx, idx + 6).join(" ");
|
|
7218
|
+
if (/Displayed|Enabled/i.test(window) && /catch\s*\(/.test(window) && /return\s+false/i.test(window)) {
|
|
7219
|
+
swallowedDisplayed.push(idx + 1);
|
|
7220
|
+
}
|
|
7221
|
+
});
|
|
7222
|
+
if (swallowedDisplayed.length) pushFinding(findings, {
|
|
7223
|
+
ruleId: "SEL-CS-REL-009",
|
|
7224
|
+
category: "reliability",
|
|
7225
|
+
severity: "warning",
|
|
7226
|
+
title: "Displayed check wrapped in try/catch as an existence test",
|
|
7227
|
+
description: `Line ${swallowedDisplayed[0]} swallows an exception to decide whether an element is present.`,
|
|
7228
|
+
impact: "A genuine failure \u2014 wrong page, timeout, crashed driver \u2014 is indistinguishable from 'not present', so the test skips its real verification and still passes.",
|
|
7229
|
+
fix: `// Ask the driver directly instead of catching
|
|
7230
|
+
bool present = driver.FindElements(locator).Count > 0;`,
|
|
7231
|
+
line: swallowedDisplayed[0]
|
|
7232
|
+
}, disabledRuleIds);
|
|
7233
|
+
const chainedFind = lineMatches2(content, /FindElement\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*FindElement\s*\(/i);
|
|
7234
|
+
if (chainedFind.length) pushFinding(findings, {
|
|
7235
|
+
ruleId: "SEL-CS-LOC-005",
|
|
7236
|
+
category: "locators",
|
|
7237
|
+
severity: "info",
|
|
7238
|
+
title: "Chained FindElement() calls",
|
|
7239
|
+
description: `Line ${chainedFind[0]} chains FindElement into another FindElement.`,
|
|
7240
|
+
impact: "Each hop is a separate round trip that can go stale mid-chain, and the locator now encodes two levels of DOM structure.",
|
|
7241
|
+
fix: `// One locator scoped with a CSS descendant selector
|
|
7242
|
+
driver.FindElement(By.CssSelector("[data-testid='cart'] .line-item__price"));`,
|
|
7243
|
+
line: chainedFind[0]
|
|
7244
|
+
}, disabledRuleIds);
|
|
7245
|
+
{
|
|
7246
|
+
const hasCategory = /\[\s*(?:Category|TestCategory|Trait)\s*\(/i.test(content);
|
|
7247
|
+
if (hasTests && !hasCategory) pushFinding(findings, {
|
|
7248
|
+
ruleId: "SEL-CS-STD-005",
|
|
7249
|
+
category: "coding_standards",
|
|
7250
|
+
severity: "info",
|
|
7251
|
+
title: "Tests carry no [Category] or [Trait]",
|
|
7252
|
+
description: "No [Category] (NUnit), [TestCategory] (MSTest) or [Trait] (xUnit) attribute appears in this file, so its tests cannot be filtered by the runner.",
|
|
7253
|
+
impact: "CI must run the whole suite every time \u2014 no smoke subset, no way to quarantine a flaky test without commenting it out.",
|
|
7254
|
+
fix: `[Test]
|
|
7255
|
+
[Category("Smoke")]
|
|
7256
|
+
public void CompletesCheckout() { }
|
|
7257
|
+
|
|
7258
|
+
// then: dotnet test --filter TestCategory=Smoke`,
|
|
7259
|
+
line: lineMatches2(content, /\[\s*(?:Test|Fact|TestMethod)\s*\]/i)[0] ?? null,
|
|
7260
|
+
reference: "https://docs.nunit.org/articles/nunit/writing-tests/attributes/category.html"
|
|
7261
|
+
}, disabledRuleIds);
|
|
7262
|
+
}
|
|
4876
7263
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
4877
7264
|
const summary = crit > 0 ? `Selenium (C#) scan of ${filename}: ${findings.length} finding(s), ${crit} critical.` : `Selenium (C#) scan of ${filename}: ${findings.length} finding(s) from standard rules.`;
|
|
4878
7265
|
return buildAuditResult({
|
|
@@ -5981,6 +8368,23 @@ log.info("Tapping login button");`,
|
|
|
5981
8368
|
reference: "https://appium.io/docs/en/writing-running-appium/page-object-model/"
|
|
5982
8369
|
}, disabledRuleIds);
|
|
5983
8370
|
}
|
|
8371
|
+
{
|
|
8372
|
+
const hasTag = /@Tag\s*\(|@Category\s*\(|groups\s*=\s*[{"']/.test(content);
|
|
8373
|
+
if (/@Test\b/.test(content) && !hasTag) pushFinding(findings, {
|
|
8374
|
+
ruleId: "APM-J-STD-005",
|
|
8375
|
+
category: "coding_standards",
|
|
8376
|
+
severity: "info",
|
|
8377
|
+
title: "Tests carry no @Tag or TestNG group",
|
|
8378
|
+
description: "No @Tag, @Category or groups= appears in this file, so its tests cannot be selected by the runner.",
|
|
8379
|
+
impact: "Device-lab time is expensive \u2014 without tags every run executes the full suite instead of a smoke subset.",
|
|
8380
|
+
fix: `@Test
|
|
8381
|
+
@Tag("smoke")
|
|
8382
|
+
@Tag("android")
|
|
8383
|
+
void launchesApp() { }`,
|
|
8384
|
+
line: lineMatches2(content, /@Test\b/)[0] ?? null,
|
|
8385
|
+
reference: "https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering"
|
|
8386
|
+
}, disabledRuleIds);
|
|
8387
|
+
}
|
|
5984
8388
|
const crit = findings.filter((f) => f.severity === "critical").length;
|
|
5985
8389
|
const summary = crit > 0 ? `Appium (Java) scan of ${filename}: ${findings.length} finding(s), ${crit} critical.` : `Appium (Java) scan of ${filename}: ${findings.length} finding(s) from standard rules.`;
|
|
5986
8390
|
return buildAuditResult({
|
|
@@ -6640,7 +9044,7 @@ function analyseToscaXmlLocally(filename, content, options = {}) {
|
|
|
6640
9044
|
}
|
|
6641
9045
|
|
|
6642
9046
|
// bin/cqs-mcp.entry.js
|
|
6643
|
-
var VERSION = true ? "2.
|
|
9047
|
+
var VERSION = true ? "2.1.0" : "1.2.0";
|
|
6644
9048
|
var RUNNERS = {
|
|
6645
9049
|
playwright: analysePlaywright,
|
|
6646
9050
|
java_api: analyseJavaApiLocally,
|