@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/README.md +190 -114
- package/docs/banner.png +0 -0
- package/docs/data/latest.json +6489 -0
- package/docs/index.html +530 -0
- package/package.json +64 -56
- package/src/mcp-server.js +236 -0
- package/src/patterns.js +345 -1
- package/src/scanner.js +127 -15
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
|
-
|
|
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 =
|
|
395
|
-
const lineNum = idx >= 0 ?
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
771
|
-
|
|
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(
|
|
774
|
-
sensitiveReads.push({ line: lineNum, text:
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
networkCalls.push({ line: lineNum, text:
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|