@omnidev-ai/core 0.11.0 → 0.12.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,563 @@
1
+ /**
2
+ * Security scanner implementation
3
+ *
4
+ * Scans capability directories for potential supply-chain issues:
5
+ * - Suspicious Unicode characters (bidi overrides, zero-width, control chars)
6
+ * - Symlinks that could escape the capability directory
7
+ * - Suspicious script patterns
8
+ * - Binary files in content directories
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { lstat, readdir, readFile, readlink, realpath } from "node:fs/promises";
13
+ import { join, relative, resolve } from "node:path";
14
+ import type {
15
+ FindingSeverity,
16
+ FindingType,
17
+ ScanResult,
18
+ ScanSettings,
19
+ ScanSummary,
20
+ SecurityConfig,
21
+ SecurityFinding,
22
+ } from "./types.js";
23
+ import { DEFAULT_SCAN_SETTINGS } from "./types.js";
24
+
25
+ /**
26
+ * Suspicious Unicode codepoint ranges
27
+ */
28
+ const UNICODE_PATTERNS = {
29
+ // Bidirectional text override characters (can hide malicious code)
30
+ bidi: [
31
+ 0x202a, // LEFT-TO-RIGHT EMBEDDING
32
+ 0x202b, // RIGHT-TO-LEFT EMBEDDING
33
+ 0x202c, // POP DIRECTIONAL FORMATTING
34
+ 0x202d, // LEFT-TO-RIGHT OVERRIDE
35
+ 0x202e, // RIGHT-TO-LEFT OVERRIDE
36
+ 0x2066, // LEFT-TO-RIGHT ISOLATE
37
+ 0x2067, // RIGHT-TO-LEFT ISOLATE
38
+ 0x2068, // FIRST STRONG ISOLATE
39
+ 0x2069, // POP DIRECTIONAL ISOLATE
40
+ ],
41
+ // Zero-width characters (can hide content)
42
+ zeroWidth: [
43
+ 0x200b, // ZERO WIDTH SPACE
44
+ 0x200c, // ZERO WIDTH NON-JOINER
45
+ 0x200d, // ZERO WIDTH JOINER
46
+ 0x2060, // WORD JOINER
47
+ 0xfeff, // ZERO WIDTH NO-BREAK SPACE (BOM when not at start)
48
+ ],
49
+ // Control characters (excluding common ones like \n, \r, \t)
50
+ control: [
51
+ 0x0000, // NUL
52
+ 0x0001, // SOH
53
+ 0x0002, // STX
54
+ 0x0003, // ETX
55
+ 0x0004, // EOT
56
+ 0x0005, // ENQ
57
+ 0x0006, // ACK
58
+ 0x0007, // BEL
59
+ 0x0008, // BS
60
+ // 0x0009 TAB - allowed
61
+ // 0x000A LF - allowed
62
+ 0x000b, // VT
63
+ 0x000c, // FF
64
+ // 0x000D CR - allowed
65
+ 0x000e, // SO
66
+ 0x000f, // SI
67
+ 0x0010, // DLE
68
+ 0x0011, // DC1
69
+ 0x0012, // DC2
70
+ 0x0013, // DC3
71
+ 0x0014, // DC4
72
+ 0x0015, // NAK
73
+ 0x0016, // SYN
74
+ 0x0017, // ETB
75
+ 0x0018, // CAN
76
+ 0x0019, // EM
77
+ 0x001a, // SUB
78
+ 0x001b, // ESC
79
+ 0x001c, // FS
80
+ 0x001d, // GS
81
+ 0x001e, // RS
82
+ 0x001f, // US
83
+ 0x007f, // DEL
84
+ ],
85
+ };
86
+
87
+ /**
88
+ * Suspicious script patterns (regex patterns and descriptions)
89
+ */
90
+ const SUSPICIOUS_SCRIPT_PATTERNS: Array<{
91
+ pattern: RegExp;
92
+ message: string;
93
+ severity: FindingSeverity;
94
+ }> = [
95
+ {
96
+ pattern: /curl\s+.*\|\s*(ba)?sh/i,
97
+ message: "Piping curl to shell can execute arbitrary remote code",
98
+ severity: "high",
99
+ },
100
+ {
101
+ pattern: /wget\s+.*\|\s*(ba)?sh/i,
102
+ message: "Piping wget to shell can execute arbitrary remote code",
103
+ severity: "high",
104
+ },
105
+ {
106
+ pattern: /eval\s*\(\s*\$\(/,
107
+ message: "eval with command substitution can be dangerous",
108
+ severity: "medium",
109
+ },
110
+ {
111
+ pattern: /rm\s+-rf\s+\/($|\s)|rm\s+-rf\s+~($|\s)/,
112
+ message: "Recursive deletion from root or home directory",
113
+ severity: "critical",
114
+ },
115
+ {
116
+ pattern: /chmod\s+777/,
117
+ message: "Setting world-writable permissions",
118
+ severity: "medium",
119
+ },
120
+ {
121
+ pattern: /\bsudo\b.*>/,
122
+ message: "Using sudo with output redirection",
123
+ severity: "medium",
124
+ },
125
+ {
126
+ pattern: /base64\s+-d.*\|\s*(ba)?sh/i,
127
+ message: "Decoding and executing base64 content",
128
+ severity: "high",
129
+ },
130
+ ];
131
+
132
+ /**
133
+ * File extensions that are considered binary (not content files)
134
+ */
135
+ const BINARY_EXTENSIONS = new Set([
136
+ ".exe",
137
+ ".dll",
138
+ ".so",
139
+ ".dylib",
140
+ ".bin",
141
+ ".o",
142
+ ".a",
143
+ ".lib",
144
+ ".pyc",
145
+ ".pyo",
146
+ ".class",
147
+ ".jar",
148
+ ".war",
149
+ ".ear",
150
+ ".wasm",
151
+ ".node",
152
+ ]);
153
+
154
+ /**
155
+ * File extensions that should be scanned for content
156
+ */
157
+ const TEXT_EXTENSIONS = new Set([
158
+ ".md",
159
+ ".txt",
160
+ ".toml",
161
+ ".yaml",
162
+ ".yml",
163
+ ".json",
164
+ ".js",
165
+ ".ts",
166
+ ".sh",
167
+ ".bash",
168
+ ".zsh",
169
+ ".fish",
170
+ ".py",
171
+ ".rb",
172
+ ]);
173
+
174
+ /**
175
+ * Scan a file for suspicious Unicode characters
176
+ */
177
+ async function scanFileForUnicode(
178
+ filePath: string,
179
+ relativePath: string,
180
+ ): Promise<SecurityFinding[]> {
181
+ const findings: SecurityFinding[] = [];
182
+
183
+ try {
184
+ const content = await readFile(filePath, "utf-8");
185
+ const lines = content.split("\n");
186
+
187
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
188
+ const line = lines[lineNum];
189
+ if (!line) continue;
190
+
191
+ for (let col = 0; col < line.length; col++) {
192
+ const codePoint = line.codePointAt(col);
193
+ if (codePoint === undefined) continue;
194
+
195
+ // Check bidi characters
196
+ if (UNICODE_PATTERNS.bidi.includes(codePoint)) {
197
+ findings.push({
198
+ type: "unicode_bidi",
199
+ severity: "high",
200
+ file: relativePath,
201
+ line: lineNum + 1,
202
+ column: col + 1,
203
+ message: "Bidirectional text override character detected",
204
+ details: `Codepoint U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`,
205
+ });
206
+ }
207
+
208
+ // Check zero-width characters (skip BOM at start of file)
209
+ if (UNICODE_PATTERNS.zeroWidth.includes(codePoint)) {
210
+ // Skip BOM at the very start of file
211
+ if (codePoint === 0xfeff && lineNum === 0 && col === 0) continue;
212
+
213
+ findings.push({
214
+ type: "unicode_zero_width",
215
+ severity: "medium",
216
+ file: relativePath,
217
+ line: lineNum + 1,
218
+ column: col + 1,
219
+ message: "Zero-width character detected",
220
+ details: `Codepoint U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`,
221
+ });
222
+ }
223
+
224
+ // Check control characters
225
+ if (UNICODE_PATTERNS.control.includes(codePoint)) {
226
+ findings.push({
227
+ type: "unicode_control",
228
+ severity: "medium",
229
+ file: relativePath,
230
+ line: lineNum + 1,
231
+ column: col + 1,
232
+ message: "Suspicious control character detected",
233
+ details: `Codepoint U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`,
234
+ });
235
+ }
236
+ }
237
+ }
238
+ } catch {
239
+ // File might be binary or unreadable, skip
240
+ }
241
+
242
+ return findings;
243
+ }
244
+
245
+ /**
246
+ * Scan a file for suspicious script patterns
247
+ */
248
+ async function scanFileForScripts(
249
+ filePath: string,
250
+ relativePath: string,
251
+ ): Promise<SecurityFinding[]> {
252
+ const findings: SecurityFinding[] = [];
253
+
254
+ try {
255
+ const content = await readFile(filePath, "utf-8");
256
+ const lines = content.split("\n");
257
+
258
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
259
+ const line = lines[lineNum];
260
+ if (!line) continue;
261
+
262
+ for (const { pattern, message, severity } of SUSPICIOUS_SCRIPT_PATTERNS) {
263
+ if (pattern.test(line)) {
264
+ findings.push({
265
+ type: "suspicious_script",
266
+ severity,
267
+ file: relativePath,
268
+ line: lineNum + 1,
269
+ message,
270
+ details: line.trim().substring(0, 100),
271
+ });
272
+ }
273
+ }
274
+ }
275
+ } catch {
276
+ // File might be binary or unreadable, skip
277
+ }
278
+
279
+ return findings;
280
+ }
281
+
282
+ /**
283
+ * Check if a symlink escapes the capability directory
284
+ */
285
+ async function checkSymlink(
286
+ symlinkPath: string,
287
+ relativePath: string,
288
+ capabilityRoot: string,
289
+ ): Promise<SecurityFinding | null> {
290
+ try {
291
+ const linkTarget = await readlink(symlinkPath);
292
+ const resolvedTarget = resolve(join(symlinkPath, "..", linkTarget));
293
+ const normalizedRoot = await realpath(capabilityRoot);
294
+
295
+ // Check if it's an absolute path
296
+ if (linkTarget.startsWith("/")) {
297
+ return {
298
+ type: "symlink_absolute",
299
+ severity: "high",
300
+ file: relativePath,
301
+ message: "Symlink points to an absolute path",
302
+ details: `Target: ${linkTarget}`,
303
+ };
304
+ }
305
+
306
+ // Check if resolved path escapes capability root
307
+ const relativeToRoot = relative(normalizedRoot, resolvedTarget);
308
+ if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("/")) {
309
+ return {
310
+ type: "symlink_escape",
311
+ severity: "critical",
312
+ file: relativePath,
313
+ message: "Symlink escapes capability directory",
314
+ details: `Resolves to: ${resolvedTarget}`,
315
+ };
316
+ }
317
+ } catch {
318
+ // Broken symlink or permission issue
319
+ }
320
+
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * Check if a file is a binary
326
+ */
327
+ function isBinaryFile(filePath: string): boolean {
328
+ const ext = filePath.toLowerCase().substring(filePath.lastIndexOf("."));
329
+ return BINARY_EXTENSIONS.has(ext);
330
+ }
331
+
332
+ /**
333
+ * Check if a file should be scanned for text content
334
+ */
335
+ function isTextFile(filePath: string): boolean {
336
+ const ext = filePath.toLowerCase().substring(filePath.lastIndexOf("."));
337
+ return TEXT_EXTENSIONS.has(ext);
338
+ }
339
+
340
+ /**
341
+ * Scan a single capability directory
342
+ */
343
+ export async function scanCapability(
344
+ capabilityId: string,
345
+ capabilityPath: string,
346
+ settings: ScanSettings = DEFAULT_SCAN_SETTINGS,
347
+ ): Promise<ScanResult> {
348
+ const startTime = Date.now();
349
+ const findings: SecurityFinding[] = [];
350
+
351
+ if (!existsSync(capabilityPath)) {
352
+ return {
353
+ capabilityId,
354
+ path: capabilityPath,
355
+ findings: [],
356
+ passed: true,
357
+ duration: Date.now() - startTime,
358
+ };
359
+ }
360
+
361
+ // Recursively scan directory
362
+ async function scanDirectory(dirPath: string): Promise<void> {
363
+ const entries = await readdir(dirPath, { withFileTypes: true });
364
+
365
+ for (const entry of entries) {
366
+ const fullPath = join(dirPath, entry.name);
367
+ const relativePath = relative(capabilityPath, fullPath);
368
+
369
+ // Skip hidden files and common non-content directories
370
+ if (
371
+ entry.name.startsWith(".") ||
372
+ entry.name === "node_modules" ||
373
+ entry.name === "__pycache__"
374
+ ) {
375
+ continue;
376
+ }
377
+
378
+ const stats = await lstat(fullPath);
379
+
380
+ // Check symlinks
381
+ if (stats.isSymbolicLink() && settings.symlinks) {
382
+ const finding = await checkSymlink(fullPath, relativePath, capabilityPath);
383
+ if (finding) {
384
+ findings.push(finding);
385
+ }
386
+ continue; // Don't follow symlinks
387
+ }
388
+
389
+ // Handle directories
390
+ if (entry.isDirectory()) {
391
+ await scanDirectory(fullPath);
392
+ continue;
393
+ }
394
+
395
+ // Handle files
396
+ if (entry.isFile()) {
397
+ // Check for binary files
398
+ if (settings.binaries && isBinaryFile(fullPath)) {
399
+ findings.push({
400
+ type: "binary_file",
401
+ severity: "low",
402
+ file: relativePath,
403
+ message: "Binary file detected in capability",
404
+ });
405
+ }
406
+
407
+ // Only scan text files for content issues
408
+ if (isTextFile(fullPath)) {
409
+ // Scan for Unicode issues
410
+ if (settings.unicode) {
411
+ const unicodeFindings = await scanFileForUnicode(fullPath, relativePath);
412
+ findings.push(...unicodeFindings);
413
+ }
414
+
415
+ // Scan for script issues (only for shell/script files)
416
+ if (settings.scripts) {
417
+ const ext = fullPath.toLowerCase().substring(fullPath.lastIndexOf("."));
418
+ if ([".sh", ".bash", ".zsh", ".fish", ".py", ".rb", ".js", ".ts"].includes(ext)) {
419
+ const scriptFindings = await scanFileForScripts(fullPath, relativePath);
420
+ findings.push(...scriptFindings);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ await scanDirectory(capabilityPath);
429
+
430
+ return {
431
+ capabilityId,
432
+ path: capabilityPath,
433
+ findings,
434
+ passed: findings.length === 0 || findings.every((f) => f.severity === "low"),
435
+ duration: Date.now() - startTime,
436
+ };
437
+ }
438
+
439
+ /**
440
+ * Scan multiple capabilities and produce a summary
441
+ */
442
+ export async function scanCapabilities(
443
+ capabilities: Array<{ id: string; path: string }>,
444
+ config: SecurityConfig = {},
445
+ ): Promise<ScanSummary> {
446
+ const settings = {
447
+ ...DEFAULT_SCAN_SETTINGS,
448
+ ...config.scan,
449
+ };
450
+
451
+ const results: ScanResult[] = [];
452
+
453
+ for (const cap of capabilities) {
454
+ // Check if source is trusted
455
+ if (config.trusted_sources && config.trusted_sources.length > 0) {
456
+ // For now, skip trusted source checking (would need source info)
457
+ }
458
+
459
+ const result = await scanCapability(cap.id, cap.path, settings);
460
+ results.push(result);
461
+ }
462
+
463
+ // Build summary
464
+ const findingsByType: Record<FindingType, number> = {
465
+ unicode_bidi: 0,
466
+ unicode_zero_width: 0,
467
+ unicode_control: 0,
468
+ symlink_escape: 0,
469
+ symlink_absolute: 0,
470
+ suspicious_script: 0,
471
+ binary_file: 0,
472
+ };
473
+
474
+ const findingsBySeverity: Record<FindingSeverity, number> = {
475
+ low: 0,
476
+ medium: 0,
477
+ high: 0,
478
+ critical: 0,
479
+ };
480
+
481
+ let totalFindings = 0;
482
+ let capabilitiesWithFindings = 0;
483
+
484
+ for (const result of results) {
485
+ if (result.findings.length > 0) {
486
+ capabilitiesWithFindings++;
487
+ }
488
+ for (const finding of result.findings) {
489
+ totalFindings++;
490
+ findingsByType[finding.type]++;
491
+ findingsBySeverity[finding.severity]++;
492
+ }
493
+ }
494
+
495
+ return {
496
+ totalCapabilities: capabilities.length,
497
+ capabilitiesWithFindings,
498
+ totalFindings,
499
+ findingsByType,
500
+ findingsBySeverity,
501
+ results,
502
+ allPassed: results.every((r) => r.passed),
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Format scan results for console output
508
+ */
509
+ export function formatScanResults(summary: ScanSummary, verbose = false): string {
510
+ const lines: string[] = [];
511
+
512
+ lines.push("Security Scan Results");
513
+ lines.push("=====================");
514
+ lines.push("");
515
+
516
+ if (summary.totalFindings === 0) {
517
+ lines.push("✓ No security issues found");
518
+ return lines.join("\n");
519
+ }
520
+
521
+ lines.push(
522
+ `Found ${summary.totalFindings} issue(s) in ${summary.capabilitiesWithFindings} capability(ies)`,
523
+ );
524
+ lines.push("");
525
+
526
+ // Show by severity
527
+ if (summary.findingsBySeverity.critical > 0) {
528
+ lines.push(` ✗ Critical: ${summary.findingsBySeverity.critical}`);
529
+ }
530
+ if (summary.findingsBySeverity.high > 0) {
531
+ lines.push(` ✗ High: ${summary.findingsBySeverity.high}`);
532
+ }
533
+ if (summary.findingsBySeverity.medium > 0) {
534
+ lines.push(` ! Medium: ${summary.findingsBySeverity.medium}`);
535
+ }
536
+ if (summary.findingsBySeverity.low > 0) {
537
+ lines.push(` · Low: ${summary.findingsBySeverity.low}`);
538
+ }
539
+ lines.push("");
540
+
541
+ // Show detailed findings if verbose
542
+ if (verbose) {
543
+ for (const result of summary.results) {
544
+ if (result.findings.length === 0) continue;
545
+
546
+ lines.push(`${result.capabilityId}:`);
547
+ for (const finding of result.findings) {
548
+ const location = finding.line
549
+ ? `:${finding.line}${finding.column ? `:${finding.column}` : ""}`
550
+ : "";
551
+ const severity = finding.severity.toUpperCase().padEnd(8);
552
+ lines.push(` [${severity}] ${finding.file}${location}`);
553
+ lines.push(` ${finding.message}`);
554
+ if (finding.details) {
555
+ lines.push(` ${finding.details}`);
556
+ }
557
+ }
558
+ lines.push("");
559
+ }
560
+ }
561
+
562
+ return lines.join("\n");
563
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Security scanning types
3
+ *
4
+ * Base types (SecurityMode, ScanSettings, SecurityConfig) are defined in ../types/index.ts
5
+ * and used in OmniConfig. This file contains additional types for the scanner implementation.
6
+ */
7
+
8
+ import type { SecurityConfig, ScanSettings } from "../types/index.js";
9
+
10
+ // Re-export the base types from the main types file
11
+ export type { SecurityMode, ScanSettings, SecurityConfig } from "../types/index.js";
12
+
13
+ /**
14
+ * Severity level for security findings
15
+ */
16
+ export type FindingSeverity = "low" | "medium" | "high" | "critical";
17
+
18
+ /**
19
+ * Types of security findings
20
+ */
21
+ export type FindingType =
22
+ | "unicode_bidi"
23
+ | "unicode_zero_width"
24
+ | "unicode_control"
25
+ | "symlink_escape"
26
+ | "symlink_absolute"
27
+ | "suspicious_script"
28
+ | "binary_file";
29
+
30
+ /**
31
+ * A security finding (potential issue)
32
+ */
33
+ export interface SecurityFinding {
34
+ /** Type of finding */
35
+ type: FindingType;
36
+ /** Severity level */
37
+ severity: FindingSeverity;
38
+ /** File path relative to capability root */
39
+ file: string;
40
+ /** Line number (if applicable) */
41
+ line?: number;
42
+ /** Column number (if applicable) */
43
+ column?: number;
44
+ /** Human-readable description */
45
+ message: string;
46
+ /** Additional details (e.g., specific codepoints found) */
47
+ details?: string;
48
+ }
49
+
50
+ /**
51
+ * Result of scanning a capability
52
+ */
53
+ export interface ScanResult {
54
+ /** Capability ID */
55
+ capabilityId: string;
56
+ /** Capability path */
57
+ path: string;
58
+ /** List of findings */
59
+ findings: SecurityFinding[];
60
+ /** Whether the scan passed (no findings or mode=warn) */
61
+ passed: boolean;
62
+ /** Scan duration in milliseconds */
63
+ duration: number;
64
+ }
65
+
66
+ /**
67
+ * Overall scan summary
68
+ */
69
+ export interface ScanSummary {
70
+ /** Total capabilities scanned */
71
+ totalCapabilities: number;
72
+ /** Capabilities with findings */
73
+ capabilitiesWithFindings: number;
74
+ /** Total findings */
75
+ totalFindings: number;
76
+ /** Findings by type */
77
+ findingsByType: Record<FindingType, number>;
78
+ /** Findings by severity */
79
+ findingsBySeverity: Record<FindingSeverity, number>;
80
+ /** Individual scan results */
81
+ results: ScanResult[];
82
+ /** Whether all scans passed */
83
+ allPassed: boolean;
84
+ }
85
+
86
+ /**
87
+ * Default security configuration
88
+ */
89
+ export const DEFAULT_SECURITY_CONFIG: Required<SecurityConfig> = {
90
+ mode: "off",
91
+ trusted_sources: [],
92
+ scan: {
93
+ unicode: true,
94
+ symlinks: true,
95
+ scripts: true,
96
+ binaries: false,
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Default scan settings
102
+ */
103
+ export const DEFAULT_SCAN_SETTINGS: Required<ScanSettings> = {
104
+ unicode: true,
105
+ symlinks: true,
106
+ scripts: true,
107
+ binaries: false,
108
+ };
@@ -1,3 +1,4 @@
1
1
  export * from "./active-profile";
2
2
  export * from "./manifest";
3
3
  export * from "./providers";
4
+ export * from "./security-allows";