agent-vault-cli 0.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/.cursor/skills/npm-publish/SKILL.md +58 -0
- package/.github/workflows/ci.yml +67 -0
- package/README.md +164 -0
- package/ROADMAP.md +986 -0
- package/dist/commands/config.d.ts +8 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +67 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/delete.d.ts +7 -0
- package/dist/commands/delete.d.ts.map +1 -0
- package/dist/commands/delete.js +30 -0
- package/dist/commands/delete.js.map +1 -0
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +37 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/register.d.ts +13 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +160 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/core/audit.d.ts +15 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +36 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/browser.d.ts +7 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +104 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +80 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/crypto.d.ts +17 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +90 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/fields.d.ts +5 -0
- package/dist/core/fields.d.ts.map +1 -0
- package/dist/core/fields.js +54 -0
- package/dist/core/fields.js.map +1 -0
- package/dist/core/keychain.d.ts +5 -0
- package/dist/core/keychain.d.ts.map +1 -0
- package/dist/core/keychain.js +97 -0
- package/dist/core/keychain.js.map +1 -0
- package/dist/core/origin.d.ts +25 -0
- package/dist/core/origin.d.ts.map +1 -0
- package/dist/core/origin.js +73 -0
- package/dist/core/origin.js.map +1 -0
- package/dist/core/ratelimit.d.ts +10 -0
- package/dist/core/ratelimit.d.ts.map +1 -0
- package/dist/core/ratelimit.js +70 -0
- package/dist/core/ratelimit.js.map +1 -0
- package/dist/core/secure-memory.d.ts +39 -0
- package/dist/core/secure-memory.d.ts.map +1 -0
- package/dist/core/secure-memory.js +68 -0
- package/dist/core/secure-memory.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +129 -0
- package/dist/index.js.map +1 -0
- package/dist/types/index.d.ts +27 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +58 -0
- package/src/commands/config.ts +84 -0
- package/src/commands/delete.ts +39 -0
- package/src/commands/login.ts +49 -0
- package/src/commands/register.ts +188 -0
- package/src/core/audit.ts +59 -0
- package/src/core/browser.ts +131 -0
- package/src/core/config.ts +91 -0
- package/src/core/crypto.ts +106 -0
- package/src/core/fields.ts +59 -0
- package/src/core/keychain.ts +110 -0
- package/src/core/origin.ts +90 -0
- package/src/core/ratelimit.ts +89 -0
- package/src/core/secure-memory.ts +78 -0
- package/src/index.ts +133 -0
- package/src/types/index.ts +31 -0
- package/tests/browser-password-manager.test.ts +1023 -0
- package/tests/crypto.test.ts +140 -0
- package/tests/e2e.test.ts +565 -0
- package/tests/fixtures/server.ts +59 -0
- package/tests/security.test.ts +113 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const AUDIT_DIR = join(homedir(), '.agent-vault');
|
|
6
|
+
const AUDIT_FILE = join(AUDIT_DIR, 'audit.log');
|
|
7
|
+
|
|
8
|
+
export type AuditEvent =
|
|
9
|
+
| 'credential_stored'
|
|
10
|
+
| 'credential_retrieved'
|
|
11
|
+
| 'credential_deleted'
|
|
12
|
+
| 'login_filled'
|
|
13
|
+
| 'config_changed'
|
|
14
|
+
| 'rate_limit_exceeded';
|
|
15
|
+
|
|
16
|
+
interface AuditEntry {
|
|
17
|
+
timestamp: string;
|
|
18
|
+
event: AuditEvent;
|
|
19
|
+
origin?: string;
|
|
20
|
+
details?: string;
|
|
21
|
+
success: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function ensureAuditDir(): Promise<void> {
|
|
25
|
+
await mkdir(AUDIT_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Log an audit event to the audit log file.
|
|
30
|
+
* Never includes sensitive data like passwords or usernames.
|
|
31
|
+
*/
|
|
32
|
+
export async function logAuditEvent(
|
|
33
|
+
event: AuditEvent,
|
|
34
|
+
options: { origin?: string; details?: string; success?: boolean } = {}
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await ensureAuditDir();
|
|
38
|
+
|
|
39
|
+
const entry: AuditEntry = {
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
event,
|
|
42
|
+
origin: options.origin,
|
|
43
|
+
details: options.details,
|
|
44
|
+
success: options.success ?? true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const line = JSON.stringify(entry) + '\n';
|
|
48
|
+
await appendFile(AUDIT_FILE, line, { mode: 0o600 });
|
|
49
|
+
} catch {
|
|
50
|
+
// Silently fail - audit logging should never break the main flow
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the audit log file path (for admin/debug purposes)
|
|
56
|
+
*/
|
|
57
|
+
export function getAuditLogPath(): string {
|
|
58
|
+
return AUDIT_FILE;
|
|
59
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import type { BrowserConnection } from '../types/index.js';
|
|
3
|
+
import { getConfigValue } from './config.js';
|
|
4
|
+
|
|
5
|
+
const CDP_TIMEOUT_MS = 10000;
|
|
6
|
+
|
|
7
|
+
// Default allowed CDP hosts for security
|
|
8
|
+
const DEFAULT_CDP_ALLOWLIST = ['127.0.0.1', 'localhost', '::1'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse CDP allowlist from config
|
|
12
|
+
*/
|
|
13
|
+
async function getCdpAllowlist(): Promise<string[]> {
|
|
14
|
+
const configValue = await getConfigValue('cdpAllowlist');
|
|
15
|
+
if (configValue) {
|
|
16
|
+
return configValue.split(',').map((h) => h.trim().toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
return DEFAULT_CDP_ALLOWLIST;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a CDP endpoint host is in the allowlist
|
|
23
|
+
*/
|
|
24
|
+
async function isAllowedCdpHost(hostname: string): Promise<boolean> {
|
|
25
|
+
const allowlist = await getCdpAllowlist();
|
|
26
|
+
return allowlist.includes(hostname.toLowerCase());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function validateCDPEndpoint(endpoint: string): Promise<void> {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(endpoint);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error('Invalid CDP endpoint format');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
|
38
|
+
throw new Error('CDP endpoint must use WebSocket protocol');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!url.hostname) {
|
|
42
|
+
throw new Error('CDP endpoint must have a valid hostname');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check against allowlist
|
|
46
|
+
const isAllowed = await isAllowedCdpHost(url.hostname);
|
|
47
|
+
if (!isAllowed) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
'CDP endpoint host is not in the allowlist. ' +
|
|
50
|
+
'Add it with: vault config set cdpAllowlist "host1,host2"'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function connectToBrowser(cdpEndpoint: string): Promise<BrowserConnection> {
|
|
56
|
+
await validateCDPEndpoint(cdpEndpoint);
|
|
57
|
+
|
|
58
|
+
let browser;
|
|
59
|
+
try {
|
|
60
|
+
browser = await Promise.race([
|
|
61
|
+
chromium.connectOverCDP(cdpEndpoint),
|
|
62
|
+
new Promise<never>((_, reject) =>
|
|
63
|
+
setTimeout(() => reject(new Error('CDP connection timeout')), CDP_TIMEOUT_MS)
|
|
64
|
+
),
|
|
65
|
+
]);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Sanitize connection errors - don't expose internal details
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
if (error.message.includes('timeout')) {
|
|
70
|
+
throw new Error('CDP connection timeout - ensure browser is running with remote debugging');
|
|
71
|
+
}
|
|
72
|
+
if (error.message.includes('ECONNREFUSED')) {
|
|
73
|
+
throw new Error('Cannot connect to CDP endpoint - ensure browser is running');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new Error('Failed to connect to browser');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contexts = browser.contexts();
|
|
80
|
+
|
|
81
|
+
if (contexts.length === 0) {
|
|
82
|
+
await browser.close();
|
|
83
|
+
throw new Error('No browser context available');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pages = contexts[0].pages();
|
|
87
|
+
|
|
88
|
+
if (pages.length === 0) {
|
|
89
|
+
await browser.close();
|
|
90
|
+
throw new Error('No browser page available');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { browser, page: pages[0] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function fillField(
|
|
97
|
+
page: BrowserConnection['page'],
|
|
98
|
+
selector: string,
|
|
99
|
+
value: string
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
await page.fill(selector, value);
|
|
103
|
+
} catch {
|
|
104
|
+
// Sanitize selector errors - don't expose selector in error message
|
|
105
|
+
throw new Error('Failed to fill form field - selector may be invalid');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function clickElement(
|
|
110
|
+
page: BrowserConnection['page'],
|
|
111
|
+
selector: string
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
try {
|
|
114
|
+
await page.click(selector);
|
|
115
|
+
} catch {
|
|
116
|
+
// Sanitize selector errors
|
|
117
|
+
throw new Error('Failed to click element - selector may be invalid');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function validateSelector(
|
|
122
|
+
page: BrowserConnection['page'],
|
|
123
|
+
selector: string
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
try {
|
|
126
|
+
const element = await page.$(selector);
|
|
127
|
+
return element !== null;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, chmod, access, constants } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { lock, unlock } from 'proper-lockfile';
|
|
5
|
+
import type { VaultConfig, ConfigKey } from '../types/index.js';
|
|
6
|
+
import { logAuditEvent } from './audit.js';
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = join(homedir(), '.agent-vault');
|
|
9
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
10
|
+
|
|
11
|
+
// Secure file permissions: owner read/write only
|
|
12
|
+
const SECURE_DIR_MODE = 0o700;
|
|
13
|
+
const SECURE_FILE_MODE = 0o600;
|
|
14
|
+
|
|
15
|
+
async function ensureConfigDir(): Promise<void> {
|
|
16
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: SECURE_DIR_MODE });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await access(path, constants.F_OK);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadConfig(): Promise<VaultConfig> {
|
|
29
|
+
try {
|
|
30
|
+
const data = await readFile(CONFIG_FILE, 'utf-8');
|
|
31
|
+
return JSON.parse(data);
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function saveConfig(config: VaultConfig): Promise<void> {
|
|
38
|
+
await ensureConfigDir();
|
|
39
|
+
|
|
40
|
+
const exists = await fileExists(CONFIG_FILE);
|
|
41
|
+
|
|
42
|
+
// Create empty file if it doesn't exist (for locking)
|
|
43
|
+
if (!exists) {
|
|
44
|
+
await writeFile(CONFIG_FILE, '{}', { mode: SECURE_FILE_MODE });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Use file locking to prevent race conditions
|
|
48
|
+
let release: (() => Promise<void>) | null = null;
|
|
49
|
+
try {
|
|
50
|
+
release = await lock(CONFIG_FILE, { retries: 3 });
|
|
51
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
|
|
52
|
+
// Ensure permissions are set correctly
|
|
53
|
+
await chmod(CONFIG_FILE, SECURE_FILE_MODE);
|
|
54
|
+
} finally {
|
|
55
|
+
if (release) {
|
|
56
|
+
await release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getConfigValue(key: ConfigKey): Promise<string | undefined> {
|
|
62
|
+
const config = await loadConfig();
|
|
63
|
+
return config[key];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function setConfigValue(key: ConfigKey, value: string): Promise<void> {
|
|
67
|
+
const config = await loadConfig();
|
|
68
|
+
config[key] = value;
|
|
69
|
+
await saveConfig(config);
|
|
70
|
+
await logAuditEvent('config_changed', { details: `Key: ${key}` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function unsetConfigValue(key: ConfigKey): Promise<boolean> {
|
|
74
|
+
const config = await loadConfig();
|
|
75
|
+
if (key in config) {
|
|
76
|
+
delete config[key];
|
|
77
|
+
await saveConfig(config);
|
|
78
|
+
await logAuditEvent('config_changed', { details: `Key removed: ${key}` });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isValidConfigKey(key: string): key is ConfigKey {
|
|
85
|
+
const validKeys: ConfigKey[] = ['defaultUsername', 'allowHttp', 'cdpAllowlist'];
|
|
86
|
+
return validKeys.includes(key as ConfigKey);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getValidConfigKeys(): ConfigKey[] {
|
|
90
|
+
return ['defaultUsername', 'allowHttp', 'cdpAllowlist'];
|
|
91
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { customAlphabet } from 'nanoid';
|
|
3
|
+
|
|
4
|
+
const PASSWORD_LENGTH = 24;
|
|
5
|
+
const MIN_PASSWORD_LENGTH = 12;
|
|
6
|
+
const PASSWORD_CHARSET =
|
|
7
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
|
8
|
+
|
|
9
|
+
const CHAR_CLASSES = {
|
|
10
|
+
lowercase: 'abcdefghijklmnopqrstuvwxyz',
|
|
11
|
+
uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
|
12
|
+
digits: '0123456789',
|
|
13
|
+
special: '!@#$%^&*',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cryptographically secure random character selection
|
|
18
|
+
*/
|
|
19
|
+
function secureRandomChar(charset: string): string {
|
|
20
|
+
return charset[randomInt(charset.length)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cryptographically secure Fisher-Yates shuffle
|
|
25
|
+
*/
|
|
26
|
+
function secureShuffleArray<T>(array: T[]): T[] {
|
|
27
|
+
const result = [...array];
|
|
28
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
29
|
+
const j = randomInt(i + 1);
|
|
30
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if password meets complexity requirements
|
|
37
|
+
*/
|
|
38
|
+
function meetsComplexityRequirements(password: string): boolean {
|
|
39
|
+
return (
|
|
40
|
+
/[a-z]/.test(password) &&
|
|
41
|
+
/[A-Z]/.test(password) &&
|
|
42
|
+
/[0-9]/.test(password) &&
|
|
43
|
+
/[!@#$%^&*]/.test(password)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a cryptographically secure password using Node.js crypto.randomInt
|
|
49
|
+
* Guarantees at least one character from each class (lowercase, uppercase, digit, special)
|
|
50
|
+
*
|
|
51
|
+
* @param length - Password length (minimum 12, default 24)
|
|
52
|
+
* @throws Error if length is less than minimum
|
|
53
|
+
*/
|
|
54
|
+
export function generatePassword(length: number = PASSWORD_LENGTH): string {
|
|
55
|
+
if (length < MIN_PASSWORD_LENGTH) {
|
|
56
|
+
throw new Error(`Password length must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Start with one guaranteed character from each class
|
|
60
|
+
const chars = [
|
|
61
|
+
secureRandomChar(CHAR_CLASSES.lowercase),
|
|
62
|
+
secureRandomChar(CHAR_CLASSES.uppercase),
|
|
63
|
+
secureRandomChar(CHAR_CLASSES.digits),
|
|
64
|
+
secureRandomChar(CHAR_CLASSES.special),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Fill remaining length with random characters from full charset
|
|
68
|
+
while (chars.length < length) {
|
|
69
|
+
chars.push(secureRandomChar(PASSWORD_CHARSET));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Cryptographically secure shuffle to randomize position of guaranteed chars
|
|
73
|
+
return secureShuffleArray(chars).join('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Alternative password generator using nanoid's customAlphabet
|
|
78
|
+
* Regenerates until complexity requirements are met
|
|
79
|
+
*
|
|
80
|
+
* @param length - Password length (minimum 12, default 24)
|
|
81
|
+
* @throws Error if length is less than minimum
|
|
82
|
+
*/
|
|
83
|
+
export function generatePasswordNanoid(length: number = PASSWORD_LENGTH): string {
|
|
84
|
+
if (length < MIN_PASSWORD_LENGTH) {
|
|
85
|
+
throw new Error(`Password length must be at least ${MIN_PASSWORD_LENGTH} characters`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const generate = customAlphabet(PASSWORD_CHARSET, length);
|
|
89
|
+
|
|
90
|
+
// Generate until we meet complexity requirements
|
|
91
|
+
// With 24 chars from a 70-char alphabet including all classes,
|
|
92
|
+
// probability of missing a class is extremely low (~0.01%)
|
|
93
|
+
let password: string;
|
|
94
|
+
let attempts = 0;
|
|
95
|
+
const maxAttempts = 100;
|
|
96
|
+
|
|
97
|
+
do {
|
|
98
|
+
password = generate();
|
|
99
|
+
attempts++;
|
|
100
|
+
if (attempts >= maxAttempts) {
|
|
101
|
+
throw new Error('Failed to generate compliant password after maximum attempts');
|
|
102
|
+
}
|
|
103
|
+
} while (!meetsComplexityRequirements(password));
|
|
104
|
+
|
|
105
|
+
return password;
|
|
106
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
|
|
3
|
+
const USERNAME_SELECTORS = [
|
|
4
|
+
'input[type="email"]',
|
|
5
|
+
'input[name="email"]',
|
|
6
|
+
'input[autocomplete="username"]',
|
|
7
|
+
'input[autocomplete="email"]',
|
|
8
|
+
'input[name="username"]',
|
|
9
|
+
'input[id="username"]',
|
|
10
|
+
'input[id="email"]',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const PASSWORD_SELECTORS = [
|
|
14
|
+
'input[type="password"]',
|
|
15
|
+
'input[autocomplete="current-password"]',
|
|
16
|
+
'input[autocomplete="new-password"]',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const SUBMIT_SELECTORS = [
|
|
20
|
+
'button[type="submit"]',
|
|
21
|
+
'input[type="submit"]',
|
|
22
|
+
'button:has-text("Log in")',
|
|
23
|
+
'button:has-text("Sign in")',
|
|
24
|
+
'button:has-text("Login")',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export async function detectUsernameField(page: Page): Promise<string | null> {
|
|
28
|
+
for (const selector of USERNAME_SELECTORS) {
|
|
29
|
+
const element = await page.$(selector);
|
|
30
|
+
if (element && await element.isVisible()) {
|
|
31
|
+
return selector;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function detectPasswordField(page: Page): Promise<string | null> {
|
|
38
|
+
for (const selector of PASSWORD_SELECTORS) {
|
|
39
|
+
const element = await page.$(selector);
|
|
40
|
+
if (element && await element.isVisible()) {
|
|
41
|
+
return selector;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function detectSubmitButton(page: Page): Promise<string | null> {
|
|
48
|
+
for (const selector of SUBMIT_SELECTORS) {
|
|
49
|
+
try {
|
|
50
|
+
const element = await page.$(selector);
|
|
51
|
+
if (element && await element.isVisible()) {
|
|
52
|
+
return selector;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Some selectors might not be valid, continue
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import keytar from 'keytar';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { RPConfig } from '../types/index.js';
|
|
4
|
+
import { logAuditEvent } from './audit.js';
|
|
5
|
+
import { checkRateLimit } from './ratelimit.js';
|
|
6
|
+
|
|
7
|
+
const SERVICE_NAME = 'agent-vault';
|
|
8
|
+
|
|
9
|
+
// Zod schema for strict validation of stored credentials
|
|
10
|
+
const SelectorsSchema = z.object({
|
|
11
|
+
username: z.string().min(1),
|
|
12
|
+
password: z.string().min(1),
|
|
13
|
+
submit: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const CredentialsSchema = z.object({
|
|
17
|
+
username: z.string(),
|
|
18
|
+
password: z.string(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const RPConfigSchema = z.object({
|
|
22
|
+
origin: z.string().url(),
|
|
23
|
+
selectors: SelectorsSchema,
|
|
24
|
+
credentials: CredentialsSchema,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate RPConfig data against schema
|
|
29
|
+
*/
|
|
30
|
+
function validateRPConfig(data: unknown): RPConfig | null {
|
|
31
|
+
const result = RPConfigSchema.safeParse(data);
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return result.data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function storeRP(config: RPConfig): Promise<void> {
|
|
39
|
+
// Rate limit credential storage
|
|
40
|
+
await checkRateLimit('store_credentials');
|
|
41
|
+
|
|
42
|
+
// Validate config before storing
|
|
43
|
+
const validated = validateRPConfig(config);
|
|
44
|
+
if (!validated) {
|
|
45
|
+
throw new Error('Invalid credential configuration');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await keytar.setPassword(SERVICE_NAME, config.origin, JSON.stringify(validated));
|
|
49
|
+
await logAuditEvent('credential_stored', { origin: config.origin });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function getRP(origin: string): Promise<RPConfig | null> {
|
|
53
|
+
try {
|
|
54
|
+
// Rate limit credential retrieval
|
|
55
|
+
await checkRateLimit('get_credentials');
|
|
56
|
+
|
|
57
|
+
const data = await keytar.getPassword(SERVICE_NAME, origin);
|
|
58
|
+
if (!data) {
|
|
59
|
+
await logAuditEvent('credential_retrieved', { origin, success: false });
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(data);
|
|
66
|
+
} catch {
|
|
67
|
+
// Corrupted JSON data
|
|
68
|
+
await logAuditEvent('credential_retrieved', {
|
|
69
|
+
origin,
|
|
70
|
+
details: 'Corrupted data',
|
|
71
|
+
success: false,
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Validate with zod schema
|
|
77
|
+
const validated = validateRPConfig(parsed);
|
|
78
|
+
if (!validated) {
|
|
79
|
+
await logAuditEvent('credential_retrieved', {
|
|
80
|
+
origin,
|
|
81
|
+
details: 'Schema validation failed',
|
|
82
|
+
success: false,
|
|
83
|
+
});
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await logAuditEvent('credential_retrieved', { origin, success: true });
|
|
88
|
+
return validated;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// Don't expose internal errors - use generic message
|
|
91
|
+
if (error instanceof Error && error.message.includes('Rate limit')) {
|
|
92
|
+
throw error; // Re-throw rate limit errors
|
|
93
|
+
}
|
|
94
|
+
await logAuditEvent('credential_retrieved', {
|
|
95
|
+
origin,
|
|
96
|
+
details: 'Internal error',
|
|
97
|
+
success: false,
|
|
98
|
+
});
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function deleteRP(origin: string): Promise<boolean> {
|
|
104
|
+
// Rate limit credential deletion
|
|
105
|
+
await checkRateLimit('delete_credentials');
|
|
106
|
+
|
|
107
|
+
const result = await keytar.deletePassword(SERVICE_NAME, origin);
|
|
108
|
+
await logAuditEvent('credential_deleted', { origin, success: result });
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getConfigValue } from './config.js';
|
|
2
|
+
|
|
3
|
+
export function extractOrigin(url: string): string {
|
|
4
|
+
const parsed = new URL(url);
|
|
5
|
+
return parsed.origin;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if origin uses HTTPS (secure)
|
|
10
|
+
*/
|
|
11
|
+
export function isSecureProtocol(origin: string): boolean {
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(origin);
|
|
14
|
+
return url.protocol === 'https:';
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if origin uses HTTP (insecure)
|
|
22
|
+
*/
|
|
23
|
+
export function isHttpProtocol(origin: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(origin);
|
|
26
|
+
return url.protocol === 'http:';
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isValidOrigin(origin: string): boolean {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(origin);
|
|
35
|
+
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OriginValidationOptions {
|
|
42
|
+
allowHttp?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate an origin with security checks.
|
|
47
|
+
* By default, requires HTTPS to prevent credentials from being sent in plaintext.
|
|
48
|
+
*/
|
|
49
|
+
export async function validateOriginSecurity(
|
|
50
|
+
origin: string,
|
|
51
|
+
options: OriginValidationOptions = {}
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
// Check config for allowHttp setting
|
|
54
|
+
const configAllowHttp = await getConfigValue('allowHttp');
|
|
55
|
+
const allowHttp = options.allowHttp ?? configAllowHttp === 'true';
|
|
56
|
+
|
|
57
|
+
// Check HTTPS requirement
|
|
58
|
+
if (!allowHttp && isHttpProtocol(origin)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'HTTP origins are not allowed by default. Credentials would be sent in plaintext. ' +
|
|
61
|
+
'Use --allow-http flag or set config allowHttp=true to override.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validate basic origin format
|
|
66
|
+
if (!isValidOrigin(origin)) {
|
|
67
|
+
throw new Error('Invalid origin format');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function extractAndValidateOrigin(url: string): string {
|
|
72
|
+
const origin = extractOrigin(url);
|
|
73
|
+
if (!isValidOrigin(origin)) {
|
|
74
|
+
throw new Error('Invalid page origin');
|
|
75
|
+
}
|
|
76
|
+
return origin;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract and validate origin with full security checks.
|
|
81
|
+
* Use this for operations that store or retrieve credentials.
|
|
82
|
+
*/
|
|
83
|
+
export async function extractAndValidateOriginSecure(
|
|
84
|
+
url: string,
|
|
85
|
+
options: OriginValidationOptions = {}
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const origin = extractOrigin(url);
|
|
88
|
+
await validateOriginSecurity(origin, options);
|
|
89
|
+
return origin;
|
|
90
|
+
}
|