@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.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/config/default.json +79 -0
- package/package.json +60 -0
- package/src/Orchestrator.js +482 -0
- package/src/agents/BaseAgent.js +35 -0
- package/src/agents/BlockchainAgent.js +453 -0
- package/src/agents/DeFiSecurityAgent.js +257 -0
- package/src/agents/NetworkAgent.js +341 -0
- package/src/agents/PermissionAgent.js +192 -0
- package/src/agents/PersistenceAgent.js +361 -0
- package/src/agents/ProcessAgent.js +572 -0
- package/src/agents/ResourceAgent.js +217 -0
- package/src/agents/SystemAgent.js +173 -0
- package/src/config/ConfigManager.js +446 -0
- package/src/index.js +629 -0
- package/src/llm/LLMAnalyzer.js +705 -0
- package/src/logging/Logger.js +352 -0
- package/src/report/ReportManager.js +445 -0
- package/src/report/generators/MarkdownGenerator.js +173 -0
- package/src/report/generators/PDFGenerator.js +616 -0
- package/src/report/templates/report.hbs +465 -0
- package/src/report/utils/formatter.js +426 -0
- package/src/report/utils/sanitizer.js +275 -0
- package/src/utils/commander.js +42 -0
- package/src/utils/signature.js +121 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { BaseAgent } from './BaseAgent.js';
|
|
2
|
+
import { executeShellCommand } from '../utils/commander.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* NetworkAgent - Analyzes network connections and listening ports
|
|
6
|
+
*/
|
|
7
|
+
export class NetworkAgent extends BaseAgent {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
super('NetworkAgent');
|
|
10
|
+
this.suspiciousPorts = [
|
|
11
|
+
1337, 31337, // Leet ports
|
|
12
|
+
4444, 5555, 6666, 7777, 8888, 9999, // Common backdoor ports
|
|
13
|
+
12345, 54321, // NetBus
|
|
14
|
+
6667, 6668, 6669, // IRC
|
|
15
|
+
1234, 3389, // Remote access
|
|
16
|
+
];
|
|
17
|
+
this.trustedPaths = [
|
|
18
|
+
'/Applications',
|
|
19
|
+
'/System',
|
|
20
|
+
'/System/Applications',
|
|
21
|
+
'/System/Library',
|
|
22
|
+
'/Library',
|
|
23
|
+
'/Library/Apple',
|
|
24
|
+
'/usr/bin',
|
|
25
|
+
'/usr/sbin',
|
|
26
|
+
'/usr/lib',
|
|
27
|
+
'/usr/libexec',
|
|
28
|
+
'/usr/local/bin'
|
|
29
|
+
];
|
|
30
|
+
this.trustedCommands = new Set([
|
|
31
|
+
'mDNSResponder', 'configd', 'systemstats', 'nsurlsessiond', 'rapportd',
|
|
32
|
+
'apsd', 'trustd', 'ocspd', 'akd', 'netbiosd', 'netinfod', 'locationd',
|
|
33
|
+
'powerd', 'tccd', 'coreaudiod', 'timed', 'bluetoothd', 'notifyd'
|
|
34
|
+
]);
|
|
35
|
+
this.enableGeoLookup = Boolean(options.enableGeoLookup);
|
|
36
|
+
this.geoLookupLimit = options.geoLookupLimit || 8;
|
|
37
|
+
this.geoCache = {};
|
|
38
|
+
this.geoipAvailable = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async analyze() {
|
|
42
|
+
const connections = await this.getNetworkConnections();
|
|
43
|
+
const geoLookup = this.enableGeoLookup ? await this.enrichGeoData(connections) : {};
|
|
44
|
+
const findings = this.analyzeConnections(connections, geoLookup);
|
|
45
|
+
|
|
46
|
+
this.results = {
|
|
47
|
+
agent: this.name,
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
totalConnections: connections.length,
|
|
50
|
+
listening: connections.filter(c => c.state === 'LISTEN').length,
|
|
51
|
+
established: connections.filter(c => c.state === 'ESTABLISHED').length,
|
|
52
|
+
geoLookupEnabled: this.enableGeoLookup,
|
|
53
|
+
geoLookups: Object.keys(geoLookup).length,
|
|
54
|
+
geolocation: geoLookup,
|
|
55
|
+
findings,
|
|
56
|
+
overallRisk: this.calculateOverallRisk(findings)
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return this.results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get network connections without triggering macOS privacy prompts
|
|
64
|
+
*/
|
|
65
|
+
async getNetworkConnections() {
|
|
66
|
+
const [tcpOutput, udpOutput] = await Promise.all([
|
|
67
|
+
executeShellCommand('netstat -anv -p tcp', { quiet: true }),
|
|
68
|
+
executeShellCommand('netstat -anv -p udp', { quiet: true })
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
...this.parseNetstatOutput(tcpOutput, 'TCP'),
|
|
73
|
+
...this.parseNetstatOutput(udpOutput, 'UDP')
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse netstat output into connection objects
|
|
79
|
+
*/
|
|
80
|
+
parseNetstatOutput(output, protocol) {
|
|
81
|
+
const connections = [];
|
|
82
|
+
if (!output) return connections;
|
|
83
|
+
|
|
84
|
+
const lines = output.split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed || trimmed.startsWith('Active') || trimmed.startsWith('Proto')) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const parts = trimmed.split(/\s+/);
|
|
92
|
+
if (parts.length < 5) continue;
|
|
93
|
+
|
|
94
|
+
const local = parts[3];
|
|
95
|
+
const remote = parts[4];
|
|
96
|
+
const state = parts[5] || (protocol === 'UDP' ? 'NONE' : 'UNKNOWN');
|
|
97
|
+
|
|
98
|
+
const [localAddr, localPort] = this.parseAddress(local);
|
|
99
|
+
const [remoteAddr, remotePort] = this.parseAddress(remote);
|
|
100
|
+
|
|
101
|
+
connections.push({
|
|
102
|
+
command: '(unknown)',
|
|
103
|
+
pid: 0,
|
|
104
|
+
user: 'unknown',
|
|
105
|
+
type: protocol,
|
|
106
|
+
localAddr,
|
|
107
|
+
localPort: parseInt(localPort) || 0,
|
|
108
|
+
remoteAddr,
|
|
109
|
+
remotePort: parseInt(remotePort) || 0,
|
|
110
|
+
state
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return connections;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse address:port string
|
|
119
|
+
*/
|
|
120
|
+
parseAddress(addr) {
|
|
121
|
+
if (!addr) return ['', ''];
|
|
122
|
+
|
|
123
|
+
if (addr === '*.*' || addr === '*') {
|
|
124
|
+
return ['*', ''];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const separatorIndex = addr.lastIndexOf(':') !== -1
|
|
128
|
+
? addr.lastIndexOf(':')
|
|
129
|
+
: addr.lastIndexOf('.');
|
|
130
|
+
|
|
131
|
+
if (separatorIndex === -1) return [addr, ''];
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
addr.substring(0, separatorIndex),
|
|
135
|
+
addr.substring(separatorIndex + 1)
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Analyze connections for suspicious patterns
|
|
141
|
+
*/
|
|
142
|
+
analyzeConnections(connections, geoLookup = {}) {
|
|
143
|
+
const findings = [];
|
|
144
|
+
|
|
145
|
+
for (const conn of connections) {
|
|
146
|
+
const risks = [];
|
|
147
|
+
let riskLevel = 'low';
|
|
148
|
+
const hasAbsolutePath = conn.command && conn.command.startsWith('/');
|
|
149
|
+
const isTrustedPath = hasAbsolutePath && this.trustedPaths.some(path => conn.command.startsWith(path));
|
|
150
|
+
const isTrustedCommand = this.trustedCommands.has(conn.command);
|
|
151
|
+
|
|
152
|
+
// Check 1: Suspicious port numbers
|
|
153
|
+
if (this.suspiciousPorts.includes(conn.localPort) || this.suspiciousPorts.includes(conn.remotePort)) {
|
|
154
|
+
risks.push(`Suspicious port detected: ${conn.localPort || conn.remotePort}`);
|
|
155
|
+
riskLevel = 'high';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check 2: Get process path
|
|
159
|
+
// Check 3: Non-system process with network activity
|
|
160
|
+
const effectiveTrusted = isTrustedPath || (!hasAbsolutePath && isTrustedCommand);
|
|
161
|
+
|
|
162
|
+
if (!effectiveTrusted && hasAbsolutePath && conn.state === 'ESTABLISHED') {
|
|
163
|
+
risks.push('Non-system process with active connection');
|
|
164
|
+
riskLevel = 'medium';
|
|
165
|
+
|
|
166
|
+
// Check if remote IP is suspicious (not local, not common services)
|
|
167
|
+
if (conn.remoteAddr && !this.isLocalAddress(conn.remoteAddr)) {
|
|
168
|
+
risks.push(`External connection to ${conn.remoteAddr}:${conn.remotePort}`);
|
|
169
|
+
|
|
170
|
+
// Elevate risk for non-standard ports
|
|
171
|
+
if (conn.remotePort > 10000 && conn.remotePort < 65000) {
|
|
172
|
+
riskLevel = 'high';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check 4: Listening on non-standard ports from user processes
|
|
178
|
+
if (conn.state === 'LISTEN' && !effectiveTrusted && hasAbsolutePath) {
|
|
179
|
+
risks.push(`Listening on port ${conn.localPort}`);
|
|
180
|
+
|
|
181
|
+
if (conn.localAddr === '*' || conn.localAddr === '0.0.0.0') {
|
|
182
|
+
risks.push('Listening on all interfaces');
|
|
183
|
+
riskLevel = 'high';
|
|
184
|
+
} else {
|
|
185
|
+
riskLevel = 'medium';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check 5: Hidden or obfuscated process names (reduce false positives)
|
|
190
|
+
// lsof "COMMAND" is usually a short name (not a path), and many legitimate daemons are lowercase.
|
|
191
|
+
// Only treat this as suspicious when the command is not trusted and matches strong obfuscation patterns.
|
|
192
|
+
if (!effectiveTrusted) {
|
|
193
|
+
if (conn.command.startsWith('.')) {
|
|
194
|
+
risks.push('Hidden process name (starts with dot)');
|
|
195
|
+
riskLevel = 'high';
|
|
196
|
+
} else if (conn.command.match(/^[a-z]{1,2}[0-9]{6,}$/i)) {
|
|
197
|
+
risks.push('Suspicious obfuscated process name pattern');
|
|
198
|
+
riskLevel = 'high';
|
|
199
|
+
} else if (conn.command.match(/^[0-9a-f]{16,}$/i)) {
|
|
200
|
+
risks.push('Suspicious hex-like process name pattern');
|
|
201
|
+
riskLevel = 'high';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Only report when we have strong enough signals to reduce false positives.
|
|
206
|
+
// (high risk) OR (multiple risk indicators)
|
|
207
|
+
const shouldReport = risks.length > 1 || riskLevel === 'high';
|
|
208
|
+
if (shouldReport) {
|
|
209
|
+
const connectionType = conn.state === 'LISTEN' ? 'listening' : 'outbound';
|
|
210
|
+
const geo = conn.remoteAddr ? geoLookup[conn.remoteAddr] : undefined;
|
|
211
|
+
if (geo && geo.summary) {
|
|
212
|
+
risks.push(`Geo: ${geo.summary}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
findings.push({
|
|
216
|
+
type: connectionType,
|
|
217
|
+
command: conn.command,
|
|
218
|
+
pid: conn.pid,
|
|
219
|
+
user: conn.user,
|
|
220
|
+
protocol: conn.type,
|
|
221
|
+
localAddr: conn.localAddr,
|
|
222
|
+
localPort: conn.localPort,
|
|
223
|
+
remoteAddr: conn.remoteAddr,
|
|
224
|
+
remotePort: conn.remotePort,
|
|
225
|
+
state: conn.state,
|
|
226
|
+
geo,
|
|
227
|
+
risks,
|
|
228
|
+
risk: riskLevel,
|
|
229
|
+
description: `${conn.command} (${conn.pid}): ${risks.join(', ')}`
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return findings;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check if an address is local/private
|
|
239
|
+
*/
|
|
240
|
+
isLocalAddress(addr) {
|
|
241
|
+
if (!addr) return false;
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
addr === 'localhost' ||
|
|
245
|
+
addr === '127.0.0.1' ||
|
|
246
|
+
addr.startsWith('192.168.') ||
|
|
247
|
+
addr.startsWith('10.') ||
|
|
248
|
+
addr.startsWith('172.16.') ||
|
|
249
|
+
addr.startsWith('fe80:') ||
|
|
250
|
+
addr === '::1'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Limit geo lookups to external IPs and add cached location info
|
|
256
|
+
*/
|
|
257
|
+
async enrichGeoData(connections) {
|
|
258
|
+
const externalIps = Array.from(new Set(
|
|
259
|
+
connections
|
|
260
|
+
.map(c => c.remoteAddr)
|
|
261
|
+
.filter(ip => ip && !this.isLocalAddress(ip) && this.isPublicIP(ip))
|
|
262
|
+
)).slice(0, this.geoLookupLimit);
|
|
263
|
+
|
|
264
|
+
const geoLookup = {};
|
|
265
|
+
|
|
266
|
+
for (const ip of externalIps) {
|
|
267
|
+
const cached = this.geoCache[ip];
|
|
268
|
+
if (cached) {
|
|
269
|
+
geoLookup[ip] = cached;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const geo = await this.lookupGeo(ip);
|
|
274
|
+
if (geo) {
|
|
275
|
+
this.geoCache[ip] = geo;
|
|
276
|
+
geoLookup[ip] = geo;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return geoLookup;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Simple public IP check (excludes wildcard/unspecified)
|
|
285
|
+
*/
|
|
286
|
+
isPublicIP(ip) {
|
|
287
|
+
if (!ip) return false;
|
|
288
|
+
if (ip === '*' || ip === '0.0.0.0') return false;
|
|
289
|
+
return !this.isLocalAddress(ip);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Lookup geo information using local geoip (if present) then ipinfo.io as fallback
|
|
294
|
+
*/
|
|
295
|
+
async lookupGeo(ip) {
|
|
296
|
+
if (this.geoipAvailable === null) {
|
|
297
|
+
const available = await executeShellCommand('command -v geoiplookup >/dev/null 2>&1 && echo yes', { quiet: true });
|
|
298
|
+
this.geoipAvailable = Boolean(available && available.trim() === 'yes');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Try geoiplookup if available (no external call if DB exists)
|
|
302
|
+
if (this.geoipAvailable) {
|
|
303
|
+
try {
|
|
304
|
+
const geoip = await executeShellCommand(`geoiplookup ${ip} 2>/dev/null`, { quiet: true });
|
|
305
|
+
if (geoip) {
|
|
306
|
+
const summary = geoip.split(':')[1]?.trim() || geoip.trim();
|
|
307
|
+
return {
|
|
308
|
+
ip,
|
|
309
|
+
summary,
|
|
310
|
+
source: 'geoiplookup'
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
// Ignore and fall through
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Fallback to ipinfo.io (external); handle failures gracefully
|
|
319
|
+
try {
|
|
320
|
+
const output = await executeShellCommand(`curl -s --max-time 5 https://ipinfo.io/${ip}/json`, { quiet: true });
|
|
321
|
+
const data = JSON.parse(output || '{}');
|
|
322
|
+
|
|
323
|
+
if (Object.keys(data).length === 0) return null;
|
|
324
|
+
|
|
325
|
+
const summaryParts = [data.city, data.region, data.country].filter(Boolean);
|
|
326
|
+
const summary = summaryParts.join(', ') || data.country || 'Unknown';
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
ip,
|
|
330
|
+
city: data.city,
|
|
331
|
+
region: data.region,
|
|
332
|
+
country: data.country,
|
|
333
|
+
org: data.org,
|
|
334
|
+
source: 'ipinfo.io',
|
|
335
|
+
summary
|
|
336
|
+
};
|
|
337
|
+
} catch (error) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { BaseAgent } from './BaseAgent.js';
|
|
2
|
+
import { executeShellCommand } from '../utils/commander.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PermissionAgent - Analyzes app permissions and privacy settings
|
|
6
|
+
*/
|
|
7
|
+
export class PermissionAgent extends BaseAgent {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('PermissionAgent');
|
|
10
|
+
this.criticalPermissions = [
|
|
11
|
+
'kTCCServiceSystemPolicyAllFiles', // Full Disk Access
|
|
12
|
+
'kTCCServiceScreenCapture', // Screen Recording
|
|
13
|
+
'kTCCServiceAccessibility', // Accessibility
|
|
14
|
+
'kTCCServiceCamera', // Camera
|
|
15
|
+
'kTCCServiceMicrophone', // Microphone
|
|
16
|
+
];
|
|
17
|
+
this.trustedPaths = ['/Applications', '/System', '/usr/bin', '/usr/sbin', '/usr/libexec'];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async analyze() {
|
|
21
|
+
const tccPermissions = await this.scanTCCDatabase();
|
|
22
|
+
const findings = this.analyzePermissions(tccPermissions);
|
|
23
|
+
|
|
24
|
+
this.results = {
|
|
25
|
+
agent: this.name,
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
totalPermissions: tccPermissions.length,
|
|
28
|
+
criticalPermissions: tccPermissions.filter(p =>
|
|
29
|
+
this.criticalPermissions.includes(p.service)
|
|
30
|
+
).length,
|
|
31
|
+
findings,
|
|
32
|
+
overallRisk: this.calculateOverallRisk(findings)
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return this.results;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Scan TCC (Transparency, Consent, and Control) database
|
|
40
|
+
* Note: Uses system_profiler as TCC.db requires Full Disk Access
|
|
41
|
+
*/
|
|
42
|
+
async scanTCCDatabase() {
|
|
43
|
+
const permissions = [];
|
|
44
|
+
|
|
45
|
+
// Use system_profiler to get privacy permissions
|
|
46
|
+
const profilerOutput = await executeShellCommand('system_profiler SPPrivacyDataType 2>/dev/null');
|
|
47
|
+
|
|
48
|
+
if (profilerOutput) {
|
|
49
|
+
// Parse system_profiler output
|
|
50
|
+
const lines = profilerOutput.split('\n');
|
|
51
|
+
let currentService = '';
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmedLine = line.trim();
|
|
55
|
+
|
|
56
|
+
// Service headers (no leading spaces, contains colon)
|
|
57
|
+
if (line.match(/^[A-Z]/) && line.includes(':') && !line.includes('Privacy:')) {
|
|
58
|
+
currentService = line.split(':')[0].trim();
|
|
59
|
+
}
|
|
60
|
+
// App entries (have leading spaces/dashes)
|
|
61
|
+
else if (trimmedLine && (trimmedLine.startsWith('-') || trimmedLine.startsWith('•'))) {
|
|
62
|
+
let app = trimmedLine.replace(/^[-•]\s*/, '').trim();
|
|
63
|
+
|
|
64
|
+
// Remove any trailing descriptors
|
|
65
|
+
app = app.split('(')[0].trim();
|
|
66
|
+
|
|
67
|
+
if (app && currentService) {
|
|
68
|
+
permissions.push({
|
|
69
|
+
service: currentService,
|
|
70
|
+
client: app,
|
|
71
|
+
clientType: 0,
|
|
72
|
+
allowed: true,
|
|
73
|
+
promptCount: 0,
|
|
74
|
+
source: 'system_profiler'
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Also check tccutil (macOS 13+)
|
|
82
|
+
try {
|
|
83
|
+
const tccOutput = await executeShellCommand('tccutil list 2>/dev/null', { quiet: true });
|
|
84
|
+
if (tccOutput) {
|
|
85
|
+
// Parse tccutil output if available
|
|
86
|
+
const services = tccOutput.split('\n').filter(s => s.trim());
|
|
87
|
+
// Note: tccutil list only shows service names, not granted apps
|
|
88
|
+
// This is mainly for validation
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// tccutil not available or failed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return permissions;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Analyze permissions for security risks
|
|
99
|
+
*/
|
|
100
|
+
analyzePermissions(permissions) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
|
|
103
|
+
for (const perm of permissions) {
|
|
104
|
+
if (!perm.allowed) continue; // Only check granted permissions
|
|
105
|
+
|
|
106
|
+
const risks = [];
|
|
107
|
+
let riskLevel = 'low';
|
|
108
|
+
|
|
109
|
+
// Check 1: Critical permissions granted
|
|
110
|
+
if (this.criticalPermissions.includes(perm.service)) {
|
|
111
|
+
risks.push(`Has critical permission: ${this.humanReadableService(perm.service)}`);
|
|
112
|
+
riskLevel = 'medium';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hasAbsolutePath = perm.client && perm.client.startsWith('/');
|
|
116
|
+
|
|
117
|
+
// Check 2: Non-standard app paths with critical permissions
|
|
118
|
+
// NOTE: system_profiler often returns app names (not paths). Only apply path checks when we actually have a path.
|
|
119
|
+
const isTrustedPath = hasAbsolutePath && this.trustedPaths.some(path => perm.client.startsWith(path));
|
|
120
|
+
|
|
121
|
+
if (hasAbsolutePath && !isTrustedPath && this.criticalPermissions.includes(perm.service)) {
|
|
122
|
+
risks.push('Critical permission granted to non-standard location');
|
|
123
|
+
riskLevel = 'high';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check 3: Apps in user directories with permissions
|
|
127
|
+
if (hasAbsolutePath && perm.client.includes('/Users/') && !perm.client.includes('/Applications')) {
|
|
128
|
+
risks.push('Permission granted to app in user directory');
|
|
129
|
+
riskLevel = 'high';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check 4: Hidden apps with permissions
|
|
133
|
+
const appName = perm.client.split('/').pop();
|
|
134
|
+
if (appName.startsWith('.')) {
|
|
135
|
+
risks.push('Permission granted to hidden application');
|
|
136
|
+
riskLevel = 'high';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check 5: Suspicious app names
|
|
140
|
+
const suspiciousKeywords = ['miner', 'crypto', 'hidden', 'backdoor', 'keylog'];
|
|
141
|
+
const clientLower = perm.client.toLowerCase();
|
|
142
|
+
|
|
143
|
+
for (const keyword of suspiciousKeywords) {
|
|
144
|
+
if (clientLower.includes(keyword)) {
|
|
145
|
+
risks.push(`Suspicious app name contains: ${keyword}`);
|
|
146
|
+
riskLevel = 'high';
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Only report when we have strong enough signals to reduce false positives.
|
|
152
|
+
// Many legitimate apps have "critical" permissions; that's useful context but can be noisy.
|
|
153
|
+
// Report high-risk items, or cases where multiple risk indicators are present.
|
|
154
|
+
const shouldReport = risks.length > 1 || riskLevel === 'high';
|
|
155
|
+
if (shouldReport) {
|
|
156
|
+
findings.push({
|
|
157
|
+
type: 'privacy_permission',
|
|
158
|
+
app: perm.client,
|
|
159
|
+
permission: this.humanReadableService(perm.service),
|
|
160
|
+
service: perm.service,
|
|
161
|
+
source: perm.source,
|
|
162
|
+
risks,
|
|
163
|
+
risk: riskLevel,
|
|
164
|
+
description: `${perm.client}: ${risks.join(', ')}`
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convert TCC service names to human-readable format
|
|
174
|
+
*/
|
|
175
|
+
humanReadableService(service) {
|
|
176
|
+
const serviceMap = {
|
|
177
|
+
'kTCCServiceSystemPolicyAllFiles': 'Full Disk Access',
|
|
178
|
+
'kTCCServiceScreenCapture': 'Screen Recording',
|
|
179
|
+
'kTCCServiceAccessibility': 'Accessibility',
|
|
180
|
+
'kTCCServiceCamera': 'Camera',
|
|
181
|
+
'kTCCServiceMicrophone': 'Microphone',
|
|
182
|
+
'kTCCServicePhotos': 'Photos',
|
|
183
|
+
'kTCCServiceContacts': 'Contacts',
|
|
184
|
+
'kTCCServiceCalendar': 'Calendar',
|
|
185
|
+
'kTCCServiceReminders': 'Reminders',
|
|
186
|
+
'kTCCServiceAddressBook': 'Contacts',
|
|
187
|
+
'kTCCServiceLocation': 'Location Services'
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return serviceMap[service] || service;
|
|
191
|
+
}
|
|
192
|
+
}
|