@jishankai/solid-cli 1.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.
@@ -0,0 +1,361 @@
1
+ import { BaseAgent } from './BaseAgent.js';
2
+ import { executeShellCommand } from '../utils/commander.js';
3
+ import { readdirSync, readFileSync, existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { parse } from 'path';
6
+
7
+ import { getSignatureAssessment } from '../utils/signature.js';
8
+
9
+ /**
10
+ * PersistenceAgent - Detects persistence mechanisms on macOS
11
+ * This is the core security agent
12
+ */
13
+ export class PersistenceAgent extends BaseAgent {
14
+ constructor() {
15
+ super('PersistenceAgent');
16
+ this.trustedPaths = [
17
+ '/Applications',
18
+ '/System',
19
+ '/System/Applications',
20
+ '/System/Library',
21
+ '/System/Library/CoreServices',
22
+ '/System/Library/PrivateFrameworks',
23
+ '/System/Library/LaunchDaemons',
24
+ '/System/Library/LaunchAgents',
25
+ '/usr/bin',
26
+ '/usr/sbin',
27
+ '/usr/lib',
28
+ '/usr/libexec'
29
+ ];
30
+ this.suspiciousCommands = ['bash', 'sh', 'curl', 'wget', 'python', 'python3', 'perl', 'ruby'];
31
+ }
32
+
33
+ async analyze() {
34
+ // Cache signature lookups across all persistence checks to avoid repeated shell calls.
35
+ this.signatureCache = new Map();
36
+
37
+ const launchAgents = await this.scanLaunchAgents();
38
+ const launchDaemons = await this.scanLaunchDaemons();
39
+ const loginItems = await this.scanLoginItems();
40
+ const crontab = await this.scanCrontab();
41
+
42
+ const allFindings = [
43
+ ...launchAgents,
44
+ ...launchDaemons,
45
+ ...loginItems,
46
+ ...crontab
47
+ ];
48
+
49
+ this.results = {
50
+ agent: this.name,
51
+ timestamp: new Date().toISOString(),
52
+ launchAgents: launchAgents.length,
53
+ launchDaemons: launchDaemons.length,
54
+ loginItems: loginItems.length,
55
+ crontabEntries: crontab.length,
56
+ findings: allFindings,
57
+ overallRisk: this.calculateOverallRisk(allFindings)
58
+ };
59
+
60
+ return this.results;
61
+ }
62
+
63
+ /**
64
+ * Scan user LaunchAgents
65
+ */
66
+ async scanLaunchAgents() {
67
+ const userHome = process.env.HOME;
68
+ const paths = [
69
+ join(userHome, 'Library/LaunchAgents'),
70
+ '/Library/LaunchAgents'
71
+ ];
72
+
73
+ const findings = [];
74
+
75
+ for (const basePath of paths) {
76
+ if (!existsSync(basePath)) continue;
77
+
78
+ try {
79
+ const files = readdirSync(basePath);
80
+
81
+ for (const file of files) {
82
+ if (!file.endsWith('.plist')) continue;
83
+
84
+ const fullPath = join(basePath, file);
85
+ const finding = await this.analyzePlist(fullPath, 'LaunchAgent');
86
+
87
+ if (finding) {
88
+ findings.push(finding);
89
+ }
90
+ }
91
+ } catch (error) {
92
+ // Permission denied or directory doesn't exist
93
+ }
94
+ }
95
+
96
+ return findings;
97
+ }
98
+
99
+ /**
100
+ * Scan LaunchDaemons (system-wide)
101
+ */
102
+ async scanLaunchDaemons() {
103
+ const paths = [
104
+ '/Library/LaunchDaemons',
105
+ '/System/Library/LaunchDaemons' // Read-only verification
106
+ ];
107
+
108
+ const findings = [];
109
+
110
+ for (const basePath of paths) {
111
+ if (!existsSync(basePath)) continue;
112
+
113
+ try {
114
+ const files = readdirSync(basePath);
115
+
116
+ for (const file of files) {
117
+ if (!file.endsWith('.plist')) continue;
118
+
119
+ const fullPath = join(basePath, file);
120
+ const finding = await this.analyzePlist(fullPath, 'LaunchDaemon');
121
+
122
+ if (finding) {
123
+ findings.push(finding);
124
+ }
125
+ }
126
+ } catch (error) {
127
+ // Permission denied
128
+ }
129
+ }
130
+
131
+ return findings;
132
+ }
133
+
134
+ /**
135
+ * Analyze a plist file for suspicious content
136
+ */
137
+ async analyzePlist(plistPath, type) {
138
+ try {
139
+ // Use plutil to convert plist to JSON
140
+ const jsonOutput = await executeShellCommand(`plutil -convert json -o - "${plistPath}"`);
141
+
142
+ if (!jsonOutput) return null;
143
+
144
+ const plist = JSON.parse(jsonOutput);
145
+ const risks = [];
146
+ let riskLevel = 'low';
147
+
148
+ // Extract program path
149
+ let programPath = plist.Program || (plist.ProgramArguments && plist.ProgramArguments[0]) || '';
150
+
151
+ if (!programPath) return null;
152
+
153
+ // Check 1: Program path not in trusted locations
154
+ const isTrustedPath = this.trustedPaths.some(trusted => programPath.startsWith(trusted));
155
+
156
+ if (!isTrustedPath && !programPath.startsWith('/Library/Application Support')) {
157
+ risks.push('Program path is not in trusted location');
158
+ riskLevel = 'medium';
159
+ }
160
+
161
+ // Check 1b: Code-signing/Gatekeeper trust check (reduces false positives)
162
+ const signature = await getSignatureAssessment(programPath, this.signatureCache);
163
+ const isGatekeeperAccepted = signature.spctlAccepted;
164
+ const isAppleSigned = signature.signedByApple;
165
+
166
+ // Check 2: Uses suspicious commands
167
+ const programName = parse(programPath).base.toLowerCase();
168
+ if (this.suspiciousCommands.some(cmd => programName.includes(cmd))) {
169
+ risks.push(`Uses potentially suspicious command: ${programName}`);
170
+ riskLevel = 'medium';
171
+ }
172
+
173
+ // Check 3: Impersonates Apple services
174
+ const fileName = parse(plistPath).name;
175
+ if (fileName.startsWith('com.apple.') && !plistPath.startsWith('/System')) {
176
+ risks.push('Potentially impersonates Apple service');
177
+ riskLevel = 'high';
178
+ }
179
+
180
+ // Check 4: Runs on load or keeps alive
181
+ if (plist.RunAtLoad || plist.KeepAlive) {
182
+ risks.push('Configured to run at load or stay alive');
183
+ }
184
+
185
+ // Check 5: Has network listen sockets
186
+ if (plist.Sockets) {
187
+ risks.push('Has network listen sockets configured');
188
+ if (!isTrustedPath) riskLevel = 'high';
189
+ }
190
+
191
+ // Check 6: Program in user home directory
192
+ // NOTE: Many legitimate apps/dev tools install user LaunchAgents that run from ~/Library.
193
+ // Treat this as a medium signal unless combined with additional indicators.
194
+ if (programPath.includes('/Users/')) {
195
+ risks.push('Program located in user home directory');
196
+ riskLevel = riskLevel === 'low' ? 'medium' : riskLevel;
197
+
198
+ // Elevate if running from a hidden directory (common malware tactic)
199
+ if (programPath.includes('/.')) {
200
+ risks.push('Program located in hidden directory');
201
+ riskLevel = 'high';
202
+ }
203
+ }
204
+
205
+ // If Gatekeeper accepts the target program and we only have weak heuristics,
206
+ // downgrade/remove the "non-trusted location" signal.
207
+ const hasStrongSignal = risks.some(r =>
208
+ r.includes('Potentially impersonates Apple service') ||
209
+ r.includes('Has network listen sockets configured') ||
210
+ r.includes('Program located in hidden directory')
211
+ );
212
+
213
+ if ((isGatekeeperAccepted || isAppleSigned) && !hasStrongSignal) {
214
+ // Remove the most common noisy signal
215
+ const filteredRisks = risks.filter(r => r !== 'Program path is not in trusted location');
216
+ risks.length = 0;
217
+ risks.push(...filteredRisks);
218
+
219
+ if (riskLevel === 'medium') {
220
+ riskLevel = 'low';
221
+ }
222
+ }
223
+
224
+ // System/Apple-signed items in Apple directories are almost always benign; drop them early
225
+ const isSystemLocation =
226
+ plistPath.startsWith('/System/Library') ||
227
+ programPath.startsWith('/System/Library') ||
228
+ programPath.startsWith('/usr/libexec');
229
+
230
+ if (isAppleSigned && isSystemLocation && !hasStrongSignal) {
231
+ return null;
232
+ }
233
+
234
+ // Only report when we have strong enough signals to reduce false positives.
235
+ // (high risk) OR (multiple risk indicators)
236
+ if (risks.length > 1 || riskLevel === 'high') {
237
+ return {
238
+ type: type.toLowerCase(),
239
+ plist: fileName,
240
+ path: plistPath,
241
+ program: programPath,
242
+ label: plist.Label || fileName,
243
+ risks,
244
+ risk: riskLevel,
245
+ trust: {
246
+ spctlAccepted: signature.spctlAccepted,
247
+ teamIdentifier: signature.teamIdentifier,
248
+ signedByApple: signature.signedByApple,
249
+ signedByDeveloperId: signature.signedByDeveloperId
250
+ },
251
+ description: `${type}: ${risks.join(', ')}`
252
+ };
253
+ }
254
+
255
+ return null;
256
+ } catch (error) {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Scan Login Items using AppleScript
263
+ */
264
+ async scanLoginItems() {
265
+ const script = `
266
+ tell application "System Events"
267
+ get name of every login item
268
+ end tell
269
+ `;
270
+
271
+ try {
272
+ const output = await executeShellCommand(`osascript -e '${script}'`, { quiet: true });
273
+
274
+ if (!output) return [];
275
+
276
+ const items = output.split(',').map(item => item.trim());
277
+ const findings = [];
278
+
279
+ for (const item of items) {
280
+ // Try to find suspicious patterns
281
+ const itemLower = item.toLowerCase();
282
+
283
+ let risk = 'low';
284
+ const risks = [];
285
+
286
+ if (itemLower.includes('hidden') || itemLower.includes('crypto') || itemLower.includes('miner')) {
287
+ risk = 'high';
288
+ risks.push('Login item has suspicious name');
289
+ }
290
+
291
+ if (risk !== 'low' || risks.length > 0) {
292
+ findings.push({
293
+ type: 'login_item',
294
+ name: item,
295
+ risks,
296
+ risk,
297
+ description: `Login item: ${item}`
298
+ });
299
+ }
300
+ }
301
+
302
+ return findings;
303
+ } catch (error) {
304
+ return [];
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Scan crontab entries
310
+ */
311
+ async scanCrontab() {
312
+ try {
313
+ const output = await executeShellCommand(
314
+ 'command -v crontab >/dev/null 2>&1 && crontab -l 2>/dev/null',
315
+ { quiet: true }
316
+ );
317
+
318
+ if (!output || output.includes('no crontab')) return [];
319
+
320
+ const lines = output.split('\n').filter(line => line.trim() && !line.startsWith('#'));
321
+ const findings = [];
322
+
323
+ for (const line of lines) {
324
+ let risk = 'low';
325
+ const risks = [];
326
+
327
+ // Check for suspicious commands
328
+ if (this.suspiciousCommands.some(cmd => line.toLowerCase().includes(cmd))) {
329
+ risks.push('Uses shell command in crontab');
330
+ risk = 'medium';
331
+ }
332
+
333
+ // Check for network operations
334
+ if (line.includes('curl') || line.includes('wget')) {
335
+ risks.push('Performs network operations');
336
+ risk = 'high';
337
+ }
338
+
339
+ // Check for execution from user directories
340
+ if (line.includes('/Users/') && !line.includes('/Applications')) {
341
+ risks.push('Executes from user directory');
342
+ risk = 'high';
343
+ }
344
+
345
+ if (risks.length > 0) {
346
+ findings.push({
347
+ type: 'crontab',
348
+ entry: line,
349
+ risks,
350
+ risk,
351
+ description: `Crontab entry: ${risks.join(', ')}`
352
+ });
353
+ }
354
+ }
355
+
356
+ return findings;
357
+ } catch (error) {
358
+ return [];
359
+ }
360
+ }
361
+ }