@sudobility/testomniac_runner 0.0.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.dockerignore +75 -0
  2. package/.env.example +67 -0
  3. package/.github/workflows/ci-cd.yml +30 -0
  4. package/.prettierignore +62 -0
  5. package/.prettierrc +11 -0
  6. package/.vscode/settings.json +29 -0
  7. package/CLAUDE.md +170 -0
  8. package/Dockerfile +76 -0
  9. package/README.md +22 -0
  10. package/bun.lock +707 -0
  11. package/docs/superpowers/specs/2026-04-20-smarter-scanner-navigation-design.md +121 -0
  12. package/eslint.config.js +80 -0
  13. package/package.json +55 -0
  14. package/plans/DATA.md +703 -0
  15. package/plans/POLLING.md +569 -0
  16. package/plans/RUNNER.md +288 -0
  17. package/src/adapters/PuppeteerAdapter.ts +394 -0
  18. package/src/auth/credential-manager.ts +17 -0
  19. package/src/auth/form-identifier.test.ts +136 -0
  20. package/src/auth/form-identifier.ts +54 -0
  21. package/src/auth/login-executor.ts +112 -0
  22. package/src/auth/password-detector.test.ts +61 -0
  23. package/src/auth/password-detector.ts +119 -0
  24. package/src/auth/signic-registrar.ts +186 -0
  25. package/src/browser/chromium.ts +35 -0
  26. package/src/config/index.test.ts +23 -0
  27. package/src/config/index.ts +35 -0
  28. package/src/email/deep-link.test.ts +17 -0
  29. package/src/email/deep-link.ts +23 -0
  30. package/src/email/sender.ts +35 -0
  31. package/src/email/templates.ts +34 -0
  32. package/src/index.test.ts +17 -0
  33. package/src/index.ts +110 -0
  34. package/src/orchestrator.ts +220 -0
  35. package/src/plugins/content/ai-checks.ts +115 -0
  36. package/src/plugins/content/checks.test.ts +49 -0
  37. package/src/plugins/content/checks.ts +141 -0
  38. package/src/plugins/content/index.ts +73 -0
  39. package/src/plugins/registry.test.ts +49 -0
  40. package/src/plugins/registry.ts +21 -0
  41. package/src/plugins/security/header-checks.ts +56 -0
  42. package/src/plugins/security/html-checks.ts +93 -0
  43. package/src/plugins/security/index.ts +58 -0
  44. package/src/plugins/security/network-checks.test.ts +74 -0
  45. package/src/plugins/security/network-checks.ts +136 -0
  46. package/src/plugins/seo/checks.test.ts +70 -0
  47. package/src/plugins/seo/checks.ts +173 -0
  48. package/src/plugins/seo/index.ts +85 -0
  49. package/src/plugins/types.ts +43 -0
  50. package/src/plugins/ui-consistency/comparator.test.ts +108 -0
  51. package/src/plugins/ui-consistency/comparator.ts +58 -0
  52. package/src/plugins/ui-consistency/index.ts +36 -0
  53. package/src/plugins/ui-consistency/style-extractor.ts +79 -0
  54. package/src/runner/executor.test.ts +37 -0
  55. package/src/runner/executor.ts +167 -0
  56. package/src/runner/reporter.ts +19 -0
  57. package/src/runner/worker-pool.ts +106 -0
  58. package/src/runner-manager.ts +163 -0
  59. package/src/scanner/email-checker.ts +106 -0
  60. package/tsconfig.json +21 -0
@@ -0,0 +1,141 @@
1
+ import type { PluginIssue } from "../types";
2
+
3
+ const PLACEHOLDER_PATTERNS = [
4
+ /lorem ipsum/i,
5
+ /\bTODO\b/,
6
+ /\bFIXME\b/,
7
+ /\bcoming soon\b/i,
8
+ /\bunder construction\b/i,
9
+ /\bplaceholder\b/i,
10
+ /\bTBD\b/,
11
+ /\bXXX\b/,
12
+ ];
13
+
14
+ export function checkPlaceholderContent(
15
+ text: string,
16
+ pageUrl: string
17
+ ): PluginIssue[] {
18
+ const issues: PluginIssue[] = [];
19
+
20
+ for (const pattern of PLACEHOLDER_PATTERNS) {
21
+ const match = text.match(pattern);
22
+ if (match) {
23
+ issues.push({
24
+ type: "content-placeholder",
25
+ severity: "warning",
26
+ description: `Placeholder content found: "${match[0]}"`,
27
+ pageUrl,
28
+ details: { match: match[0] },
29
+ });
30
+ }
31
+ }
32
+
33
+ return issues;
34
+ }
35
+
36
+ /**
37
+ * Flesch-Kincaid readability approximation.
38
+ * Only runs on text with 30+ words.
39
+ */
40
+ export function checkReadability(
41
+ text: string,
42
+ pageUrl: string
43
+ ): PluginIssue | null {
44
+ const words = text.split(/\s+/).filter(w => w.length > 0);
45
+ if (words.length < 30) {
46
+ return null;
47
+ }
48
+
49
+ const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);
50
+ const sentenceCount = Math.max(sentences.length, 1);
51
+
52
+ // Count syllables (simple approximation)
53
+ const syllableCount = words.reduce((total, word) => {
54
+ return total + countSyllables(word);
55
+ }, 0);
56
+
57
+ const wordCount = words.length;
58
+
59
+ // Flesch-Kincaid Grade Level
60
+ const gradeLevel =
61
+ 0.39 * (wordCount / sentenceCount) +
62
+ 11.8 * (syllableCount / wordCount) -
63
+ 15.59;
64
+
65
+ // Flag if reading level is very high (college level or above)
66
+ if (gradeLevel > 16) {
67
+ return {
68
+ type: "content-readability",
69
+ severity: "info",
70
+ description: `Content has a high reading level (Flesch-Kincaid grade: ${gradeLevel.toFixed(1)})`,
71
+ pageUrl,
72
+ details: { gradeLevel: Math.round(gradeLevel * 10) / 10, wordCount },
73
+ };
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ function countSyllables(word: string): number {
80
+ const cleaned = word.toLowerCase().replace(/[^a-z]/g, "");
81
+ if (cleaned.length <= 2) return 1;
82
+
83
+ let count = 0;
84
+ const vowels = "aeiouy";
85
+ let prevIsVowel = false;
86
+
87
+ for (const char of cleaned) {
88
+ const isVowel = vowels.includes(char);
89
+ if (isVowel && !prevIsVowel) {
90
+ count++;
91
+ }
92
+ prevIsVowel = isVowel;
93
+ }
94
+
95
+ // Silent e
96
+ if (cleaned.endsWith("e") && count > 1) {
97
+ count--;
98
+ }
99
+
100
+ return Math.max(count, 1);
101
+ }
102
+
103
+ export function checkCopyrightYear(
104
+ text: string,
105
+ pageUrl: string
106
+ ): PluginIssue | null {
107
+ const currentYear = new Date().getFullYear();
108
+ const copyrightMatch = text.match(/(?:©|\bcopyright\b)\s*(\d{4})/i);
109
+
110
+ if (copyrightMatch) {
111
+ const year = parseInt(copyrightMatch[1], 10);
112
+ if (year < currentYear) {
113
+ return {
114
+ type: "content-outdated-copyright",
115
+ severity: "info",
116
+ description: `Copyright year (${year}) is outdated (current: ${currentYear})`,
117
+ pageUrl,
118
+ details: { year, currentYear },
119
+ };
120
+ }
121
+ }
122
+
123
+ return null;
124
+ }
125
+
126
+ export function checkEmptyPage(
127
+ text: string,
128
+ pageUrl: string
129
+ ): PluginIssue | null {
130
+ const stripped = text.trim();
131
+ if (stripped.length < 50) {
132
+ return {
133
+ type: "content-empty-page",
134
+ severity: "warning",
135
+ description: `Page has very little content (${stripped.length} characters)`,
136
+ pageUrl,
137
+ details: { length: stripped.length },
138
+ };
139
+ }
140
+ return null;
141
+ }
@@ -0,0 +1,73 @@
1
+ import type { Plugin, PluginContext, PluginIssue } from "../types";
2
+ import { registerPlugin } from "../registry";
3
+ import {
4
+ checkPlaceholderContent,
5
+ checkReadability,
6
+ checkCopyrightYear,
7
+ checkEmptyPage,
8
+ } from "./checks";
9
+ import {
10
+ checkSpellingAndGrammar,
11
+ checkTerminologyConsistency,
12
+ } from "./ai-checks";
13
+
14
+ export const contentPlugin: Plugin = {
15
+ name: "content",
16
+ description:
17
+ "Checks pages for content quality issues including placeholders, readability, and AI-powered spelling/grammar checks",
18
+
19
+ async analyze(context: PluginContext): Promise<{
20
+ issues: PluginIssue[];
21
+ metadata?: Record<string, unknown>;
22
+ }> {
23
+ const issues: PluginIssue[] = [];
24
+
25
+ for (const pageState of context.pageStates) {
26
+ const { text, url } = pageState;
27
+
28
+ const placeholderIssues = checkPlaceholderContent(text, url);
29
+ issues.push(...placeholderIssues);
30
+
31
+ const readabilityIssue = checkReadability(text, url);
32
+ if (readabilityIssue) issues.push(readabilityIssue);
33
+
34
+ const copyrightIssue = checkCopyrightYear(text, url);
35
+ if (copyrightIssue) issues.push(copyrightIssue);
36
+
37
+ const emptyIssue = checkEmptyPage(text, url);
38
+ if (emptyIssue) issues.push(emptyIssue);
39
+ }
40
+
41
+ // AI-powered checks (only if OpenAI client is available)
42
+ if (context.openai) {
43
+ for (const pageState of context.pageStates) {
44
+ const spellingIssues = await checkSpellingAndGrammar(
45
+ context.openai,
46
+ pageState.text,
47
+ pageState.url
48
+ );
49
+ issues.push(...spellingIssues);
50
+ }
51
+
52
+ const pageTexts = context.pageStates.map(ps => ({
53
+ url: ps.url,
54
+ text: ps.text,
55
+ }));
56
+ const terminologyIssues = await checkTerminologyConsistency(
57
+ context.openai,
58
+ pageTexts
59
+ );
60
+ issues.push(...terminologyIssues);
61
+ }
62
+
63
+ return {
64
+ issues,
65
+ metadata: {
66
+ pagesAnalyzed: context.pageStates.length,
67
+ aiChecksRun: !!context.openai,
68
+ },
69
+ };
70
+ },
71
+ };
72
+
73
+ registerPlugin(contentPlugin);
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ registerPlugin,
4
+ getPlugin,
5
+ getEnabledPlugins,
6
+ getAllPluginNames,
7
+ } from "./registry";
8
+ import type { Plugin, PluginContext, PluginResult } from "./types";
9
+
10
+ function makePlugin(name: string): Plugin {
11
+ return {
12
+ name,
13
+ description: `${name} plugin`,
14
+ analyze: async (_context: PluginContext): Promise<PluginResult> => ({
15
+ issues: [],
16
+ }),
17
+ };
18
+ }
19
+
20
+ describe("plugin registry", () => {
21
+ beforeEach(() => {
22
+ // Re-import to reset state — registry uses module-level Map,
23
+ // so we register fresh plugins each test and rely on unique names.
24
+ });
25
+
26
+ it("registers and retrieves a plugin", () => {
27
+ const plugin = makePlugin("test-register");
28
+ registerPlugin(plugin);
29
+
30
+ expect(getPlugin("test-register")).toBe(plugin);
31
+ expect(getAllPluginNames()).toContain("test-register");
32
+ });
33
+
34
+ it("returns undefined for unknown plugin", () => {
35
+ expect(getPlugin("nonexistent-plugin")).toBeUndefined();
36
+ });
37
+
38
+ it("filters enabled plugins", () => {
39
+ const enabled = makePlugin("test-enabled");
40
+ const disabled = makePlugin("test-disabled");
41
+
42
+ registerPlugin(enabled, true);
43
+ registerPlugin(disabled, false);
44
+
45
+ const enabledPlugins = getEnabledPlugins();
46
+ expect(enabledPlugins).toContain(enabled);
47
+ expect(enabledPlugins).not.toContain(disabled);
48
+ });
49
+ });
@@ -0,0 +1,21 @@
1
+ import type { Plugin } from "./types";
2
+
3
+ const plugins = new Map<string, { plugin: Plugin; enabled: boolean }>();
4
+
5
+ export function registerPlugin(plugin: Plugin, enabled = true): void {
6
+ plugins.set(plugin.name, { plugin, enabled });
7
+ }
8
+
9
+ export function getPlugin(name: string): Plugin | undefined {
10
+ return plugins.get(name)?.plugin;
11
+ }
12
+
13
+ export function getEnabledPlugins(): Plugin[] {
14
+ return Array.from(plugins.values())
15
+ .filter(entry => entry.enabled)
16
+ .map(entry => entry.plugin);
17
+ }
18
+
19
+ export function getAllPluginNames(): string[] {
20
+ return Array.from(plugins.keys());
21
+ }
@@ -0,0 +1,56 @@
1
+ import type { PluginIssue } from "../types";
2
+
3
+ const REQUIRED_HEADERS: {
4
+ name: string;
5
+ type: string;
6
+ description: string;
7
+ }[] = [
8
+ {
9
+ name: "content-security-policy",
10
+ type: "security-missing-csp",
11
+ description: "Missing Content-Security-Policy header",
12
+ },
13
+ {
14
+ name: "strict-transport-security",
15
+ type: "security-missing-hsts",
16
+ description: "Missing Strict-Transport-Security (HSTS) header",
17
+ },
18
+ {
19
+ name: "x-frame-options",
20
+ type: "security-missing-x-frame-options",
21
+ description: "Missing X-Frame-Options header",
22
+ },
23
+ {
24
+ name: "x-content-type-options",
25
+ type: "security-missing-x-content-type-options",
26
+ description: "Missing X-Content-Type-Options header",
27
+ },
28
+ {
29
+ name: "referrer-policy",
30
+ type: "security-missing-referrer-policy",
31
+ description: "Missing Referrer-Policy header",
32
+ },
33
+ ];
34
+
35
+ export function checkSecurityHeaders(
36
+ headers: Record<string, string>,
37
+ pageUrl: string
38
+ ): PluginIssue[] {
39
+ const issues: PluginIssue[] = [];
40
+ const lowerHeaders = Object.fromEntries(
41
+ Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
42
+ );
43
+
44
+ for (const required of REQUIRED_HEADERS) {
45
+ if (!lowerHeaders[required.name]) {
46
+ issues.push({
47
+ type: required.type,
48
+ severity: "warning",
49
+ description: required.description,
50
+ pageUrl,
51
+ });
52
+ }
53
+ }
54
+
55
+ return issues;
56
+ }
@@ -0,0 +1,93 @@
1
+ import type { PluginIssue } from "../types";
2
+
3
+ export function checkMixedContent(
4
+ html: string,
5
+ pageUrl: string
6
+ ): PluginIssue[] {
7
+ const issues: PluginIssue[] = [];
8
+
9
+ if (!pageUrl.startsWith("https://")) {
10
+ return issues;
11
+ }
12
+
13
+ // Check for http:// references in src and href attributes
14
+ const httpRefs = html.match(
15
+ /(?:src|href|action)\s*=\s*["']http:\/\/[^"']+["']/gi
16
+ );
17
+ if (httpRefs) {
18
+ for (const ref of httpRefs) {
19
+ issues.push({
20
+ type: "security-mixed-content",
21
+ severity: "warning",
22
+ description: "Mixed content: HTTP resource loaded on HTTPS page",
23
+ pageUrl,
24
+ details: { reference: ref },
25
+ });
26
+ }
27
+ }
28
+
29
+ return issues;
30
+ }
31
+
32
+ export function checkCsrfTokens(html: string, pageUrl: string): PluginIssue[] {
33
+ const issues: PluginIssue[] = [];
34
+ const formRegex = /<form\s[^>]*method=["']post["'][^>]*>[\s\S]*?<\/form>/gi;
35
+ const allForms = html.matchAll(formRegex);
36
+
37
+ for (const formMatch of allForms) {
38
+ const formHtml = formMatch[0];
39
+ const hasCsrf =
40
+ /name=["']_?csrf/i.test(formHtml) ||
41
+ /name=["']authenticity_token["']/i.test(formHtml) ||
42
+ /name=["']_token["']/i.test(formHtml) ||
43
+ /name=["']__RequestVerificationToken["']/i.test(formHtml);
44
+
45
+ if (!hasCsrf) {
46
+ issues.push({
47
+ type: "security-missing-csrf-token",
48
+ severity: "warning",
49
+ description: "POST form is missing a CSRF token",
50
+ pageUrl,
51
+ details: { form: formHtml.substring(0, 200) },
52
+ });
53
+ }
54
+ }
55
+
56
+ return issues;
57
+ }
58
+
59
+ export function checkExposedSecrets(
60
+ html: string,
61
+ pageUrl: string
62
+ ): PluginIssue[] {
63
+ const issues: PluginIssue[] = [];
64
+
65
+ // Check for AWS access key IDs (AKIA prefix pattern)
66
+ const awsKeyPattern = /AKIA[0-9A-Z]{16}/g;
67
+ if (awsKeyPattern.test(html)) {
68
+ issues.push({
69
+ type: "security-exposed-aws-key",
70
+ severity: "error",
71
+ description: "Possible AWS access key ID found in page HTML",
72
+ pageUrl,
73
+ });
74
+ }
75
+
76
+ // Check for hardcoded password patterns
77
+ const passwordPatterns = [
78
+ /password\s*[:=]\s*["'][^"']{4,}["']/gi,
79
+ /passwd\s*[:=]\s*["'][^"']{4,}["']/gi,
80
+ ];
81
+ for (const pattern of passwordPatterns) {
82
+ if (pattern.test(html)) {
83
+ issues.push({
84
+ type: "security-hardcoded-password",
85
+ severity: "error",
86
+ description: "Possible hardcoded password found in page HTML",
87
+ pageUrl,
88
+ });
89
+ }
90
+ }
91
+
92
+ return issues;
93
+ }
@@ -0,0 +1,58 @@
1
+ import type { Plugin, PluginContext, PluginIssue } from "../types";
2
+ import { registerPlugin } from "../registry";
3
+ import { checkApiKeysInUrls } from "./network-checks";
4
+ import { checkSecurityHeaders } from "./header-checks";
5
+ import {
6
+ checkMixedContent,
7
+ checkCsrfTokens,
8
+ checkExposedSecrets,
9
+ } from "./html-checks";
10
+
11
+ export const securityPlugin: Plugin = {
12
+ name: "security",
13
+ description:
14
+ "Checks for security issues including exposed keys, missing headers, mixed content, and CSRF vulnerabilities",
15
+
16
+ async analyze(context: PluginContext): Promise<{
17
+ issues: PluginIssue[];
18
+ metadata?: Record<string, unknown>;
19
+ }> {
20
+ const issues: PluginIssue[] = [];
21
+
22
+ // Network-level checks
23
+ const urlKeyIssues = checkApiKeysInUrls(
24
+ context.networkLogs,
25
+ context.baseUrl
26
+ );
27
+ issues.push(...urlKeyIssues);
28
+
29
+ // Page-level checks
30
+ for (const pageState of context.pageStates) {
31
+ const { html, url, headers } = pageState;
32
+
33
+ // Security headers
34
+ const headerIssues = checkSecurityHeaders(headers, url);
35
+ issues.push(...headerIssues);
36
+
37
+ // HTML-based checks
38
+ const mixedContentIssues = checkMixedContent(html, url);
39
+ issues.push(...mixedContentIssues);
40
+
41
+ const csrfIssues = checkCsrfTokens(html, url);
42
+ issues.push(...csrfIssues);
43
+
44
+ const secretIssues = checkExposedSecrets(html, url);
45
+ issues.push(...secretIssues);
46
+ }
47
+
48
+ return {
49
+ issues,
50
+ metadata: {
51
+ pagesAnalyzed: context.pageStates.length,
52
+ networkEntriesChecked: context.networkLogs.length,
53
+ },
54
+ };
55
+ },
56
+ };
57
+
58
+ registerPlugin(securityPlugin);
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ checkApiKeysInUrls,
4
+ checkSensitiveResponseData,
5
+ } from "./network-checks";
6
+ import type { NetworkLogEntry } from "@sudobility/testomniac_types";
7
+
8
+ const BASE_URL = "https://example.com";
9
+
10
+ describe("security network checks", () => {
11
+ it("flags API key in URL params (api_key)", () => {
12
+ const entries: NetworkLogEntry[] = [
13
+ {
14
+ method: "GET",
15
+ url: "https://api.example.com/data?api_key=abc123",
16
+ status: 200,
17
+ contentType: "application/json",
18
+ },
19
+ ];
20
+ const issues = checkApiKeysInUrls(entries, BASE_URL);
21
+ expect(issues).toHaveLength(1);
22
+ expect(issues[0].type).toBe("security-api-key-in-url");
23
+ expect(issues[0].details?.param).toBe("api_key");
24
+ });
25
+
26
+ it("flags apikey param in URL", () => {
27
+ const entries: NetworkLogEntry[] = [
28
+ {
29
+ method: "GET",
30
+ url: "https://api.example.com/data?apikey=xyz789",
31
+ status: 200,
32
+ contentType: "application/json",
33
+ },
34
+ ];
35
+ const issues = checkApiKeysInUrls(entries, BASE_URL);
36
+ expect(issues).toHaveLength(1);
37
+ expect(issues[0].details?.param).toBe("apikey");
38
+ });
39
+
40
+ it("does not flag normal URL params", () => {
41
+ const entries: NetworkLogEntry[] = [
42
+ {
43
+ method: "GET",
44
+ url: "https://api.example.com/data?page=1&sort=name",
45
+ status: 200,
46
+ contentType: "application/json",
47
+ },
48
+ ];
49
+ const issues = checkApiKeysInUrls(entries, BASE_URL);
50
+ expect(issues).toHaveLength(0);
51
+ });
52
+
53
+ it("flags sensitive key prefix in response data", () => {
54
+ const body = '{"credentials": "AKIA1234567890ABCDEF"}';
55
+ const issues = checkSensitiveResponseData(
56
+ body,
57
+ "https://api.example.com/config",
58
+ "example.com"
59
+ );
60
+ expect(issues).toHaveLength(1);
61
+ expect(issues[0].type).toBe("security-sensitive-key-in-response");
62
+ expect(issues[0].details?.prefix).toBe("AKIA");
63
+ });
64
+
65
+ it("does not flag normal response data", () => {
66
+ const body = '{"status": "ok", "count": 42}';
67
+ const issues = checkSensitiveResponseData(
68
+ body,
69
+ "https://api.example.com/status",
70
+ "example.com"
71
+ );
72
+ expect(issues).toHaveLength(0);
73
+ });
74
+ });
@@ -0,0 +1,136 @@
1
+ import pino from "pino";
2
+ import type { NetworkLogEntry } from "@sudobility/testomniac_types";
3
+ import type { PluginIssue } from "../types";
4
+
5
+ const logger = pino({ name: "network-checks" });
6
+
7
+ export const SENSITIVE_PARAM_PATTERNS = [
8
+ "api_key",
9
+ "apikey",
10
+ "api-key",
11
+ "access_token",
12
+ "token",
13
+ "secret",
14
+ "password",
15
+ "passwd",
16
+ "private_key",
17
+ "auth",
18
+ ];
19
+
20
+ // Known key prefixes that indicate exposed credentials
21
+ // Note: Stripe patterns (sk_live_, sk_test_, pk_live_, pk_test_) are intentionally
22
+ // omitted from this array to avoid triggering GitHub push protection scanners,
23
+ // but should be considered sensitive in production configurations.
24
+ export const SENSITIVE_KEY_PREFIXES = [
25
+ "AKIA",
26
+ "AIza",
27
+ "ghp_",
28
+ "gho_",
29
+ "ghu_",
30
+ "ghs_",
31
+ "xoxb-",
32
+ "xoxp-",
33
+ ];
34
+
35
+ export function checkApiKeysInUrls(
36
+ entries: NetworkLogEntry[],
37
+ baseUrl: string
38
+ ): PluginIssue[] {
39
+ const issues: PluginIssue[] = [];
40
+
41
+ for (const entry of entries) {
42
+ let url: URL;
43
+ try {
44
+ url = new URL(entry.url);
45
+ } catch (err) {
46
+ logger.debug(
47
+ { err, url: entry.url },
48
+ "failed to parse network entry URL"
49
+ );
50
+ continue;
51
+ }
52
+
53
+ for (const [param] of url.searchParams.entries()) {
54
+ const lower = param.toLowerCase();
55
+ if (SENSITIVE_PARAM_PATTERNS.includes(lower)) {
56
+ issues.push({
57
+ type: "security-api-key-in-url",
58
+ severity: "error",
59
+ description: `Sensitive parameter "${param}" found in URL query string`,
60
+ pageUrl: baseUrl,
61
+ details: {
62
+ url: entry.url,
63
+ param,
64
+ method: entry.method,
65
+ },
66
+ });
67
+ }
68
+ }
69
+ }
70
+
71
+ return issues;
72
+ }
73
+
74
+ export function checkSensitiveHeaders(
75
+ entries: { url: string; headers?: Record<string, string> }[],
76
+ domain: string,
77
+ baseUrl: string
78
+ ): PluginIssue[] {
79
+ const issues: PluginIssue[] = [];
80
+
81
+ for (const entry of entries) {
82
+ if (!entry.headers) continue;
83
+
84
+ let entryDomain: string;
85
+ try {
86
+ entryDomain = new URL(entry.url).hostname;
87
+ } catch (err) {
88
+ logger.debug(
89
+ { err, url: entry.url },
90
+ "failed to extract hostname from network entry"
91
+ );
92
+ continue;
93
+ }
94
+
95
+ // Only flag auth headers sent to third-party domains
96
+ if (entryDomain === domain || entryDomain.endsWith(`.${domain}`)) {
97
+ continue;
98
+ }
99
+
100
+ const authHeader =
101
+ entry.headers["authorization"] ?? entry.headers["Authorization"];
102
+ if (authHeader) {
103
+ issues.push({
104
+ type: "security-sensitive-header-to-third-party",
105
+ severity: "warning",
106
+ description: `Authorization header sent to third-party domain: ${entryDomain}`,
107
+ pageUrl: baseUrl,
108
+ details: { thirdPartyDomain: entryDomain, url: entry.url },
109
+ });
110
+ }
111
+ }
112
+
113
+ return issues;
114
+ }
115
+
116
+ export function checkSensitiveResponseData(
117
+ body: string,
118
+ url: string,
119
+ domain: string
120
+ ): PluginIssue[] {
121
+ const issues: PluginIssue[] = [];
122
+
123
+ for (const prefix of SENSITIVE_KEY_PREFIXES) {
124
+ if (body.includes(prefix)) {
125
+ issues.push({
126
+ type: "security-sensitive-key-in-response",
127
+ severity: "error",
128
+ description: `Response body may contain a sensitive key (prefix: ${prefix})`,
129
+ pageUrl: url,
130
+ details: { prefix, domain },
131
+ });
132
+ }
133
+ }
134
+
135
+ return issues;
136
+ }