@arcadialdev/arcality 2.4.24 → 2.4.25
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/.agents/skills/e2e-testing-expert/SKILL.md +28 -28
- package/.agents/skills/security-qa/SKILL.md +254 -0
- package/README.md +95 -95
- package/bin/arcality.mjs +55 -55
- package/package.json +68 -68
- package/playwright.config.js +33 -33
- package/scripts/gen-and-run.mjs +4 -1
- package/scripts/init.mjs +297 -297
- package/scripts/postinstall.mjs +77 -77
- package/scripts/setup.mjs +166 -166
- package/src/configLoader.mjs +179 -179
- package/src/configManager.mjs +172 -172
- package/src/envSetup.ts +205 -205
- package/src/index.ts +25 -25
- package/src/services/collectiveMemoryService.ts +1 -1
- package/src/services/securityScanner.ts +128 -0
- package/tests/_helpers/ArcalityReporter.js +632 -229
- package/tests/_helpers/ArcalityReporter.ts +262 -27
- package/tests/_helpers/agentic-runner.bundle.spec.js +357 -5
- package/tests/_helpers/agentic-runner.spec.ts +37 -1
- package/tests/_helpers/ai-agent-helper.ts +128 -6
- package/tests/_helpers/qa-security-tools.ts +265 -0
package/src/envSetup.ts
CHANGED
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import prompts from 'prompts';
|
|
4
|
-
|
|
5
|
-
export class UserCancelledError extends Error {
|
|
6
|
-
constructor(message = 'Usuario canceló la operación') {
|
|
7
|
-
super(message);
|
|
8
|
-
this.name = 'UserCancelledError';
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
type EnvValues = {
|
|
13
|
-
BASE_URL?: string;
|
|
14
|
-
LOGIN_USER?: string;
|
|
15
|
-
LOGIN_PASSWORD?: string;
|
|
16
|
-
ARCALITY_PROJECT_ID?: string;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
interface ArcalityConfigFile {
|
|
20
|
-
apiKey?: string;
|
|
21
|
-
projectId?: string;
|
|
22
|
-
project?: {
|
|
23
|
-
name?: string;
|
|
24
|
-
baseUrl?: string;
|
|
25
|
-
frameworkDetected?: string;
|
|
26
|
-
};
|
|
27
|
-
auth?: {
|
|
28
|
-
username?: string;
|
|
29
|
-
password?: string;
|
|
30
|
-
};
|
|
31
|
-
runtime?: {
|
|
32
|
-
yamlOutputDir?: string;
|
|
33
|
-
reuseSuccessfulYamls?: boolean;
|
|
34
|
-
singleConfigurationMode?: boolean;
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function envPath(): string {
|
|
39
|
-
return path.join(process.cwd(), '.env');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function arcalityConfigPath(): string {
|
|
43
|
-
return path.join(process.cwd(), 'arcality.config');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function escapeEnvValue(v: string): string {
|
|
47
|
-
const needsQuotes = /[\s"'`\\]/.test(v);
|
|
48
|
-
const cleaned = v.replace(/"/g, '\\"');
|
|
49
|
-
return needsQuotes ? `"${cleaned}"` : cleaned;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseDotEnv(content: string): Record<string, string> {
|
|
53
|
-
const out: Record<string, string> = {};
|
|
54
|
-
for (const line of content.split(/\r?\n/)) {
|
|
55
|
-
const trimmed = line.trim();
|
|
56
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
57
|
-
|
|
58
|
-
const idx = trimmed.indexOf('=');
|
|
59
|
-
if (idx === -1) continue;
|
|
60
|
-
|
|
61
|
-
const k = trimmed.slice(0, idx).trim();
|
|
62
|
-
let v = trimmed.slice(idx + 1).trim();
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
(v.startsWith('"') && v.endsWith('"')) ||
|
|
66
|
-
(v.startsWith("'") && v.endsWith("'"))
|
|
67
|
-
) {
|
|
68
|
-
v = v.slice(1, -1);
|
|
69
|
-
}
|
|
70
|
-
out[k] = v;
|
|
71
|
-
}
|
|
72
|
-
return out;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function readExistingEnv(): Record<string, string> {
|
|
76
|
-
const p = envPath();
|
|
77
|
-
if (!fs.existsSync(p)) return {};
|
|
78
|
-
return parseDotEnv(fs.readFileSync(p, 'utf8'));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Loads arcality.config if it exists.
|
|
83
|
-
*/
|
|
84
|
-
function loadArcalityConfig(): ArcalityConfigFile | null {
|
|
85
|
-
const p = arcalityConfigPath();
|
|
86
|
-
try {
|
|
87
|
-
if (!fs.existsSync(p)) return null;
|
|
88
|
-
let raw = fs.readFileSync(p, 'utf8');
|
|
89
|
-
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
90
|
-
return JSON.parse(raw);
|
|
91
|
-
} catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function writeEnv(values: EnvValues): void {
|
|
97
|
-
const existing = readExistingEnv();
|
|
98
|
-
const merged = { ...existing, ...values };
|
|
99
|
-
|
|
100
|
-
const lines = [
|
|
101
|
-
'# Auto-generado por Arcality testRunner',
|
|
102
|
-
`BASE_URL=${escapeEnvValue(merged.BASE_URL ?? '')}`,
|
|
103
|
-
`LOGIN_USER=${escapeEnvValue(merged.LOGIN_USER ?? '')}`,
|
|
104
|
-
`LOGIN_PASSWORD=${escapeEnvValue(merged.LOGIN_PASSWORD ?? '')}`,
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
if (merged.ARCALITY_PROJECT_ID) {
|
|
108
|
-
lines.push(`ARCALITY_PROJECT_ID=${escapeEnvValue(merged.ARCALITY_PROJECT_ID)}`);
|
|
109
|
-
}
|
|
110
|
-
lines.push('');
|
|
111
|
-
|
|
112
|
-
fs.writeFileSync(envPath(), lines.join('\n'), 'utf8');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function isValidBaseUrl(url: string): boolean {
|
|
116
|
-
try {
|
|
117
|
-
const u = new URL(url);
|
|
118
|
-
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
119
|
-
} catch {
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Ensures environment is configured for test execution.
|
|
126
|
-
* In single-config mode, reads from arcality.config first.
|
|
127
|
-
* Falls back to .env prompts for backward compatibility.
|
|
128
|
-
*/
|
|
129
|
-
export async function ensureEnvInteractive(): Promise<void> {
|
|
130
|
-
// Try to load arcality.config first
|
|
131
|
-
const arcalityConfig = loadArcalityConfig();
|
|
132
|
-
|
|
133
|
-
if (arcalityConfig) {
|
|
134
|
-
// Inject config values into process.env for the current session
|
|
135
|
-
if (arcalityConfig.project?.baseUrl) {
|
|
136
|
-
process.env.BASE_URL = arcalityConfig.project.baseUrl;
|
|
137
|
-
}
|
|
138
|
-
if (arcalityConfig.auth?.username) {
|
|
139
|
-
process.env.LOGIN_USER = arcalityConfig.auth.username;
|
|
140
|
-
}
|
|
141
|
-
if (arcalityConfig.auth?.password) {
|
|
142
|
-
process.env.LOGIN_PASSWORD = arcalityConfig.auth.password;
|
|
143
|
-
}
|
|
144
|
-
if (arcalityConfig.projectId) {
|
|
145
|
-
process.env.ARCALITY_PROJECT_ID = arcalityConfig.projectId;
|
|
146
|
-
}
|
|
147
|
-
if (arcalityConfig.apiKey) {
|
|
148
|
-
process.env.ARCALITY_API_KEY = arcalityConfig.apiKey;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// All good — no prompts needed
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Fallback: prompt for missing .env values (legacy mode)
|
|
156
|
-
const existing = readExistingEnv();
|
|
157
|
-
const questions: prompts.PromptObject[] = [];
|
|
158
|
-
|
|
159
|
-
if (!existing.BASE_URL) {
|
|
160
|
-
questions.push({
|
|
161
|
-
type: 'text',
|
|
162
|
-
name: 'BASE_URL',
|
|
163
|
-
message: 'Base URL (ej: http://localhost:3000):',
|
|
164
|
-
initial: 'http://localhost:3000',
|
|
165
|
-
validate: (v: string) =>
|
|
166
|
-
isValidBaseUrl(v) ? true : 'URL inválida (usa http/https)',
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (!existing.LOGIN_USER) {
|
|
171
|
-
questions.push({
|
|
172
|
-
type: 'text',
|
|
173
|
-
name: 'LOGIN_USER',
|
|
174
|
-
message: 'Usuario:',
|
|
175
|
-
validate: (v: string) =>
|
|
176
|
-
v?.trim().length ? true : 'Usuario requerido',
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (!existing.LOGIN_PASSWORD) {
|
|
181
|
-
questions.push({
|
|
182
|
-
type: 'password',
|
|
183
|
-
name: 'LOGIN_PASSWORD',
|
|
184
|
-
message: 'Contraseña:',
|
|
185
|
-
validate: (v: string) =>
|
|
186
|
-
v?.length ? true : 'Contraseña requerida',
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
if (questions.length > 0) {
|
|
191
|
-
const res = await prompts(questions, {
|
|
192
|
-
onCancel: () => {
|
|
193
|
-
throw new UserCancelledError();
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const merged: EnvValues = {
|
|
198
|
-
BASE_URL: res.BASE_URL ?? existing.BASE_URL,
|
|
199
|
-
LOGIN_USER: res.LOGIN_USER ?? existing.LOGIN_USER,
|
|
200
|
-
LOGIN_PASSWORD: res.LOGIN_PASSWORD ?? existing.LOGIN_PASSWORD,
|
|
201
|
-
};
|
|
202
|
-
writeEnv(merged);
|
|
203
|
-
console.log('Archivo .env actualizado.');
|
|
204
|
-
}
|
|
205
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
|
|
5
|
+
export class UserCancelledError extends Error {
|
|
6
|
+
constructor(message = 'Usuario canceló la operación') {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'UserCancelledError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type EnvValues = {
|
|
13
|
+
BASE_URL?: string;
|
|
14
|
+
LOGIN_USER?: string;
|
|
15
|
+
LOGIN_PASSWORD?: string;
|
|
16
|
+
ARCALITY_PROJECT_ID?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface ArcalityConfigFile {
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
projectId?: string;
|
|
22
|
+
project?: {
|
|
23
|
+
name?: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
frameworkDetected?: string;
|
|
26
|
+
};
|
|
27
|
+
auth?: {
|
|
28
|
+
username?: string;
|
|
29
|
+
password?: string;
|
|
30
|
+
};
|
|
31
|
+
runtime?: {
|
|
32
|
+
yamlOutputDir?: string;
|
|
33
|
+
reuseSuccessfulYamls?: boolean;
|
|
34
|
+
singleConfigurationMode?: boolean;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envPath(): string {
|
|
39
|
+
return path.join(process.cwd(), '.env');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function arcalityConfigPath(): string {
|
|
43
|
+
return path.join(process.cwd(), 'arcality.config');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function escapeEnvValue(v: string): string {
|
|
47
|
+
const needsQuotes = /[\s"'`\\]/.test(v);
|
|
48
|
+
const cleaned = v.replace(/"/g, '\\"');
|
|
49
|
+
return needsQuotes ? `"${cleaned}"` : cleaned;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseDotEnv(content: string): Record<string, string> {
|
|
53
|
+
const out: Record<string, string> = {};
|
|
54
|
+
for (const line of content.split(/\r?\n/)) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
57
|
+
|
|
58
|
+
const idx = trimmed.indexOf('=');
|
|
59
|
+
if (idx === -1) continue;
|
|
60
|
+
|
|
61
|
+
const k = trimmed.slice(0, idx).trim();
|
|
62
|
+
let v = trimmed.slice(idx + 1).trim();
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
66
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
67
|
+
) {
|
|
68
|
+
v = v.slice(1, -1);
|
|
69
|
+
}
|
|
70
|
+
out[k] = v;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readExistingEnv(): Record<string, string> {
|
|
76
|
+
const p = envPath();
|
|
77
|
+
if (!fs.existsSync(p)) return {};
|
|
78
|
+
return parseDotEnv(fs.readFileSync(p, 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Loads arcality.config if it exists.
|
|
83
|
+
*/
|
|
84
|
+
function loadArcalityConfig(): ArcalityConfigFile | null {
|
|
85
|
+
const p = arcalityConfigPath();
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(p)) return null;
|
|
88
|
+
let raw = fs.readFileSync(p, 'utf8');
|
|
89
|
+
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeEnv(values: EnvValues): void {
|
|
97
|
+
const existing = readExistingEnv();
|
|
98
|
+
const merged = { ...existing, ...values };
|
|
99
|
+
|
|
100
|
+
const lines = [
|
|
101
|
+
'# Auto-generado por Arcality testRunner',
|
|
102
|
+
`BASE_URL=${escapeEnvValue(merged.BASE_URL ?? '')}`,
|
|
103
|
+
`LOGIN_USER=${escapeEnvValue(merged.LOGIN_USER ?? '')}`,
|
|
104
|
+
`LOGIN_PASSWORD=${escapeEnvValue(merged.LOGIN_PASSWORD ?? '')}`,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
if (merged.ARCALITY_PROJECT_ID) {
|
|
108
|
+
lines.push(`ARCALITY_PROJECT_ID=${escapeEnvValue(merged.ARCALITY_PROJECT_ID)}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
|
|
112
|
+
fs.writeFileSync(envPath(), lines.join('\n'), 'utf8');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isValidBaseUrl(url: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
const u = new URL(url);
|
|
118
|
+
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ensures environment is configured for test execution.
|
|
126
|
+
* In single-config mode, reads from arcality.config first.
|
|
127
|
+
* Falls back to .env prompts for backward compatibility.
|
|
128
|
+
*/
|
|
129
|
+
export async function ensureEnvInteractive(): Promise<void> {
|
|
130
|
+
// Try to load arcality.config first
|
|
131
|
+
const arcalityConfig = loadArcalityConfig();
|
|
132
|
+
|
|
133
|
+
if (arcalityConfig) {
|
|
134
|
+
// Inject config values into process.env for the current session
|
|
135
|
+
if (arcalityConfig.project?.baseUrl) {
|
|
136
|
+
process.env.BASE_URL = arcalityConfig.project.baseUrl;
|
|
137
|
+
}
|
|
138
|
+
if (arcalityConfig.auth?.username) {
|
|
139
|
+
process.env.LOGIN_USER = arcalityConfig.auth.username;
|
|
140
|
+
}
|
|
141
|
+
if (arcalityConfig.auth?.password) {
|
|
142
|
+
process.env.LOGIN_PASSWORD = arcalityConfig.auth.password;
|
|
143
|
+
}
|
|
144
|
+
if (arcalityConfig.projectId) {
|
|
145
|
+
process.env.ARCALITY_PROJECT_ID = arcalityConfig.projectId;
|
|
146
|
+
}
|
|
147
|
+
if (arcalityConfig.apiKey) {
|
|
148
|
+
process.env.ARCALITY_API_KEY = arcalityConfig.apiKey;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// All good — no prompts needed
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fallback: prompt for missing .env values (legacy mode)
|
|
156
|
+
const existing = readExistingEnv();
|
|
157
|
+
const questions: prompts.PromptObject[] = [];
|
|
158
|
+
|
|
159
|
+
if (!existing.BASE_URL) {
|
|
160
|
+
questions.push({
|
|
161
|
+
type: 'text',
|
|
162
|
+
name: 'BASE_URL',
|
|
163
|
+
message: 'Base URL (ej: http://localhost:3000):',
|
|
164
|
+
initial: 'http://localhost:3000',
|
|
165
|
+
validate: (v: string) =>
|
|
166
|
+
isValidBaseUrl(v) ? true : 'URL inválida (usa http/https)',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!existing.LOGIN_USER) {
|
|
171
|
+
questions.push({
|
|
172
|
+
type: 'text',
|
|
173
|
+
name: 'LOGIN_USER',
|
|
174
|
+
message: 'Usuario:',
|
|
175
|
+
validate: (v: string) =>
|
|
176
|
+
v?.trim().length ? true : 'Usuario requerido',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!existing.LOGIN_PASSWORD) {
|
|
181
|
+
questions.push({
|
|
182
|
+
type: 'password',
|
|
183
|
+
name: 'LOGIN_PASSWORD',
|
|
184
|
+
message: 'Contraseña:',
|
|
185
|
+
validate: (v: string) =>
|
|
186
|
+
v?.length ? true : 'Contraseña requerida',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (questions.length > 0) {
|
|
191
|
+
const res = await prompts(questions, {
|
|
192
|
+
onCancel: () => {
|
|
193
|
+
throw new UserCancelledError();
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const merged: EnvValues = {
|
|
198
|
+
BASE_URL: res.BASE_URL ?? existing.BASE_URL,
|
|
199
|
+
LOGIN_USER: res.LOGIN_USER ?? existing.LOGIN_USER,
|
|
200
|
+
LOGIN_PASSWORD: res.LOGIN_PASSWORD ?? existing.LOGIN_PASSWORD,
|
|
201
|
+
};
|
|
202
|
+
writeEnv(merged);
|
|
203
|
+
console.log('Archivo .env actualizado.');
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
|
-
import { showBanner } from './consoleBanner';
|
|
3
|
-
import { promptAndRunPlaywrightTests } from './testRunner';
|
|
4
|
-
import { KnowledgeService } from './KnowledgeService';
|
|
5
|
-
import { ArcalityClient } from './arcalityClient';
|
|
6
|
-
import { loadConfig } from './configLoader';
|
|
7
|
-
|
|
8
|
-
async function main() {
|
|
9
|
-
showBanner();
|
|
10
|
-
|
|
11
|
-
const config = loadConfig();
|
|
12
|
-
const arcalityClient = new ArcalityClient(config.ARCALITY_API_KEY);
|
|
13
|
-
const knowledgeService = KnowledgeService.getInstance();
|
|
14
|
-
|
|
15
|
-
await promptAndRunPlaywrightTests(arcalityClient, knowledgeService);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
main().catch((err) => {
|
|
19
|
-
// Normal user cancellation is not an error
|
|
20
|
-
if (err instanceof Error && err.name === 'UserCancelledError') {
|
|
21
|
-
process.exit(130);
|
|
22
|
-
}
|
|
23
|
-
console.error(err);
|
|
24
|
-
process.exit(1);
|
|
25
|
-
});
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { showBanner } from './consoleBanner';
|
|
3
|
+
import { promptAndRunPlaywrightTests } from './testRunner';
|
|
4
|
+
import { KnowledgeService } from './KnowledgeService';
|
|
5
|
+
import { ArcalityClient } from './arcalityClient';
|
|
6
|
+
import { loadConfig } from './configLoader';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
showBanner();
|
|
10
|
+
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const arcalityClient = new ArcalityClient(config.ARCALITY_API_KEY);
|
|
13
|
+
const knowledgeService = KnowledgeService.getInstance();
|
|
14
|
+
|
|
15
|
+
await promptAndRunPlaywrightTests(arcalityClient, knowledgeService);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main().catch((err) => {
|
|
19
|
+
// Normal user cancellation is not an error
|
|
20
|
+
if (err instanceof Error && err.name === 'UserCancelledError') {
|
|
21
|
+
process.exit(130);
|
|
22
|
+
}
|
|
23
|
+
console.error(err);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -30,7 +30,7 @@ function isConfigured(): boolean {
|
|
|
30
30
|
|
|
31
31
|
// ─── 1. REGLAS DE UI / NEGOCIO ──────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
-
export type RuleType = 'UI_UX' | 'VALIDATION' | 'NAVIGATION' | 'BUSINESS';
|
|
33
|
+
export type RuleType = 'UI_UX' | 'VALIDATION' | 'NAVIGATION' | 'BUSINESS' | 'SECURITY';
|
|
34
34
|
export type Severity = 'critical' | 'important' | 'suggestion';
|
|
35
35
|
|
|
36
36
|
export async function pushRule(rule: {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Page } from '@playwright/test';
|
|
2
|
+
import {
|
|
3
|
+
SecurityFinding,
|
|
4
|
+
audit_http_headers,
|
|
5
|
+
scan_sensitive_data_exposure,
|
|
6
|
+
// test_auth_bypass can be added here if there are common protected routes to check.
|
|
7
|
+
} from '../../tests/_helpers/qa-security-tools';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The main service for running automated security scans at different points in the test lifecycle.
|
|
11
|
+
*/
|
|
12
|
+
export class SecurityScanner {
|
|
13
|
+
private page: Page;
|
|
14
|
+
private report: SecurityFinding[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(page: Page) {
|
|
17
|
+
this.page = page;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs a series of automated scans against the current state of the page.
|
|
22
|
+
* This is intended to be called at the end of a test run to aggregate findings.
|
|
23
|
+
* @returns A promise that resolves to an array of found vulnerabilities.
|
|
24
|
+
*/
|
|
25
|
+
async runAllScans(): Promise<SecurityFinding[]> {
|
|
26
|
+
console.log('[QA-SEC] Starting comprehensive security scan...');
|
|
27
|
+
|
|
28
|
+
// Scan 1: Audit HTTP Security Headers
|
|
29
|
+
const headerVulnerabilities = await audit_http_headers(this.page);
|
|
30
|
+
this.report.push(...headerVulnerabilities);
|
|
31
|
+
|
|
32
|
+
// Scan 2: Scan for sensitive data in browser storage
|
|
33
|
+
const storageVulnerabilities = await scan_sensitive_data_exposure(this.page);
|
|
34
|
+
this.report.push(...storageVulnerabilities);
|
|
35
|
+
|
|
36
|
+
// Scan 3: Check for open redirect vulnerabilities (basic check)
|
|
37
|
+
await this.testOpenRedirect();
|
|
38
|
+
|
|
39
|
+
// More scans like auth bypass for known admin URLs could be added here.
|
|
40
|
+
|
|
41
|
+
console.log(`[QA-SEC] Comprehensive scan finished. Found ${this.report.length} potential vulnerabilities.`);
|
|
42
|
+
return this.getReport();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A basic test for open redirect vulnerabilities on the current URL.
|
|
47
|
+
*/
|
|
48
|
+
async testOpenRedirect(): Promise<void> {
|
|
49
|
+
const url = new URL(this.page.url());
|
|
50
|
+
const redirectParams = ['redirect', 'next', 'url', 'returnTo', 'dest'];
|
|
51
|
+
|
|
52
|
+
for (const param of redirectParams) {
|
|
53
|
+
if (url.searchParams.has(param)) {
|
|
54
|
+
const originalValue = url.searchParams.get(param);
|
|
55
|
+
const payload = 'https://www.evil-redirect.com';
|
|
56
|
+
url.searchParams.set(param, payload);
|
|
57
|
+
|
|
58
|
+
console.log(`[QA-SEC] Testing Open Redirect on param: ${param}`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await this.page.goto(url.toString(), { waitUntil: 'networkidle' });
|
|
62
|
+
|
|
63
|
+
if (this.page.url().startsWith(payload)) {
|
|
64
|
+
const vulnerability: SecurityFinding = {
|
|
65
|
+
type: 'confirmed_vulnerability',
|
|
66
|
+
category: 'Open Redirect',
|
|
67
|
+
severity: 'Medium',
|
|
68
|
+
confidence: 'High',
|
|
69
|
+
exploitability: 'confirmed',
|
|
70
|
+
status: 'detected',
|
|
71
|
+
description: `The application is vulnerable to Open Redirect. The '${param}' parameter was manipulated to redirect the user to an external, malicious site.`,
|
|
72
|
+
impact: 'Attackers can use this to craft phishing links that appear to come from the trusted domain but redirect to malicious sites.',
|
|
73
|
+
evidence: {
|
|
74
|
+
vulnerableUrl: url.toString(),
|
|
75
|
+
parameter: param,
|
|
76
|
+
payload: payload,
|
|
77
|
+
},
|
|
78
|
+
reproductionSteps: [
|
|
79
|
+
`Navigate to: ${url.toString()}`,
|
|
80
|
+
"Observe that the page redirects to the payload domain."
|
|
81
|
+
],
|
|
82
|
+
remediation: 'Validate all redirection URLs against a whitelist of allowed domains. Do not blindly redirect to user-supplied URLs.',
|
|
83
|
+
classification: {
|
|
84
|
+
cwe: 'CWE-601'
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
this.report.push(vulnerability);
|
|
88
|
+
// Go back to the original page to continue other tests
|
|
89
|
+
await this.page.goBack();
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.log(`[QA-SEC] Error during open redirect test, this might be expected if the navigation was blocked. Continuing scan.`);
|
|
93
|
+
// It's possible the navigation to the evil site fails, which is good.
|
|
94
|
+
// We should ensure we can recover and continue.
|
|
95
|
+
await this.page.goBack(); // Try to recover state
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Adds a vulnerability to the report. Can be used by other parts of the system
|
|
104
|
+
* to feed into this scanner's report.
|
|
105
|
+
* @param vuln - The vulnerability to add.
|
|
106
|
+
*/
|
|
107
|
+
addVulnerability(vuln: SecurityFinding) {
|
|
108
|
+
this.report.push(vuln);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the final list of vulnerabilities.
|
|
113
|
+
*/
|
|
114
|
+
getReport(): SecurityFinding[] {
|
|
115
|
+
// Deduplicate vulnerabilities to avoid noise in the report
|
|
116
|
+
const uniqueReport: SecurityFinding[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
|
|
119
|
+
for (const vuln of this.report) {
|
|
120
|
+
const key = `${vuln.category}|${vuln.description}|${vuln.evidence.selector || ''}`;
|
|
121
|
+
if (!seen.has(key)) {
|
|
122
|
+
uniqueReport.push(vuln);
|
|
123
|
+
seen.add(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return uniqueReport;
|
|
127
|
+
}
|
|
128
|
+
}
|