@sonde/agent 0.0.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/cli/packs.d.ts +23 -0
- package/dist/cli/packs.d.ts.map +1 -0
- package/dist/cli/packs.js +172 -0
- package/dist/cli/packs.js.map +1 -0
- package/dist/cli/packs.test.d.ts +2 -0
- package/dist/cli/packs.test.d.ts.map +1 -0
- package/dist/cli/packs.test.js +171 -0
- package/dist/cli/packs.test.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +191 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/attestation.d.ts +9 -0
- package/dist/runtime/attestation.d.ts.map +1 -0
- package/dist/runtime/attestation.js +32 -0
- package/dist/runtime/attestation.js.map +1 -0
- package/dist/runtime/attestation.test.d.ts +2 -0
- package/dist/runtime/attestation.test.d.ts.map +1 -0
- package/dist/runtime/attestation.test.js +59 -0
- package/dist/runtime/attestation.test.js.map +1 -0
- package/dist/runtime/audit.d.ts +19 -0
- package/dist/runtime/audit.d.ts.map +1 -0
- package/dist/runtime/audit.js +52 -0
- package/dist/runtime/audit.js.map +1 -0
- package/dist/runtime/audit.test.d.ts +2 -0
- package/dist/runtime/audit.test.d.ts.map +1 -0
- package/dist/runtime/audit.test.js +53 -0
- package/dist/runtime/audit.test.js.map +1 -0
- package/dist/runtime/connection.d.ts +55 -0
- package/dist/runtime/connection.d.ts.map +1 -0
- package/dist/runtime/connection.js +325 -0
- package/dist/runtime/connection.js.map +1 -0
- package/dist/runtime/connection.test.d.ts +2 -0
- package/dist/runtime/connection.test.d.ts.map +1 -0
- package/dist/runtime/connection.test.js +221 -0
- package/dist/runtime/connection.test.js.map +1 -0
- package/dist/runtime/executor.d.ts +21 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +89 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/executor.test.d.ts +2 -0
- package/dist/runtime/executor.test.d.ts.map +1 -0
- package/dist/runtime/executor.test.js +88 -0
- package/dist/runtime/executor.test.js.map +1 -0
- package/dist/runtime/privilege.d.ts +9 -0
- package/dist/runtime/privilege.d.ts.map +1 -0
- package/dist/runtime/privilege.js +35 -0
- package/dist/runtime/privilege.js.map +1 -0
- package/dist/runtime/privilege.test.d.ts +2 -0
- package/dist/runtime/privilege.test.d.ts.map +1 -0
- package/dist/runtime/privilege.test.js +22 -0
- package/dist/runtime/privilege.test.js.map +1 -0
- package/dist/runtime/scrubber.d.ts +17 -0
- package/dist/runtime/scrubber.d.ts.map +1 -0
- package/dist/runtime/scrubber.js +84 -0
- package/dist/runtime/scrubber.js.map +1 -0
- package/dist/runtime/scrubber.test.d.ts +2 -0
- package/dist/runtime/scrubber.test.d.ts.map +1 -0
- package/dist/runtime/scrubber.test.js +72 -0
- package/dist/runtime/scrubber.test.js.map +1 -0
- package/dist/system/scanner.d.ts +32 -0
- package/dist/system/scanner.d.ts.map +1 -0
- package/dist/system/scanner.js +90 -0
- package/dist/system/scanner.js.map +1 -0
- package/dist/system/scanner.test.d.ts +2 -0
- package/dist/system/scanner.test.d.ts.map +1 -0
- package/dist/system/scanner.test.js +121 -0
- package/dist/system/scanner.test.js.map +1 -0
- package/dist/tui/installer/InstallerApp.d.ts +11 -0
- package/dist/tui/installer/InstallerApp.d.ts.map +1 -0
- package/dist/tui/installer/InstallerApp.js +32 -0
- package/dist/tui/installer/InstallerApp.js.map +1 -0
- package/dist/tui/installer/StepComplete.d.ts +9 -0
- package/dist/tui/installer/StepComplete.d.ts.map +1 -0
- package/dist/tui/installer/StepComplete.js +46 -0
- package/dist/tui/installer/StepComplete.js.map +1 -0
- package/dist/tui/installer/StepHub.d.ts +8 -0
- package/dist/tui/installer/StepHub.d.ts.map +1 -0
- package/dist/tui/installer/StepHub.js +65 -0
- package/dist/tui/installer/StepHub.js.map +1 -0
- package/dist/tui/installer/StepPacks.d.ts +9 -0
- package/dist/tui/installer/StepPacks.d.ts.map +1 -0
- package/dist/tui/installer/StepPacks.js +35 -0
- package/dist/tui/installer/StepPacks.js.map +1 -0
- package/dist/tui/installer/StepPermissions.d.ts +9 -0
- package/dist/tui/installer/StepPermissions.d.ts.map +1 -0
- package/dist/tui/installer/StepPermissions.js +39 -0
- package/dist/tui/installer/StepPermissions.js.map +1 -0
- package/dist/tui/installer/StepScan.d.ts +7 -0
- package/dist/tui/installer/StepScan.d.ts.map +1 -0
- package/dist/tui/installer/StepScan.js +38 -0
- package/dist/tui/installer/StepScan.js.map +1 -0
- package/dist/tui/manager/ActivityLog.d.ts +7 -0
- package/dist/tui/manager/ActivityLog.d.ts.map +1 -0
- package/dist/tui/manager/ActivityLog.js +25 -0
- package/dist/tui/manager/ActivityLog.js.map +1 -0
- package/dist/tui/manager/AuditView.d.ts +7 -0
- package/dist/tui/manager/AuditView.d.ts.map +1 -0
- package/dist/tui/manager/AuditView.js +32 -0
- package/dist/tui/manager/AuditView.js.map +1 -0
- package/dist/tui/manager/ManagerApp.d.ts +20 -0
- package/dist/tui/manager/ManagerApp.d.ts.map +1 -0
- package/dist/tui/manager/ManagerApp.js +79 -0
- package/dist/tui/manager/ManagerApp.js.map +1 -0
- package/dist/tui/manager/PackManager.d.ts +7 -0
- package/dist/tui/manager/PackManager.d.ts.map +1 -0
- package/dist/tui/manager/PackManager.js +22 -0
- package/dist/tui/manager/PackManager.js.map +1 -0
- package/dist/tui/manager/StatusView.d.ts +15 -0
- package/dist/tui/manager/StatusView.d.ts.map +1 -0
- package/dist/tui/manager/StatusView.js +10 -0
- package/dist/tui/manager/StatusView.js.map +1 -0
- package/package.json +45 -0
- package/scripts/install.sh +11 -0
- package/src/cli/packs.test.ts +213 -0
- package/src/cli/packs.ts +214 -0
- package/src/config.ts +62 -0
- package/src/index.ts +218 -0
- package/src/runtime/attestation.test.ts +69 -0
- package/src/runtime/attestation.ts +36 -0
- package/src/runtime/audit.test.ts +64 -0
- package/src/runtime/audit.ts +70 -0
- package/src/runtime/connection.test.ts +303 -0
- package/src/runtime/connection.ts +389 -0
- package/src/runtime/executor.test.ts +112 -0
- package/src/runtime/executor.ts +107 -0
- package/src/runtime/privilege.test.ts +25 -0
- package/src/runtime/privilege.ts +36 -0
- package/src/runtime/scrubber.test.ts +84 -0
- package/src/runtime/scrubber.ts +96 -0
- package/src/system/scanner.test.ts +154 -0
- package/src/system/scanner.ts +133 -0
- package/src/tui/installer/InstallerApp.tsx +86 -0
- package/src/tui/installer/StepComplete.tsx +94 -0
- package/src/tui/installer/StepHub.tsx +111 -0
- package/src/tui/installer/StepPacks.tsx +73 -0
- package/src/tui/installer/StepPermissions.tsx +104 -0
- package/src/tui/installer/StepScan.tsx +82 -0
- package/src/tui/manager/ActivityLog.tsx +57 -0
- package/src/tui/manager/AuditView.tsx +73 -0
- package/src/tui/manager/ManagerApp.tsx +157 -0
- package/src/tui/manager/PackManager.tsx +71 -0
- package/src/tui/manager/StatusView.tsx +103 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_SCRUB_PATTERNS, buildPatterns, scrubData } from './scrubber.js';
|
|
3
|
+
|
|
4
|
+
const patterns = DEFAULT_SCRUB_PATTERNS;
|
|
5
|
+
|
|
6
|
+
describe('scrubData', () => {
|
|
7
|
+
it('redacts env var secrets like DB_PASSWORD=hunter2', () => {
|
|
8
|
+
const result = scrubData('DB_PASSWORD=hunter2', patterns);
|
|
9
|
+
expect(result).toBe('DB_PASSWORD=[REDACTED]');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('redacts connection strings (keeps user and host, redacts password)', () => {
|
|
13
|
+
const input = 'postgresql://admin:s3cret@db.host:5432/mydb';
|
|
14
|
+
const result = scrubData(input, patterns);
|
|
15
|
+
expect(result).toBe('postgresql://admin:[REDACTED]@db.host:5432/mydb');
|
|
16
|
+
expect(result).not.toContain('s3cret');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('redacts Bearer tokens', () => {
|
|
20
|
+
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig';
|
|
21
|
+
const result = scrubData(input, patterns);
|
|
22
|
+
expect(result).toContain('Bearer [REDACTED]');
|
|
23
|
+
expect(result).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('redacts values of sensitive object keys', () => {
|
|
27
|
+
const input = { DB_PASSWORD: 'hunter2', host: 'localhost' };
|
|
28
|
+
const result = scrubData(input, patterns);
|
|
29
|
+
expect(result).toEqual({ DB_PASSWORD: '[REDACTED]', host: 'localhost' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('passes through numbers, booleans, and null unchanged', () => {
|
|
33
|
+
expect(scrubData(42, patterns)).toBe(42);
|
|
34
|
+
expect(scrubData(true, patterns)).toBe(true);
|
|
35
|
+
expect(scrubData(null, patterns)).toBe(null);
|
|
36
|
+
expect(scrubData(undefined, patterns)).toBe(undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles nested objects and arrays recursively', () => {
|
|
40
|
+
const input = {
|
|
41
|
+
config: {
|
|
42
|
+
secrets: [{ API_KEY: 'abc123xyz', name: 'prod' }, 'SECRET_TOKEN=my-token-value'],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const result = scrubData(input, patterns) as Record<string, unknown>;
|
|
46
|
+
const config = result.config as Record<string, unknown>;
|
|
47
|
+
const secrets = config.secrets as unknown[];
|
|
48
|
+
expect((secrets[0] as Record<string, unknown>).API_KEY).toBe('[REDACTED]');
|
|
49
|
+
expect((secrets[0] as Record<string, unknown>).name).toBe('prod');
|
|
50
|
+
expect(secrets[1]).toBe('SECRET_TOKEN=[REDACTED]');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('redacts generic API key patterns', () => {
|
|
54
|
+
const input = 'api_key=abcdef1234567890xx';
|
|
55
|
+
const result = scrubData(input, patterns);
|
|
56
|
+
expect(result).toBe('api_key=[REDACTED]');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not modify non-sensitive data', () => {
|
|
60
|
+
const input = { hostname: 'server-1', uptime: 12345, healthy: true };
|
|
61
|
+
const result = scrubData(input, patterns);
|
|
62
|
+
expect(result).toEqual(input);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('buildPatterns', () => {
|
|
67
|
+
it('includes default patterns when no custom regexes given', () => {
|
|
68
|
+
const result = buildPatterns();
|
|
69
|
+
expect(result.length).toBe(DEFAULT_SCRUB_PATTERNS.length);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('adds valid custom regex patterns', () => {
|
|
73
|
+
const result = buildPatterns(['SSN:\\s*\\d{3}-\\d{2}-\\d{4}']);
|
|
74
|
+
expect(result.length).toBe(DEFAULT_SCRUB_PATTERNS.length + 1);
|
|
75
|
+
|
|
76
|
+
const scrubbed = scrubData('SSN: 123-45-6789', result);
|
|
77
|
+
expect(scrubbed).toBe('[REDACTED]');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('skips invalid custom regexes gracefully', () => {
|
|
81
|
+
const result = buildPatterns(['[invalid-regex']);
|
|
82
|
+
expect(result.length).toBe(DEFAULT_SCRUB_PATTERNS.length);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface ScrubPattern {
|
|
2
|
+
name: string;
|
|
3
|
+
pattern: RegExp;
|
|
4
|
+
replacement?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Key names that indicate sensitive values (case-insensitive match) */
|
|
8
|
+
const SENSITIVE_KEY_RE = /(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL|PRIVATE)/i;
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SCRUB_PATTERNS: ScrubPattern[] = [
|
|
11
|
+
{
|
|
12
|
+
name: 'env-var-secrets',
|
|
13
|
+
pattern: /\b(\w*(?:SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL|PRIVATE)\w*)\s*[=:]\s*\S+/gi,
|
|
14
|
+
replacement: '$1=[REDACTED]',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'connection-strings',
|
|
18
|
+
pattern:
|
|
19
|
+
/((?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp|mssql):\/\/[^:]+:)[^@]+(@)/gi,
|
|
20
|
+
replacement: '$1[REDACTED]$2',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'bearer-tokens',
|
|
24
|
+
pattern: /(Bearer\s+)\S+/gi,
|
|
25
|
+
replacement: '$1[REDACTED]',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'generic-api-keys',
|
|
29
|
+
pattern: /(api[_-]?key|access[_-]?token|auth[_-]?token)\s*[=:]\s*["']?[\w\-./+=]{16,}["']?/gi,
|
|
30
|
+
replacement: '$1=[REDACTED]',
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deep-walk data, applying scrub patterns to all string values.
|
|
36
|
+
* Also redacts values of object keys that match sensitive key names.
|
|
37
|
+
*/
|
|
38
|
+
export function scrubData(data: unknown, patterns: ScrubPattern[]): unknown {
|
|
39
|
+
if (data === null || data === undefined) return data;
|
|
40
|
+
if (typeof data === 'boolean' || typeof data === 'number') return data;
|
|
41
|
+
|
|
42
|
+
if (typeof data === 'string') {
|
|
43
|
+
return scrubString(data, patterns);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (Array.isArray(data)) {
|
|
47
|
+
return data.map((item) => scrubData(item, patterns));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof data === 'object') {
|
|
51
|
+
const result: Record<string, unknown> = {};
|
|
52
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
53
|
+
if (SENSITIVE_KEY_RE.test(key) && typeof value === 'string') {
|
|
54
|
+
result[key] = '[REDACTED]';
|
|
55
|
+
} else {
|
|
56
|
+
result[key] = scrubData(value, patterns);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scrubString(str: string, patterns: ScrubPattern[]): string {
|
|
66
|
+
let result = str;
|
|
67
|
+
for (const p of patterns) {
|
|
68
|
+
// Reset lastIndex for global regexes
|
|
69
|
+
p.pattern.lastIndex = 0;
|
|
70
|
+
result = result.replace(p.pattern, p.replacement ?? '[REDACTED]');
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build scrub patterns from defaults + optional custom regex strings.
|
|
77
|
+
* Invalid custom regexes are silently skipped.
|
|
78
|
+
*/
|
|
79
|
+
export function buildPatterns(customRegexes?: string[]): ScrubPattern[] {
|
|
80
|
+
const patterns = [...DEFAULT_SCRUB_PATTERNS];
|
|
81
|
+
|
|
82
|
+
if (customRegexes) {
|
|
83
|
+
for (const raw of customRegexes) {
|
|
84
|
+
try {
|
|
85
|
+
patterns.push({
|
|
86
|
+
name: `custom:${raw}`,
|
|
87
|
+
pattern: new RegExp(raw, 'gi'),
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
// Invalid regex — skip gracefully
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return patterns;
|
|
96
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { PackManifest } from '@sonde/shared';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type SystemChecker, checkPackPermissions, scanForSoftware } from './scanner.js';
|
|
4
|
+
|
|
5
|
+
function createMockChecker(overrides: Partial<SystemChecker> = {}): SystemChecker {
|
|
6
|
+
return {
|
|
7
|
+
commandExists: () => false,
|
|
8
|
+
fileExists: () => false,
|
|
9
|
+
serviceExists: () => false,
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dockerManifest: PackManifest = {
|
|
15
|
+
name: 'docker',
|
|
16
|
+
version: '0.1.0',
|
|
17
|
+
description: 'Docker probes',
|
|
18
|
+
requires: { groups: ['docker'], files: [], commands: ['docker'] },
|
|
19
|
+
probes: [],
|
|
20
|
+
detect: { commands: ['docker'] },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const systemdManifest: PackManifest = {
|
|
24
|
+
name: 'systemd',
|
|
25
|
+
version: '0.1.0',
|
|
26
|
+
description: 'systemd probes',
|
|
27
|
+
requires: { groups: [], files: [], commands: ['systemctl'] },
|
|
28
|
+
probes: [],
|
|
29
|
+
detect: { files: ['/run/systemd/system'] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const noDetectManifest: PackManifest = {
|
|
33
|
+
name: 'custom',
|
|
34
|
+
version: '0.1.0',
|
|
35
|
+
description: 'No detect rules',
|
|
36
|
+
requires: { groups: [], files: [], commands: [] },
|
|
37
|
+
probes: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('scanForSoftware', () => {
|
|
41
|
+
it('detects software when command exists', () => {
|
|
42
|
+
const checker = createMockChecker({
|
|
43
|
+
commandExists: (cmd) => cmd === 'docker',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const results = scanForSoftware([dockerManifest], checker);
|
|
47
|
+
|
|
48
|
+
expect(results).toHaveLength(1);
|
|
49
|
+
expect(results[0]?.detected).toBe(true);
|
|
50
|
+
expect(results[0]?.matchedCommands).toEqual(['docker']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('detects software when file exists', () => {
|
|
54
|
+
const checker = createMockChecker({
|
|
55
|
+
fileExists: (p) => p === '/run/systemd/system',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const results = scanForSoftware([systemdManifest], checker);
|
|
59
|
+
|
|
60
|
+
expect(results).toHaveLength(1);
|
|
61
|
+
expect(results[0]?.detected).toBe(true);
|
|
62
|
+
expect(results[0]?.matchedFiles).toEqual(['/run/systemd/system']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('marks as not detected when no checks pass', () => {
|
|
66
|
+
const checker = createMockChecker();
|
|
67
|
+
|
|
68
|
+
const results = scanForSoftware([dockerManifest], checker);
|
|
69
|
+
|
|
70
|
+
expect(results[0]?.detected).toBe(false);
|
|
71
|
+
expect(results[0]?.matchedCommands).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('handles manifests without detect rules', () => {
|
|
75
|
+
const checker = createMockChecker();
|
|
76
|
+
|
|
77
|
+
const results = scanForSoftware([noDetectManifest], checker);
|
|
78
|
+
|
|
79
|
+
expect(results[0]?.detected).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('scans multiple manifests', () => {
|
|
83
|
+
const checker = createMockChecker({
|
|
84
|
+
commandExists: (cmd) => cmd === 'docker',
|
|
85
|
+
fileExists: () => false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const results = scanForSoftware([dockerManifest, systemdManifest, noDetectManifest], checker);
|
|
89
|
+
|
|
90
|
+
expect(results).toHaveLength(3);
|
|
91
|
+
expect(results[0]?.detected).toBe(true);
|
|
92
|
+
expect(results[1]?.detected).toBe(false);
|
|
93
|
+
expect(results[2]?.detected).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('checkPackPermissions', () => {
|
|
98
|
+
it('returns satisfied when all requirements met', () => {
|
|
99
|
+
const checker = createMockChecker({
|
|
100
|
+
commandExists: () => true,
|
|
101
|
+
fileExists: () => true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = checkPackPermissions(dockerManifest, checker, ['docker']);
|
|
105
|
+
|
|
106
|
+
expect(result.satisfied).toBe(true);
|
|
107
|
+
expect(result.missingGroups).toEqual([]);
|
|
108
|
+
expect(result.missingCommands).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reports missing groups', () => {
|
|
112
|
+
const checker = createMockChecker({
|
|
113
|
+
commandExists: () => true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const result = checkPackPermissions(dockerManifest, checker, []);
|
|
117
|
+
|
|
118
|
+
expect(result.satisfied).toBe(false);
|
|
119
|
+
expect(result.missingGroups).toEqual(['docker']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('reports missing commands', () => {
|
|
123
|
+
const checker = createMockChecker({
|
|
124
|
+
commandExists: () => false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = checkPackPermissions(dockerManifest, checker, ['docker']);
|
|
128
|
+
|
|
129
|
+
expect(result.satisfied).toBe(false);
|
|
130
|
+
expect(result.missingCommands).toEqual(['docker']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reports missing files', () => {
|
|
134
|
+
const manifest: PackManifest = {
|
|
135
|
+
name: 'test',
|
|
136
|
+
version: '0.1.0',
|
|
137
|
+
description: 'Test',
|
|
138
|
+
requires: { groups: [], files: ['/etc/special.conf'], commands: [] },
|
|
139
|
+
probes: [],
|
|
140
|
+
};
|
|
141
|
+
const checker = createMockChecker({ fileExists: () => false });
|
|
142
|
+
|
|
143
|
+
const result = checkPackPermissions(manifest, checker, []);
|
|
144
|
+
|
|
145
|
+
expect(result.satisfied).toBe(false);
|
|
146
|
+
expect(result.missingFiles).toEqual(['/etc/special.conf']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns satisfied for packs with no requirements', () => {
|
|
150
|
+
const result = checkPackPermissions(noDetectManifest, createMockChecker(), []);
|
|
151
|
+
|
|
152
|
+
expect(result.satisfied).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import type { DetectRules, PackManifest } from '@sonde/shared';
|
|
4
|
+
|
|
5
|
+
export interface ScanResult {
|
|
6
|
+
packName: string;
|
|
7
|
+
detected: boolean;
|
|
8
|
+
matchedCommands: string[];
|
|
9
|
+
matchedFiles: string[];
|
|
10
|
+
matchedServices: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Abstraction for testability */
|
|
14
|
+
export interface SystemChecker {
|
|
15
|
+
commandExists(cmd: string): boolean;
|
|
16
|
+
fileExists(path: string): boolean;
|
|
17
|
+
serviceExists(service: string): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Real system checker using PATH lookup and fs */
|
|
21
|
+
export function createSystemChecker(): SystemChecker {
|
|
22
|
+
return {
|
|
23
|
+
commandExists(cmd: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync('which', [cmd], { stdio: 'ignore' });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
fileExists(filePath: string): boolean {
|
|
32
|
+
return fs.existsSync(filePath);
|
|
33
|
+
},
|
|
34
|
+
serviceExists(service: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
execFileSync('systemctl', ['cat', service], { stdio: 'ignore' });
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Scans the system against pack detect rules.
|
|
47
|
+
* Returns a ScanResult per manifest with which checks matched.
|
|
48
|
+
*/
|
|
49
|
+
export function scanForSoftware(manifests: PackManifest[], checker: SystemChecker): ScanResult[] {
|
|
50
|
+
const results: ScanResult[] = [];
|
|
51
|
+
|
|
52
|
+
for (const manifest of manifests) {
|
|
53
|
+
const detect = manifest.detect;
|
|
54
|
+
if (!detect) {
|
|
55
|
+
results.push({
|
|
56
|
+
packName: manifest.name,
|
|
57
|
+
detected: false,
|
|
58
|
+
matchedCommands: [],
|
|
59
|
+
matchedFiles: [],
|
|
60
|
+
matchedServices: [],
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = checkDetectRules(manifest.name, detect, checker);
|
|
66
|
+
results.push(result);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkDetectRules(
|
|
73
|
+
packName: string,
|
|
74
|
+
detect: DetectRules,
|
|
75
|
+
checker: SystemChecker,
|
|
76
|
+
): ScanResult {
|
|
77
|
+
const matchedCommands: string[] = [];
|
|
78
|
+
const matchedFiles: string[] = [];
|
|
79
|
+
const matchedServices: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const cmd of detect.commands ?? []) {
|
|
82
|
+
if (checker.commandExists(cmd)) {
|
|
83
|
+
matchedCommands.push(cmd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const file of detect.files ?? []) {
|
|
88
|
+
if (checker.fileExists(file)) {
|
|
89
|
+
matchedFiles.push(file);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const service of detect.services ?? []) {
|
|
94
|
+
if (checker.serviceExists(service)) {
|
|
95
|
+
matchedServices.push(service);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Detected if at least one check passed
|
|
100
|
+
const detected =
|
|
101
|
+
matchedCommands.length > 0 || matchedFiles.length > 0 || matchedServices.length > 0;
|
|
102
|
+
|
|
103
|
+
return { packName, detected, matchedCommands, matchedFiles, matchedServices };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface PermissionCheck {
|
|
107
|
+
satisfied: boolean;
|
|
108
|
+
missingGroups: string[];
|
|
109
|
+
missingCommands: string[];
|
|
110
|
+
missingFiles: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Checks if the current user has the required permissions for a pack.
|
|
115
|
+
*/
|
|
116
|
+
export function checkPackPermissions(
|
|
117
|
+
manifest: PackManifest,
|
|
118
|
+
checker: SystemChecker,
|
|
119
|
+
userGroups: string[],
|
|
120
|
+
): PermissionCheck {
|
|
121
|
+
const groupSet = new Set(userGroups);
|
|
122
|
+
const missingGroups = manifest.requires.groups.filter((g) => !groupSet.has(g));
|
|
123
|
+
const missingCommands = manifest.requires.commands.filter((c) => !checker.commandExists(c));
|
|
124
|
+
const missingFiles = manifest.requires.files.filter((f) => !checker.fileExists(f));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
satisfied:
|
|
128
|
+
missingGroups.length === 0 && missingCommands.length === 0 && missingFiles.length === 0,
|
|
129
|
+
missingGroups,
|
|
130
|
+
missingCommands,
|
|
131
|
+
missingFiles,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { PackManifest } from '@sonde/shared';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ScanResult } from '../../system/scanner.js';
|
|
5
|
+
import { StepComplete } from './StepComplete.js';
|
|
6
|
+
import { StepHub } from './StepHub.js';
|
|
7
|
+
import { StepPacks } from './StepPacks.js';
|
|
8
|
+
import { StepPermissions } from './StepPermissions.js';
|
|
9
|
+
import { StepScan } from './StepScan.js';
|
|
10
|
+
|
|
11
|
+
type Step = 'hub' | 'scan' | 'packs' | 'permissions' | 'complete';
|
|
12
|
+
|
|
13
|
+
export interface HubConfig {
|
|
14
|
+
hubUrl: string;
|
|
15
|
+
apiKey: string;
|
|
16
|
+
agentName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STEP_LABELS: Record<Step, string> = {
|
|
20
|
+
hub: 'Hub Connection',
|
|
21
|
+
scan: 'System Scan',
|
|
22
|
+
packs: 'Pack Selection',
|
|
23
|
+
permissions: 'Permissions',
|
|
24
|
+
complete: 'Complete',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface InstallerAppProps {
|
|
28
|
+
initialHubUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function InstallerApp({ initialHubUrl }: InstallerAppProps): JSX.Element {
|
|
32
|
+
const [step, setStep] = useState<Step>('hub');
|
|
33
|
+
const [hubConfig, setHubConfig] = useState<HubConfig>({ hubUrl: '', apiKey: '', agentName: '' });
|
|
34
|
+
const [scanResults, setScanResults] = useState<ScanResult[]>([]);
|
|
35
|
+
const [selectedPacks, setSelectedPacks] = useState<PackManifest[]>([]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
39
|
+
<Box marginBottom={1}>
|
|
40
|
+
<Text bold color="cyan">
|
|
41
|
+
Sonde Installer
|
|
42
|
+
</Text>
|
|
43
|
+
<Text color="gray"> — {STEP_LABELS[step]}</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
|
|
46
|
+
{step === 'hub' && (
|
|
47
|
+
<StepHub
|
|
48
|
+
initialHubUrl={initialHubUrl}
|
|
49
|
+
onNext={(config) => {
|
|
50
|
+
setHubConfig(config);
|
|
51
|
+
setStep('scan');
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
{step === 'scan' && (
|
|
57
|
+
<StepScan
|
|
58
|
+
onNext={(results) => {
|
|
59
|
+
setScanResults(results);
|
|
60
|
+
setStep('packs');
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{step === 'packs' && (
|
|
66
|
+
<StepPacks
|
|
67
|
+
scanResults={scanResults}
|
|
68
|
+
onNext={(packs) => {
|
|
69
|
+
setSelectedPacks(packs);
|
|
70
|
+
setStep('permissions');
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{step === 'permissions' && (
|
|
76
|
+
<StepPermissions
|
|
77
|
+
selectedPacks={selectedPacks}
|
|
78
|
+
onNext={() => setStep('complete')}
|
|
79
|
+
onBack={() => setStep('packs')}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{step === 'complete' && <StepComplete hubConfig={hubConfig} selectedPacks={selectedPacks} />}
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { PackManifest } from '@sonde/shared';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import { type AgentConfig, saveConfig } from '../../config.js';
|
|
6
|
+
import { enrollWithHub } from '../../runtime/connection.js';
|
|
7
|
+
import { ProbeExecutor } from '../../runtime/executor.js';
|
|
8
|
+
import type { HubConfig } from './InstallerApp.js';
|
|
9
|
+
|
|
10
|
+
interface StepCompleteProps {
|
|
11
|
+
hubConfig: HubConfig;
|
|
12
|
+
selectedPacks: PackManifest[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function StepComplete({ hubConfig, selectedPacks }: StepCompleteProps): JSX.Element {
|
|
16
|
+
const { exit } = useApp();
|
|
17
|
+
const [enrolling, setEnrolling] = useState(true);
|
|
18
|
+
const [agentId, setAgentId] = useState('');
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const config: AgentConfig = {
|
|
23
|
+
hubUrl: hubConfig.hubUrl,
|
|
24
|
+
apiKey: hubConfig.apiKey,
|
|
25
|
+
agentName: hubConfig.agentName,
|
|
26
|
+
};
|
|
27
|
+
saveConfig(config);
|
|
28
|
+
|
|
29
|
+
const executor = new ProbeExecutor();
|
|
30
|
+
|
|
31
|
+
enrollWithHub(config, executor)
|
|
32
|
+
.then(({ agentId: id }) => {
|
|
33
|
+
config.agentId = id;
|
|
34
|
+
saveConfig(config);
|
|
35
|
+
setAgentId(id);
|
|
36
|
+
setEnrolling(false);
|
|
37
|
+
})
|
|
38
|
+
.catch((err: Error) => {
|
|
39
|
+
setError(err.message);
|
|
40
|
+
setEnrolling(false);
|
|
41
|
+
});
|
|
42
|
+
}, [hubConfig]);
|
|
43
|
+
|
|
44
|
+
useInput(() => {
|
|
45
|
+
if (!enrolling) {
|
|
46
|
+
exit();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (enrolling) {
|
|
51
|
+
return (
|
|
52
|
+
<Box>
|
|
53
|
+
<Text color="cyan">
|
|
54
|
+
<Spinner type="dots" />
|
|
55
|
+
</Text>
|
|
56
|
+
<Text> Enrolling with hub at {hubConfig.hubUrl}...</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (error) {
|
|
62
|
+
return (
|
|
63
|
+
<Box flexDirection="column">
|
|
64
|
+
<Text color="red">Enrollment failed: {error}</Text>
|
|
65
|
+
<Box marginTop={1}>
|
|
66
|
+
<Text color="gray">Press any key to exit.</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Box flexDirection="column">
|
|
74
|
+
<Text color="green" bold>
|
|
75
|
+
Enrollment successful!
|
|
76
|
+
</Text>
|
|
77
|
+
<Box marginTop={1} flexDirection="column">
|
|
78
|
+
<Text> Agent ID: {agentId}</Text>
|
|
79
|
+
<Text> Hub URL: {hubConfig.hubUrl}</Text>
|
|
80
|
+
<Text> Name: {hubConfig.agentName}</Text>
|
|
81
|
+
<Text>
|
|
82
|
+
{' '}Packs: {selectedPacks.map((p) => p.name).join(', ') || '(none)'}
|
|
83
|
+
</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
<Box marginTop={1} flexDirection="column">
|
|
86
|
+
<Text bold>Next steps:</Text>
|
|
87
|
+
<Text color="cyan"> sonde start</Text>
|
|
88
|
+
</Box>
|
|
89
|
+
<Box marginTop={1}>
|
|
90
|
+
<Text color="gray">Press any key to exit.</Text>
|
|
91
|
+
</Box>
|
|
92
|
+
</Box>
|
|
93
|
+
);
|
|
94
|
+
}
|