@guava-parity/guard-scanner 9.0.0 → 13.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/src/scanner.js CHANGED
@@ -160,6 +160,30 @@ class GuardScanner {
160
160
  }
161
161
  }
162
162
 
163
+ /**
164
+ * Scan raw text for threats (used for Discord incoming messages, etc.)
165
+ * @param {string} text - Raw text to scan
166
+ * @returns {{ safe: boolean, risk: number, detections: Array }}
167
+ */
168
+ scanText(text) {
169
+ const findings = [];
170
+ this.checkIoCs(text, 'raw_text', findings);
171
+ this.checkPatterns(text, 'raw_text', 'code', findings); // use 'code' to run all patterns
172
+ if (this.customRules.length > 0) {
173
+ this.checkPatterns(text, 'raw_text', 'code', findings, this.customRules);
174
+ }
175
+
176
+ // Filter ignored patterns
177
+ const filteredFindings = findings.filter(f => !this.ignoredPatterns.has(f.id));
178
+ const risk = this.calculateRisk(filteredFindings);
179
+
180
+ return {
181
+ safe: risk < this.thresholds.suspicious,
182
+ risk,
183
+ detections: filteredFindings
184
+ };
185
+ }
186
+
163
187
  scanDirectory(dir) {
164
188
  if (!fs.existsSync(dir)) {
165
189
  console.error(`❌ Directory not found: ${dir}`);
@@ -379,6 +403,23 @@ class GuardScanner {
379
403
  }
380
404
 
381
405
  checkPatterns(content, relFile, fileType, findings, patterns = PATTERNS) {
406
+ // v9: Payload Unfurling (Base64 / Hex Decoders)
407
+ let unfurledContent = content;
408
+
409
+ // Unfurl Buffer.from('...', 'base64') and atob('...')
410
+ const b64Regex = /(?:Buffer\.from\(\s*['"]([^'"]+)['"]\s*,\s*['"]base64['"]\)|atob\(\s*['"]([^'"]+)['"]\))/g;
411
+ unfurledContent = unfurledContent.replace(b64Regex, (match, g1, g2) => {
412
+ try {
413
+ const b64 = g1 || g2;
414
+ return Buffer.from(b64, 'base64').toString('utf8');
415
+ } catch { return match; }
416
+ });
417
+
418
+ // Unfurl hex escaped strings like \x63\x61\x74 -> cat
419
+ unfurledContent = unfurledContent.replace(/\\x([0-9a-fA-F]{2})/g, (match, hex) => {
420
+ return String.fromCharCode(parseInt(hex, 16));
421
+ });
422
+
382
423
  for (const pattern of patterns) {
383
424
  // Soul Lock: skip identity-hijack/memory-poisoning patterns unless --soul-lock is enabled
384
425
  if (pattern.soulLock && !this.soulLock) continue;
@@ -387,12 +428,21 @@ class GuardScanner {
387
428
  if (!pattern.all && !pattern.codeOnly && !pattern.docOnly) continue;
388
429
 
389
430
  pattern.regex.lastIndex = 0;
390
- const matches = content.match(pattern.regex);
431
+ let matches = content.match(pattern.regex);
432
+ let targetContent = content;
433
+
434
+ // If no match on raw content, try unfurled content
435
+ if (!matches && unfurledContent !== content) {
436
+ pattern.regex.lastIndex = 0;
437
+ matches = unfurledContent.match(pattern.regex);
438
+ targetContent = unfurledContent;
439
+ }
440
+
391
441
  if (!matches) continue;
392
442
 
393
443
  pattern.regex.lastIndex = 0;
394
- const idx = content.search(pattern.regex);
395
- const lineNum = idx >= 0 ? content.substring(0, idx).split('\n').length : null;
444
+ const idx = targetContent.search(pattern.regex);
445
+ const lineNum = idx >= 0 ? targetContent.substring(0, idx).split('\n').length : null;
396
446
 
397
447
  let adjustedSeverity = pattern.severity;
398
448
  if ((fileType === 'doc' || fileType === 'skill-doc') && pattern.all && !pattern.docOnly) {
@@ -751,36 +801,98 @@ class GuardScanner {
751
801
  }
752
802
 
753
803
  checkJSDataFlow(content, relFile, findings) {
754
- const lines = content.split('\n');
804
+ // v9: Pseudo-AST Semantic Unfurling & Alias Tracking
805
+ // 1. Resolve string concatenations (e.g., '"f" + "etch"' -> '"fetch"')
806
+ let unfurledContent = content.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
807
+ for (let i = 0; i < 3; i++) { // Deep unfurl (up to 3 concats)
808
+ unfurledContent = unfurledContent.replace(/(["'`])([^"'`]*)\1\s*\+\s*(["'`])([^"'`]*)\3/g, '$1$2$4$1');
809
+ }
810
+
811
+ const lines = unfurledContent.split('\n');
755
812
  const imports = new Map();
756
813
  const sensitiveReads = [];
757
814
  const networkCalls = [];
758
815
  const execCalls = [];
759
816
 
817
+ // Alias Tracker for Sinks & Vars
818
+ const activeAliases = {
819
+ network: ['fetch', 'axios', 'request', 'http.request', 'https.request', 'got'],
820
+ exec: ['exec', 'execSync', 'spawn', 'spawnSync', 'execFile', "require('child_process').execSync"],
821
+ fsRead: ['readFileSync', 'readFile', 'fs.readFileSync', 'fs.readFile', "require('fs').readFileSync"]
822
+ };
823
+ const stringVars = new Map();
824
+
825
+ const registerAlias = (alias, target) => {
826
+ if (!alias || !target) return;
827
+ for (const [key, sinks] of Object.entries(activeAliases)) {
828
+ if (sinks.some(s => target.includes(s) || s.includes(target))) {
829
+ activeAliases[key].push(alias);
830
+ }
831
+ }
832
+ };
833
+
834
+ // Pass 1: Extract Context & Aliases & Values
760
835
  for (let i = 0; i < lines.length; i++) {
761
836
  const line = lines[i];
762
- const lineNum = i + 1;
763
837
 
838
+ // Standard variable assignment: const getRemote = fetch;
839
+ const aliasMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*([a-zA-Z0-9_$.]+(?:\([^)]*\))?)\s*;/);
840
+ if (aliasMatch) {
841
+ registerAlias(aliasMatch[1], aliasMatch[2]);
842
+ }
843
+
844
+ // String literals: const target = ".env";
845
+ const strMatch = line.match(/(?:const|let|var)\s+([a-zA-Z0-9_$]+)\s*=\s*(["'`])([^"'`]+)\2/);
846
+ if (strMatch) {
847
+ stringVars.set(strMatch[1], strMatch[3]); // target -> .env
848
+ }
849
+
850
+ // Require assignments: const fs = require('fs')
764
851
  const reqMatch = line.match(/(?:const|let|var)\s+(?:{[^}]+}|\w+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
765
852
  if (reqMatch) {
766
853
  const varMatch = line.match(/(?:const|let|var)\s+({[^}]+}|\w+)/);
767
- if (varMatch) imports.set(varMatch[1].trim(), reqMatch[1]);
854
+ if (varMatch) {
855
+ const aliasName = varMatch[1].trim();
856
+ imports.set(aliasName, reqMatch[1]);
857
+ registerAlias(`${aliasName}.readFileSync`, 'readFileSync'); // Link fs methods
858
+ registerAlias(`${aliasName}.readFile`, 'readFile');
859
+ registerAlias(`${aliasName}.exec`, 'exec');
860
+ registerAlias(`${aliasName}.execSync`, 'execSync');
861
+ }
862
+ }
863
+ }
864
+
865
+ // Helper to create safe regex from dynamic aliases
866
+ const escapeRegex = (arr) => arr.map(a => a.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')).join('|');
867
+
868
+ // Pass 2: Data Flow Matching with Interpolation
869
+ for (let i = 0; i < lines.length; i++) {
870
+ const line = lines[i];
871
+ const lineNum = i + 1;
872
+
873
+ // Pseudo-AST: substitute known literal vars into the line to reveal logic
874
+ let resolvedLine = line;
875
+ for (const [k, v] of stringVars.entries()) {
876
+ // replace var usage but only for whole words
877
+ resolvedLine = resolvedLine.replace(new RegExp(`\\b${k}\\b`, 'g'), `"${v}"`);
768
878
  }
769
879
 
770
- if (/(?:readFileSync|readFile)\s*\([^)]*(?:\.env|\.ssh|id_rsa|\.clawdbot|\.openclaw(?!\/workspace))/i.test(line)) {
771
- sensitiveReads.push({ line: lineNum, text: line.trim() });
880
+ const fsPattern = new RegExp(`(?:${escapeRegex(activeAliases.fsRead)})\\s*\\([^)]*(?:\\.env|\\.ssh|id_rsa|\\.clawdbot|\\.openclaw(?!\\/workspace))`, 'i');
881
+ if (fsPattern.test(resolvedLine)) {
882
+ sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
772
883
  }
773
- if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(line)) {
774
- sensitiveReads.push({ line: lineNum, text: line.trim() });
884
+ if (/process\.env\.[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)/i.test(resolvedLine)) {
885
+ sensitiveReads.push({ line: lineNum, text: resolvedLine.trim() });
775
886
  }
776
887
 
777
- if (/(?:fetch|axios|request|http\.request|https\.request|got)\s*\(/i.test(line) ||
778
- /\.post\s*\(|\.put\s*\(|\.patch\s*\(/i.test(line)) {
779
- networkCalls.push({ line: lineNum, text: line.trim() });
888
+ const netPattern = new RegExp(`(?:${escapeRegex(activeAliases.network)})\\s*\\(`, 'i');
889
+ if (netPattern.test(resolvedLine) || /\.post\s*\(|\.put\s*\(|\.patch\s*\(/.test(resolvedLine)) {
890
+ networkCalls.push({ line: lineNum, text: resolvedLine.trim() });
780
891
  }
781
892
 
782
- if (/(?:exec|execSync|spawn|spawnSync|execFile)\s*\(/i.test(line)) {
783
- execCalls.push({ line: lineNum, text: line.trim() });
893
+ const execPattern = new RegExp(`(?:${escapeRegex(activeAliases.exec)})\\s*\\(`, 'i');
894
+ if (execPattern.test(resolvedLine)) {
895
+ execCalls.push({ line: lineNum, text: resolvedLine.trim() });
784
896
  }
785
897
  }
786
898