shieldcortex 4.2.4 → 4.3.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/dist/cli/doctor.js +54 -13
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -1
- package/dist/license/gate.d.ts +1 -1
- package/dist/license/gate.js +2 -0
- package/dist/xray/dir-scanner.d.ts +14 -0
- package/dist/xray/dir-scanner.js +77 -0
- package/dist/xray/file-scanner.d.ts +15 -0
- package/dist/xray/file-scanner.js +296 -0
- package/dist/xray/index.d.ts +20 -0
- package/dist/xray/index.js +166 -0
- package/dist/xray/npm-inspector.d.ts +15 -0
- package/dist/xray/npm-inspector.js +380 -0
- package/dist/xray/patterns.d.ts +20 -0
- package/dist/xray/patterns.js +271 -0
- package/dist/xray/report.d.ts +16 -0
- package/dist/xray/report.js +193 -0
- package/dist/xray/trust-score.d.ts +10 -0
- package/dist/xray/trust-score.js +37 -0
- package/dist/xray/types.d.ts +29 -0
- package/dist/xray/types.js +7 -0
- package/package.json +1 -1
- package/plugins/openclaw/dist/openclaw.plugin.json +1 -1
package/dist/cli/doctor.js
CHANGED
|
@@ -36,6 +36,22 @@ function formatBytes(bytes) {
|
|
|
36
36
|
function getShieldCortexDir() {
|
|
37
37
|
return path.join(os.homedir(), '.shieldcortex');
|
|
38
38
|
}
|
|
39
|
+
function detectEnvironment() {
|
|
40
|
+
const home = os.homedir();
|
|
41
|
+
const hasClaude = fs.existsSync(path.join(home, '.claude')) || fs.existsSync(path.join(home, '.claude.json'));
|
|
42
|
+
const hasOpenClaw = fs.existsSync(path.join(home, '.openclaw'));
|
|
43
|
+
const hasCodex = fs.existsSync(path.join(home, '.codex'));
|
|
44
|
+
const platform = process.platform;
|
|
45
|
+
const vscodeDirs = platform === 'darwin'
|
|
46
|
+
? [path.join(home, 'Library', 'Application Support', 'Code', 'User')]
|
|
47
|
+
: platform === 'win32'
|
|
48
|
+
? [path.join(process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'Code', 'User')]
|
|
49
|
+
: [path.join(home, '.config', 'Code', 'User')];
|
|
50
|
+
const hasVSCode = vscodeDirs.some(d => fs.existsSync(d));
|
|
51
|
+
// Headless = no display environment (SSH server, container, etc.)
|
|
52
|
+
const isHeadless = !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && platform !== 'darwin' && platform !== 'win32';
|
|
53
|
+
return { hasClaude, hasOpenClaw, hasVSCode, hasCodex, isHeadless };
|
|
54
|
+
}
|
|
39
55
|
function getDbPath() {
|
|
40
56
|
const newPath = path.join(getShieldCortexDir(), 'memories.db');
|
|
41
57
|
const legacyPath = path.join(os.homedir(), '.claude-memory', 'memories.db');
|
|
@@ -49,11 +65,15 @@ function getDbPath() {
|
|
|
49
65
|
async function checkDatabase() {
|
|
50
66
|
const dbPath = getDbPath();
|
|
51
67
|
if (!fs.existsSync(dbPath)) {
|
|
68
|
+
const env = detectEnvironment();
|
|
69
|
+
const fix = env.hasOpenClaw && !env.hasClaude
|
|
70
|
+
? 'Run `shieldcortex scan "test"` to initialise the database (OpenClaw-only setup detected)'
|
|
71
|
+
: 'Start the MCP server or run `shieldcortex quickstart` to initialise the database';
|
|
52
72
|
return {
|
|
53
73
|
label: 'Database',
|
|
54
74
|
status: 'fail',
|
|
55
75
|
message: 'not found',
|
|
56
|
-
fix
|
|
76
|
+
fix,
|
|
57
77
|
};
|
|
58
78
|
}
|
|
59
79
|
try {
|
|
@@ -225,6 +245,9 @@ async function checkHooks() {
|
|
|
225
245
|
// ── Check 5: Process check ────────────────────────────────
|
|
226
246
|
async function checkProcesses() {
|
|
227
247
|
const results = [];
|
|
248
|
+
const env = detectEnvironment();
|
|
249
|
+
// On headless/OpenClaw-only setups, API/Dashboard are optional
|
|
250
|
+
const isOptional = env.isHeadless || (env.hasOpenClaw && !env.hasClaude && !env.hasVSCode);
|
|
228
251
|
// Check API server on port 3001
|
|
229
252
|
try {
|
|
230
253
|
const controller = new AbortController();
|
|
@@ -244,12 +267,21 @@ async function checkProcesses() {
|
|
|
244
267
|
}
|
|
245
268
|
}
|
|
246
269
|
catch {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
if (isOptional) {
|
|
271
|
+
results.push({
|
|
272
|
+
label: 'API server',
|
|
273
|
+
status: 'info',
|
|
274
|
+
message: 'not running (optional on headless/OpenClaw-only setups)',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
results.push({
|
|
279
|
+
label: 'API server',
|
|
280
|
+
status: 'warn',
|
|
281
|
+
message: 'not running',
|
|
282
|
+
fix: 'Run `shieldcortex dashboard` to start the API server',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
253
285
|
}
|
|
254
286
|
// Check dashboard on port 3030
|
|
255
287
|
try {
|
|
@@ -270,12 +302,21 @@ async function checkProcesses() {
|
|
|
270
302
|
}
|
|
271
303
|
}
|
|
272
304
|
catch {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
if (isOptional) {
|
|
306
|
+
results.push({
|
|
307
|
+
label: 'Dashboard',
|
|
308
|
+
status: 'info',
|
|
309
|
+
message: 'not running (optional on headless/OpenClaw-only setups)',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
results.push({
|
|
314
|
+
label: 'Dashboard',
|
|
315
|
+
status: 'warn',
|
|
316
|
+
message: 'not running',
|
|
317
|
+
fix: 'Run `shieldcortex dashboard` to start the dashboard',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
279
320
|
}
|
|
280
321
|
return results;
|
|
281
322
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
* shieldcortex codex install # Configure MCP server for Codex CLI + VS Code extension
|
|
44
44
|
* shieldcortex codex uninstall # Remove Codex MCP configuration
|
|
45
45
|
* shieldcortex codex status # Check Codex MCP configuration
|
|
46
|
+
* shieldcortex xray <target> # Inspect package/file/plugin for hidden risk
|
|
47
|
+
* shieldcortex xray <path> --deep # Deep scan (Pro) with full analysis
|
|
48
|
+
* shieldcortex xray <package> --json # JSON output
|
|
46
49
|
* shieldcortex cortex capture # Log a mistake with rule extraction
|
|
47
50
|
* shieldcortex cortex preflight # Pre-flight check before a task
|
|
48
51
|
* shieldcortex cortex review # Weekly pattern review
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
* shieldcortex codex install # Configure MCP server for Codex CLI + VS Code extension
|
|
44
44
|
* shieldcortex codex uninstall # Remove Codex MCP configuration
|
|
45
45
|
* shieldcortex codex status # Check Codex MCP configuration
|
|
46
|
+
* shieldcortex xray <target> # Inspect package/file/plugin for hidden risk
|
|
47
|
+
* shieldcortex xray <path> --deep # Deep scan (Pro) with full analysis
|
|
48
|
+
* shieldcortex xray <package> --json # JSON output
|
|
46
49
|
* shieldcortex cortex capture # Log a mistake with rule extraction
|
|
47
50
|
* shieldcortex cortex preflight # Pre-flight check before a task
|
|
48
51
|
* shieldcortex cortex review # Weekly pattern review
|
|
@@ -763,6 +766,12 @@ ${bold}DOCS${reset}
|
|
|
763
766
|
console.log(` Total processed: ${result.totalProcessed}`);
|
|
764
767
|
process.exit(0);
|
|
765
768
|
}
|
|
769
|
+
// Handle "xray" subcommand — package/file/plugin risk inspector
|
|
770
|
+
if (process.argv[2] === 'xray') {
|
|
771
|
+
const { handleXRayCommand } = await import('./xray/index.js');
|
|
772
|
+
await handleXRayCommand(process.argv.slice(3));
|
|
773
|
+
process.exit(0);
|
|
774
|
+
}
|
|
766
775
|
// Handle "cortex" subcommand (Pro tier — mistake learning)
|
|
767
776
|
if (process.argv[2] === 'cortex') {
|
|
768
777
|
const { handleCortexCommand } = await import('./cortex/cli.js');
|
|
@@ -774,7 +783,7 @@ ${bold}DOCS${reset}
|
|
|
774
783
|
'doctor', 'quickstart', 'setup', 'install', 'migrate', 'uninstall', 'hook',
|
|
775
784
|
'openclaw', 'clawdbot', 'copilot', 'codex', 'service', 'config', 'status',
|
|
776
785
|
'graph', 'license', 'licence', 'audit', 'iron-dome', 'scan', 'cloud',
|
|
777
|
-
'scan-skill', 'scan-skills', 'dashboard', 'api', 'worker', 'stats', 'cortex', 'consolidate',
|
|
786
|
+
'scan-skill', 'scan-skills', 'dashboard', 'api', 'worker', 'stats', 'cortex', 'consolidate', 'xray',
|
|
778
787
|
]);
|
|
779
788
|
const arg = process.argv[2];
|
|
780
789
|
if (arg && !arg.startsWith('-') && !knownCommands.has(arg)) {
|
package/dist/license/gate.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* isFeatureEnabled('cloud_sync'); // returns boolean (soft check)
|
|
7
7
|
*/
|
|
8
8
|
import { type LicenseTier } from './keys.js';
|
|
9
|
-
export type GatedFeature = 'custom_injection_patterns' | 'custom_iron_dome_policies' | 'custom_firewall_rules' | 'audit_export' | 'skill_scanner_deep' | 'cloud_sync' | 'team_management' | 'shared_patterns' | 'cortex_learning' | 'deps_quarantine' | 'deps_clean' | 'deps_auto_protect' | 'deps_global_scan' | 'memory_types' | 'memory_scopes' | 'dream_mode' | 'llm_reranking' | 'positive_feedback';
|
|
9
|
+
export type GatedFeature = 'custom_injection_patterns' | 'custom_iron_dome_policies' | 'custom_firewall_rules' | 'audit_export' | 'skill_scanner_deep' | 'cloud_sync' | 'team_management' | 'shared_patterns' | 'cortex_learning' | 'deps_quarantine' | 'deps_clean' | 'deps_auto_protect' | 'deps_global_scan' | 'memory_types' | 'memory_scopes' | 'dream_mode' | 'llm_reranking' | 'positive_feedback' | 'xray_deep';
|
|
10
10
|
export declare class FeatureGatedError extends Error {
|
|
11
11
|
feature: GatedFeature;
|
|
12
12
|
requiredTier: LicenseTier;
|
package/dist/license/gate.js
CHANGED
|
@@ -26,6 +26,7 @@ const FEATURE_TIERS = {
|
|
|
26
26
|
dream_mode: 'pro',
|
|
27
27
|
llm_reranking: 'pro',
|
|
28
28
|
positive_feedback: 'pro',
|
|
29
|
+
xray_deep: 'pro',
|
|
29
30
|
};
|
|
30
31
|
const FEATURE_DESCRIPTIONS = {
|
|
31
32
|
custom_injection_patterns: 'Define up to 50 custom regex patterns for detecting domain-specific threats.',
|
|
@@ -46,6 +47,7 @@ const FEATURE_DESCRIPTIONS = {
|
|
|
46
47
|
dream_mode: 'Background memory consolidation — merge duplicates, archive stale, detect contradictions.',
|
|
47
48
|
llm_reranking: 'LLM-powered memory reranking for precision recall.',
|
|
48
49
|
positive_feedback: 'Capture what worked, not just what failed — learn from success.',
|
|
50
|
+
xray_deep: 'Deep X-Ray scanning: npm registry analysis, binary file inspection, dependency graph risk, and AI-directive detection in metadata and filenames.',
|
|
49
51
|
};
|
|
50
52
|
// ── Error class ──────────────────────────────────────────
|
|
51
53
|
export class FeatureGatedError extends Error {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-Ray Directory Scanner
|
|
3
|
+
*
|
|
4
|
+
* Walks a directory tree, scans each file, checks package.json if present,
|
|
5
|
+
* and aggregates findings into a single XRayResult.
|
|
6
|
+
*/
|
|
7
|
+
import type { XRayResult } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Scan an entire directory for X-Ray findings.
|
|
10
|
+
*
|
|
11
|
+
* @param dirPath - Path to the directory to scan
|
|
12
|
+
* @param deep - If true, performs deeper analysis (Pro feature)
|
|
13
|
+
*/
|
|
14
|
+
export declare function scanDirectory(dirPath: string, deep: boolean): Promise<XRayResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-Ray Directory Scanner
|
|
3
|
+
*
|
|
4
|
+
* Walks a directory tree, scans each file, checks package.json if present,
|
|
5
|
+
* and aggregates findings into a single XRayResult.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { scanFile } from './file-scanner.js';
|
|
10
|
+
import { calculateTrustScore } from './trust-score.js';
|
|
11
|
+
// ── Constants ───────────────────────────────────────────────
|
|
12
|
+
/** Directories to skip when walking. */
|
|
13
|
+
const SKIP_DIRS = new Set([
|
|
14
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', '.next',
|
|
15
|
+
'__pycache__', '.tox', '.venv', 'venv', '.cache', 'coverage',
|
|
16
|
+
]);
|
|
17
|
+
/** Maximum number of files to scan per directory. */
|
|
18
|
+
const MAX_FILES = 5000;
|
|
19
|
+
// ── Directory walker ────────────────────────────────────────
|
|
20
|
+
function walkDir(dirPath, files, depth = 0) {
|
|
21
|
+
if (depth > 20 || files.length >= MAX_FILES)
|
|
22
|
+
return;
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (files.length >= MAX_FILES)
|
|
32
|
+
break;
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
35
|
+
walkDir(path.join(dirPath, entry.name), files, depth + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (entry.isFile()) {
|
|
39
|
+
files.push(path.join(dirPath, entry.name));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ── Public API ──────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Scan an entire directory for X-Ray findings.
|
|
46
|
+
*
|
|
47
|
+
* @param dirPath - Path to the directory to scan
|
|
48
|
+
* @param deep - If true, performs deeper analysis (Pro feature)
|
|
49
|
+
*/
|
|
50
|
+
export async function scanDirectory(dirPath, deep) {
|
|
51
|
+
const startTime = Date.now();
|
|
52
|
+
const allFindings = [];
|
|
53
|
+
// Collect files
|
|
54
|
+
const files = [];
|
|
55
|
+
walkDir(dirPath, files);
|
|
56
|
+
// Scan each file
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const findings = await scanFile(file, deep);
|
|
59
|
+
allFindings.push(...findings);
|
|
60
|
+
}
|
|
61
|
+
// Check for package.json at root
|
|
62
|
+
const pkgJsonPath = path.join(dirPath, 'package.json');
|
|
63
|
+
if (fs.existsSync(pkgJsonPath) && !files.includes(pkgJsonPath)) {
|
|
64
|
+
const findings = await scanFile(pkgJsonPath, deep);
|
|
65
|
+
allFindings.push(...findings);
|
|
66
|
+
}
|
|
67
|
+
const { score, riskLevel } = calculateTrustScore(allFindings);
|
|
68
|
+
return {
|
|
69
|
+
target: dirPath,
|
|
70
|
+
trustScore: score,
|
|
71
|
+
riskLevel,
|
|
72
|
+
findings: allFindings,
|
|
73
|
+
filesScanned: files.length,
|
|
74
|
+
scannedAt: new Date(),
|
|
75
|
+
deepScan: deep,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-Ray File Scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans individual files for hidden risk patterns.
|
|
5
|
+
* Handles code files (.js/.ts), JSON, and binary/image files.
|
|
6
|
+
* Uses only Node.js built-ins — no new dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import type { XRayFinding } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Scan a single file for X-Ray findings.
|
|
11
|
+
*
|
|
12
|
+
* @param filePath - Absolute or relative path to the file
|
|
13
|
+
* @param deep - If true, performs deeper analysis (Pro feature)
|
|
14
|
+
*/
|
|
15
|
+
export declare function scanFile(filePath: string, deep: boolean): Promise<XRayFinding[]>;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-Ray File Scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans individual files for hidden risk patterns.
|
|
5
|
+
* Handles code files (.js/.ts), JSON, and binary/image files.
|
|
6
|
+
* Uses only Node.js built-ins — no new dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { detectPatterns, detectFilenameDirectives } from './patterns.js';
|
|
11
|
+
// ── Constants ───────────────────────────────────────────────
|
|
12
|
+
/** Extensions treated as code files. */
|
|
13
|
+
const CODE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs', '.mts', '.cts', '.jsx', '.tsx']);
|
|
14
|
+
/** Extensions treated as JSON. */
|
|
15
|
+
const JSON_EXTENSIONS = new Set(['.json']);
|
|
16
|
+
/** Extensions treated as images. */
|
|
17
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']);
|
|
18
|
+
/** Maximum file size to scan (10 MB). */
|
|
19
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
20
|
+
// ── Image EOI markers ───────────────────────────────────────
|
|
21
|
+
/** JPEG End-of-Image marker. */
|
|
22
|
+
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
|
23
|
+
/** PNG IEND chunk signature. */
|
|
24
|
+
const PNG_IEND = Buffer.from([0x49, 0x45, 0x4e, 0x44]);
|
|
25
|
+
/** GIF trailer byte. */
|
|
26
|
+
const GIF_TRAILER = 0x3b;
|
|
27
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
28
|
+
function hasZeroWidthUnicode(content) {
|
|
29
|
+
const zwMatch = content.match(/[\u200b\u200c\u200d\ufeff\u2060]{2,}/);
|
|
30
|
+
if (!zwMatch)
|
|
31
|
+
return null;
|
|
32
|
+
const before = content.slice(0, zwMatch.index);
|
|
33
|
+
const line = (before.match(/\n/g) || []).length + 1;
|
|
34
|
+
return {
|
|
35
|
+
severity: 'medium',
|
|
36
|
+
category: 'unicode-trick',
|
|
37
|
+
title: 'Zero-width Unicode characters detected',
|
|
38
|
+
description: 'File contains clusters of zero-width characters that may hide invisible content.',
|
|
39
|
+
line,
|
|
40
|
+
evidence: `${zwMatch[0].length} zero-width chars at line ${line}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function checkPolyglot(buf) {
|
|
44
|
+
// Check for multiple magic byte signatures in a single file
|
|
45
|
+
const signatures = [
|
|
46
|
+
{ name: 'PDF', magic: Buffer.from('%PDF') },
|
|
47
|
+
{ name: 'ZIP/JAR', magic: Buffer.from([0x50, 0x4b, 0x03, 0x04]) },
|
|
48
|
+
{ name: 'ELF', magic: Buffer.from([0x7f, 0x45, 0x4c, 0x46]) },
|
|
49
|
+
{ name: 'PE/EXE', magic: Buffer.from([0x4d, 0x5a]) },
|
|
50
|
+
{ name: 'GZIP', magic: Buffer.from([0x1f, 0x8b]) },
|
|
51
|
+
];
|
|
52
|
+
const matched = [];
|
|
53
|
+
for (const sig of signatures) {
|
|
54
|
+
// Check at start and also embedded
|
|
55
|
+
if (buf.includes(sig.magic)) {
|
|
56
|
+
matched.push(sig.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (matched.length >= 2) {
|
|
60
|
+
return {
|
|
61
|
+
severity: 'high',
|
|
62
|
+
category: 'steganography',
|
|
63
|
+
title: 'Polyglot file detected',
|
|
64
|
+
description: `File contains multiple format signatures (${matched.join(', ')}), indicating a polyglot that may hide payloads.`,
|
|
65
|
+
evidence: matched.join(', '),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// ── Image scanner ───────────────────────────────────────────
|
|
71
|
+
function scanImage(filePath, buf) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
74
|
+
// Check for appended data after EOI marker
|
|
75
|
+
if (ext === '.jpg' || ext === '.jpeg') {
|
|
76
|
+
const eoiIndex = buf.lastIndexOf(JPEG_EOI);
|
|
77
|
+
if (eoiIndex >= 0 && eoiIndex < buf.length - 2) {
|
|
78
|
+
const trailing = buf.length - eoiIndex - 2;
|
|
79
|
+
if (trailing > 16) {
|
|
80
|
+
findings.push({
|
|
81
|
+
severity: 'high',
|
|
82
|
+
category: 'steganography',
|
|
83
|
+
title: 'Data appended after JPEG EOI marker',
|
|
84
|
+
description: `${trailing} bytes found after the JPEG End-of-Image marker — may contain hidden payloads.`,
|
|
85
|
+
file: filePath,
|
|
86
|
+
evidence: `${trailing} trailing bytes after EOI at offset ${eoiIndex}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (ext === '.png') {
|
|
92
|
+
const iendIndex = buf.lastIndexOf(PNG_IEND);
|
|
93
|
+
if (iendIndex >= 0) {
|
|
94
|
+
// IEND chunk: 4-byte length + 4-byte type + 4-byte CRC = the IEND text is at +4 from chunk start
|
|
95
|
+
// After IEND chunk: iendIndex + 4 (IEND) + 4 (CRC) = iendIndex + 8
|
|
96
|
+
const afterIend = iendIndex + 8;
|
|
97
|
+
if (afterIend < buf.length) {
|
|
98
|
+
const trailing = buf.length - afterIend;
|
|
99
|
+
if (trailing > 16) {
|
|
100
|
+
findings.push({
|
|
101
|
+
severity: 'high',
|
|
102
|
+
category: 'steganography',
|
|
103
|
+
title: 'Data appended after PNG IEND chunk',
|
|
104
|
+
description: `${trailing} bytes found after the PNG IEND marker — may contain hidden payloads.`,
|
|
105
|
+
file: filePath,
|
|
106
|
+
evidence: `${trailing} trailing bytes after IEND at offset ${iendIndex}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (ext === '.gif') {
|
|
113
|
+
const lastByte = buf[buf.length - 1];
|
|
114
|
+
if (lastByte !== GIF_TRAILER) {
|
|
115
|
+
// Find the trailer
|
|
116
|
+
const trailerIndex = buf.lastIndexOf(GIF_TRAILER);
|
|
117
|
+
if (trailerIndex >= 0 && trailerIndex < buf.length - 1) {
|
|
118
|
+
const trailing = buf.length - trailerIndex - 1;
|
|
119
|
+
if (trailing > 16) {
|
|
120
|
+
findings.push({
|
|
121
|
+
severity: 'high',
|
|
122
|
+
category: 'steganography',
|
|
123
|
+
title: 'Data appended after GIF trailer',
|
|
124
|
+
description: `${trailing} bytes found after the GIF trailer byte — may contain hidden payloads.`,
|
|
125
|
+
file: filePath,
|
|
126
|
+
evidence: `${trailing} trailing bytes after GIF trailer`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Check EXIF/metadata chunks for text with AI directives
|
|
133
|
+
// Look for readable text in the binary that matches AI directive patterns
|
|
134
|
+
const textChunks = extractTextFromBinary(buf);
|
|
135
|
+
if (textChunks) {
|
|
136
|
+
const metaFindings = detectPatterns(textChunks, filePath);
|
|
137
|
+
for (const f of metaFindings) {
|
|
138
|
+
if (f.category === 'ai-directive' || f.category === 'metadata-exploit') {
|
|
139
|
+
f.description = `Image metadata contains: ${f.description}`;
|
|
140
|
+
findings.push(f);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Extract readable ASCII/UTF-8 text sequences from a binary buffer.
|
|
148
|
+
* Returns concatenated text chunks >= 8 chars for pattern scanning.
|
|
149
|
+
*/
|
|
150
|
+
function extractTextFromBinary(buf) {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let current = '';
|
|
153
|
+
for (let i = 0; i < buf.length && i < 1024 * 1024; i++) {
|
|
154
|
+
const byte = buf[i];
|
|
155
|
+
// Printable ASCII range
|
|
156
|
+
if (byte >= 0x20 && byte <= 0x7e) {
|
|
157
|
+
current += String.fromCharCode(byte);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
if (current.length >= 8) {
|
|
161
|
+
chunks.push(current);
|
|
162
|
+
}
|
|
163
|
+
current = '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (current.length >= 8) {
|
|
167
|
+
chunks.push(current);
|
|
168
|
+
}
|
|
169
|
+
return chunks.length > 0 ? chunks.join('\n') : null;
|
|
170
|
+
}
|
|
171
|
+
// ── JSON scanner ────────────────────────────────────────────
|
|
172
|
+
function scanJson(filePath, content) {
|
|
173
|
+
const findings = [];
|
|
174
|
+
// Run pattern detection for metadata injection, postinstall hooks, etc.
|
|
175
|
+
findings.push(...detectPatterns(content, filePath));
|
|
176
|
+
// Check for suspicious scripts in package.json
|
|
177
|
+
const basename = path.basename(filePath);
|
|
178
|
+
if (basename === 'package.json') {
|
|
179
|
+
try {
|
|
180
|
+
const pkg = JSON.parse(content);
|
|
181
|
+
const scripts = pkg.scripts || {};
|
|
182
|
+
for (const hook of ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall']) {
|
|
183
|
+
if (scripts[hook]) {
|
|
184
|
+
const val = scripts[hook];
|
|
185
|
+
if (/curl|wget|node\s+-e|bash\s+-c|powershell|https?:\/\/|eval|exec/i.test(val)) {
|
|
186
|
+
findings.push({
|
|
187
|
+
severity: 'high',
|
|
188
|
+
category: 'persistence-hook',
|
|
189
|
+
title: `Suspicious ${hook} script`,
|
|
190
|
+
description: `package.json "${hook}" script executes potentially dangerous commands.`,
|
|
191
|
+
file: filePath,
|
|
192
|
+
evidence: val.slice(0, 120),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Not valid JSON — just rely on pattern detection
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
// ── Public API ──────────────────────────────────────────────
|
|
205
|
+
/**
|
|
206
|
+
* Scan a single file for X-Ray findings.
|
|
207
|
+
*
|
|
208
|
+
* @param filePath - Absolute or relative path to the file
|
|
209
|
+
* @param deep - If true, performs deeper analysis (Pro feature)
|
|
210
|
+
*/
|
|
211
|
+
export async function scanFile(filePath, deep) {
|
|
212
|
+
const findings = [];
|
|
213
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
214
|
+
const basename = path.basename(filePath);
|
|
215
|
+
// Check filename for AI directives
|
|
216
|
+
findings.push(...detectFilenameDirectives(basename));
|
|
217
|
+
// Check file exists and size
|
|
218
|
+
let stat;
|
|
219
|
+
try {
|
|
220
|
+
stat = fs.statSync(filePath);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return findings;
|
|
224
|
+
}
|
|
225
|
+
if (!stat.isFile() || stat.size > MAX_FILE_SIZE) {
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
// Route by extension
|
|
229
|
+
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
230
|
+
const buf = fs.readFileSync(filePath);
|
|
231
|
+
findings.push(...scanImage(filePath, buf));
|
|
232
|
+
// Polyglot check
|
|
233
|
+
const polyglot = checkPolyglot(buf);
|
|
234
|
+
if (polyglot) {
|
|
235
|
+
polyglot.file = filePath;
|
|
236
|
+
findings.push(polyglot);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (JSON_EXTENSIONS.has(ext)) {
|
|
240
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
241
|
+
findings.push(...scanJson(filePath, content));
|
|
242
|
+
// Zero-width unicode check
|
|
243
|
+
const zw = hasZeroWidthUnicode(content);
|
|
244
|
+
if (zw) {
|
|
245
|
+
zw.file = filePath;
|
|
246
|
+
findings.push(zw);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (CODE_EXTENSIONS.has(ext)) {
|
|
250
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
251
|
+
findings.push(...detectPatterns(content, filePath));
|
|
252
|
+
// Zero-width unicode check
|
|
253
|
+
const zw = hasZeroWidthUnicode(content);
|
|
254
|
+
if (zw) {
|
|
255
|
+
zw.file = filePath;
|
|
256
|
+
findings.push(zw);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// For any other text-like file, try reading as text
|
|
261
|
+
try {
|
|
262
|
+
const buf = fs.readFileSync(filePath);
|
|
263
|
+
// Check if it's mostly text
|
|
264
|
+
let nonPrintable = 0;
|
|
265
|
+
const sampleSize = Math.min(buf.length, 8192);
|
|
266
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
267
|
+
const b = buf[i];
|
|
268
|
+
if (b < 0x09 || (b > 0x0d && b < 0x20 && b !== 0x1b)) {
|
|
269
|
+
nonPrintable++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (nonPrintable / sampleSize < 0.1) {
|
|
273
|
+
// Treat as text
|
|
274
|
+
const content = buf.toString('utf-8');
|
|
275
|
+
findings.push(...detectPatterns(content, filePath));
|
|
276
|
+
const zw = hasZeroWidthUnicode(content);
|
|
277
|
+
if (zw) {
|
|
278
|
+
zw.file = filePath;
|
|
279
|
+
findings.push(zw);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
// Binary file — polyglot check
|
|
284
|
+
const polyglot = checkPolyglot(buf);
|
|
285
|
+
if (polyglot) {
|
|
286
|
+
polyglot.file = filePath;
|
|
287
|
+
findings.push(polyglot);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Can't read — skip
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return findings;
|
|
296
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X-Ray — Package, File & Plugin Risk Inspector
|
|
3
|
+
*
|
|
4
|
+
* Main entry point for the `shieldcortex xray` CLI command.
|
|
5
|
+
* Inspects npm packages, local files, and directories for hidden risk.
|
|
6
|
+
*
|
|
7
|
+
* Free tier: local scans only (no npm registry), max 5 scans/day.
|
|
8
|
+
* Pro tier: npm registry analysis, deep scanning, unlimited scans.
|
|
9
|
+
*/
|
|
10
|
+
export type { XRayTarget, XRayFinding, XRayCategory, XRayResult } from './types.js';
|
|
11
|
+
export { calculateTrustScore } from './trust-score.js';
|
|
12
|
+
export { detectPatterns, detectFilenameDirectives } from './patterns.js';
|
|
13
|
+
export { scanFile } from './file-scanner.js';
|
|
14
|
+
export { scanDirectory } from './dir-scanner.js';
|
|
15
|
+
export { inspectNpmPackage } from './npm-inspector.js';
|
|
16
|
+
export { formatXRayReport, formatXRayMarkdown } from './report.js';
|
|
17
|
+
/**
|
|
18
|
+
* Handle the `shieldcortex xray` CLI command.
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleXRayCommand(args: string[]): Promise<void>;
|