@cyberhub/shieldpm 0.1.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 +239 -0
- package/dist/analyzer/static.d.ts +35 -0
- package/dist/analyzer/static.d.ts.map +1 -0
- package/dist/analyzer/static.js +416 -0
- package/dist/analyzer/static.js.map +1 -0
- package/dist/analyzer/typosquat.d.ts +30 -0
- package/dist/analyzer/typosquat.d.ts.map +1 -0
- package/dist/analyzer/typosquat.js +211 -0
- package/dist/analyzer/typosquat.js.map +1 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +621 -0
- package/dist/cli.js.map +1 -0
- package/dist/diff/dependency.d.ts +51 -0
- package/dist/diff/dependency.d.ts.map +1 -0
- package/dist/diff/dependency.js +222 -0
- package/dist/diff/dependency.js.map +1 -0
- package/dist/fingerprint/profile.d.ts +68 -0
- package/dist/fingerprint/profile.d.ts.map +1 -0
- package/dist/fingerprint/profile.js +233 -0
- package/dist/fingerprint/profile.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/monitor/permissions.d.ts +45 -0
- package/dist/monitor/permissions.d.ts.map +1 -0
- package/dist/monitor/permissions.js +265 -0
- package/dist/monitor/permissions.js.map +1 -0
- package/dist/sandbox/runner.d.ts +46 -0
- package/dist/sandbox/runner.d.ts.map +1 -0
- package/dist/sandbox/runner.js +216 -0
- package/dist/sandbox/runner.js.map +1 -0
- package/dist/utils/colors.d.ts +31 -0
- package/dist/utils/colors.d.ts.map +1 -0
- package/dist/utils/colors.js +54 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/logger.d.ts +26 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +77 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +24 -0
- package/src/analyzer/static.ts +483 -0
- package/src/analyzer/typosquat.ts +272 -0
- package/src/cli.ts +700 -0
- package/src/diff/dependency.ts +297 -0
- package/src/fingerprint/profile.ts +333 -0
- package/src/index.ts +34 -0
- package/src/monitor/permissions.ts +330 -0
- package/src/sandbox/runner.ts +302 -0
- package/src/utils/colors.ts +58 -0
- package/src/utils/logger.ts +87 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShieldPM — Permission Manifest System
|
|
3
|
+
* Defines, loads, validates, and generates shieldpm.json permission manifests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { analyzePackage } from '../analyzer/static.js';
|
|
9
|
+
|
|
10
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface PackagePermissions {
|
|
13
|
+
/** Allowed network destinations (glob patterns), or false to block all */
|
|
14
|
+
net: string[] | false;
|
|
15
|
+
/** Allowed filesystem paths (relative or absolute), or false to block all */
|
|
16
|
+
fs: string[] | false;
|
|
17
|
+
/** Whether native/C++ addons are allowed */
|
|
18
|
+
native?: boolean;
|
|
19
|
+
/** Whether child_process spawning is allowed */
|
|
20
|
+
exec?: boolean;
|
|
21
|
+
/** Whether environment variable access is allowed */
|
|
22
|
+
env?: string[] | boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PermissionManifest {
|
|
26
|
+
/** Manifest format version */
|
|
27
|
+
version: 1;
|
|
28
|
+
/** Per-package permission declarations */
|
|
29
|
+
permissions: Record<string, PackagePermissions>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ResourceType = 'net' | 'fs' | 'native' | 'exec' | 'env';
|
|
33
|
+
|
|
34
|
+
export interface AccessCheck {
|
|
35
|
+
allowed: boolean;
|
|
36
|
+
rule: string;
|
|
37
|
+
details: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Default manifest path ────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const MANIFEST_FILENAME = 'shieldpm.json';
|
|
43
|
+
|
|
44
|
+
function resolveManifestPath(dir?: string): string {
|
|
45
|
+
return join(dir ?? process.cwd(), MANIFEST_FILENAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Load / Save ──────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Load the permission manifest from disk.
|
|
52
|
+
*/
|
|
53
|
+
export async function loadManifest(dir?: string): Promise<PermissionManifest | null> {
|
|
54
|
+
const path = resolveManifestPath(dir);
|
|
55
|
+
try {
|
|
56
|
+
const raw = await readFile(path, 'utf-8');
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
|
|
59
|
+
// Basic shape validation
|
|
60
|
+
if (!parsed.permissions || typeof parsed.permissions !== 'object') {
|
|
61
|
+
throw new Error('Invalid manifest: missing "permissions" object');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
version: parsed.version ?? 1,
|
|
66
|
+
permissions: parsed.permissions,
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
70
|
+
return null; // No manifest yet
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save a permission manifest to disk.
|
|
78
|
+
*/
|
|
79
|
+
export async function saveManifest(manifest: PermissionManifest, dir?: string): Promise<string> {
|
|
80
|
+
const path = resolveManifestPath(dir);
|
|
81
|
+
const json = JSON.stringify(manifest, null, 2) + '\n';
|
|
82
|
+
await writeFile(path, json, 'utf-8');
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Access validation ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check whether a package is allowed to access a resource.
|
|
90
|
+
*/
|
|
91
|
+
export function validateAccess(
|
|
92
|
+
manifest: PermissionManifest,
|
|
93
|
+
packageName: string,
|
|
94
|
+
resource: ResourceType,
|
|
95
|
+
target?: string
|
|
96
|
+
): AccessCheck {
|
|
97
|
+
const perms = manifest.permissions[packageName];
|
|
98
|
+
|
|
99
|
+
// No entry in manifest — default deny
|
|
100
|
+
if (!perms) {
|
|
101
|
+
return {
|
|
102
|
+
allowed: false,
|
|
103
|
+
rule: 'no-manifest-entry',
|
|
104
|
+
details: `Package "${packageName}" has no entry in the permission manifest`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (resource) {
|
|
109
|
+
case 'net': {
|
|
110
|
+
if (perms.net === false) {
|
|
111
|
+
return {
|
|
112
|
+
allowed: false,
|
|
113
|
+
rule: 'net-blocked',
|
|
114
|
+
details: `Network access is blocked for "${packageName}"`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!target) {
|
|
118
|
+
return {
|
|
119
|
+
allowed: Array.isArray(perms.net) && perms.net.length > 0,
|
|
120
|
+
rule: 'net-general',
|
|
121
|
+
details: Array.isArray(perms.net)
|
|
122
|
+
? `Network allowed to: ${perms.net.join(', ')}`
|
|
123
|
+
: 'Network access not configured',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Check target against allowed patterns
|
|
127
|
+
const allowed = matchesAnyPattern(target, perms.net);
|
|
128
|
+
return {
|
|
129
|
+
allowed,
|
|
130
|
+
rule: allowed ? 'net-allowed' : 'net-denied',
|
|
131
|
+
details: allowed
|
|
132
|
+
? `"${target}" matches allowed network pattern`
|
|
133
|
+
: `"${target}" does not match any allowed network pattern for "${packageName}"`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'fs': {
|
|
138
|
+
if (perms.fs === false) {
|
|
139
|
+
return {
|
|
140
|
+
allowed: false,
|
|
141
|
+
rule: 'fs-blocked',
|
|
142
|
+
details: `Filesystem access is blocked for "${packageName}"`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!target) {
|
|
146
|
+
return {
|
|
147
|
+
allowed: Array.isArray(perms.fs) && perms.fs.length > 0,
|
|
148
|
+
rule: 'fs-general',
|
|
149
|
+
details: Array.isArray(perms.fs)
|
|
150
|
+
? `FS allowed in: ${perms.fs.join(', ')}`
|
|
151
|
+
: 'FS access not configured',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const resolvedTarget = resolve(target);
|
|
155
|
+
const allowed = perms.fs.some((pattern) => {
|
|
156
|
+
const resolvedPattern = resolve(pattern);
|
|
157
|
+
return resolvedTarget.startsWith(resolvedPattern);
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
allowed,
|
|
161
|
+
rule: allowed ? 'fs-allowed' : 'fs-denied',
|
|
162
|
+
details: allowed
|
|
163
|
+
? `"${target}" is within allowed filesystem paths`
|
|
164
|
+
: `"${target}" is not within any allowed filesystem path for "${packageName}"`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'native': {
|
|
169
|
+
const allowed = perms.native === true;
|
|
170
|
+
return {
|
|
171
|
+
allowed,
|
|
172
|
+
rule: allowed ? 'native-allowed' : 'native-denied',
|
|
173
|
+
details: allowed
|
|
174
|
+
? `Native modules allowed for "${packageName}"`
|
|
175
|
+
: `Native modules blocked for "${packageName}"`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'exec': {
|
|
180
|
+
const allowed = perms.exec === true;
|
|
181
|
+
return {
|
|
182
|
+
allowed,
|
|
183
|
+
rule: allowed ? 'exec-allowed' : 'exec-denied',
|
|
184
|
+
details: allowed
|
|
185
|
+
? `Process execution allowed for "${packageName}"`
|
|
186
|
+
: `Process execution blocked for "${packageName}"`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case 'env': {
|
|
191
|
+
if (perms.env === false || perms.env === undefined) {
|
|
192
|
+
return {
|
|
193
|
+
allowed: false,
|
|
194
|
+
rule: 'env-blocked',
|
|
195
|
+
details: `Environment variable access blocked for "${packageName}"`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (perms.env === true) {
|
|
199
|
+
return {
|
|
200
|
+
allowed: true,
|
|
201
|
+
rule: 'env-allowed-all',
|
|
202
|
+
details: `All environment variables allowed for "${packageName}"`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (!target) {
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
rule: 'env-general',
|
|
209
|
+
details: `Env access allowed for: ${perms.env.join(', ')}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const allowed = perms.env.includes(target);
|
|
213
|
+
return {
|
|
214
|
+
allowed,
|
|
215
|
+
rule: allowed ? 'env-allowed' : 'env-denied',
|
|
216
|
+
details: allowed
|
|
217
|
+
? `Env var "${target}" is allowed for "${packageName}"`
|
|
218
|
+
: `Env var "${target}" is not allowed for "${packageName}"`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
default:
|
|
223
|
+
return {
|
|
224
|
+
allowed: false,
|
|
225
|
+
rule: 'unknown-resource',
|
|
226
|
+
details: `Unknown resource type: ${resource}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Pattern matching ─────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Match a string against an array of glob-like patterns.
|
|
235
|
+
* Supports: * (any), *.domain.com, exact match.
|
|
236
|
+
*/
|
|
237
|
+
function matchesAnyPattern(value: string, patterns: string[]): boolean {
|
|
238
|
+
for (const pattern of patterns) {
|
|
239
|
+
if (pattern === '*') return true;
|
|
240
|
+
|
|
241
|
+
// Convert glob pattern to regex
|
|
242
|
+
const regexStr = pattern
|
|
243
|
+
.replace(/\./g, '\\.')
|
|
244
|
+
.replace(/\*/g, '.*');
|
|
245
|
+
const regex = new RegExp(`^${regexStr}$`, 'i');
|
|
246
|
+
|
|
247
|
+
if (regex.test(value)) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Manifest generation ──────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Auto-generate a permission manifest by scanning installed packages.
|
|
256
|
+
*/
|
|
257
|
+
export async function generateManifest(projectDir: string): Promise<PermissionManifest> {
|
|
258
|
+
const manifest: PermissionManifest = {
|
|
259
|
+
version: 1,
|
|
260
|
+
permissions: {},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const nodeModules = join(projectDir, 'node_modules');
|
|
264
|
+
let entries: string[];
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
entries = await readdir(nodeModules);
|
|
268
|
+
} catch {
|
|
269
|
+
return manifest; // No node_modules
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Collect package directories (including scoped packages)
|
|
273
|
+
const packageDirs: { name: string; dir: string }[] = [];
|
|
274
|
+
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
if (entry.startsWith('.')) continue;
|
|
277
|
+
|
|
278
|
+
const fullPath = join(nodeModules, entry);
|
|
279
|
+
const entryStat = await stat(fullPath).catch(() => null);
|
|
280
|
+
if (!entryStat?.isDirectory()) continue;
|
|
281
|
+
|
|
282
|
+
if (entry.startsWith('@')) {
|
|
283
|
+
// Scoped package — look one level deeper
|
|
284
|
+
const scopedEntries = await readdir(fullPath).catch(() => [] as string[]);
|
|
285
|
+
for (const scopedEntry of scopedEntries) {
|
|
286
|
+
const scopedPath = join(fullPath, scopedEntry);
|
|
287
|
+
const scopedStat = await stat(scopedPath).catch(() => null);
|
|
288
|
+
if (scopedStat?.isDirectory()) {
|
|
289
|
+
packageDirs.push({ name: `${entry}/${scopedEntry}`, dir: scopedPath });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
packageDirs.push({ name: entry, dir: fullPath });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Analyze each package and build permissions
|
|
298
|
+
for (const { name, dir } of packageDirs) {
|
|
299
|
+
const report = await analyzePackage(dir);
|
|
300
|
+
|
|
301
|
+
const perms: PackagePermissions = {
|
|
302
|
+
net: false,
|
|
303
|
+
fs: false,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// If the package uses network, allow it (but default to restrictive)
|
|
307
|
+
if (report.categoryCounts['network']) {
|
|
308
|
+
perms.net = []; // User must fill in allowed destinations
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// If the package uses filesystem
|
|
312
|
+
if (report.categoryCounts['filesystem']) {
|
|
313
|
+
perms.fs = []; // User must fill in allowed paths
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// If the package uses child_process
|
|
317
|
+
if (report.categoryCounts['process']) {
|
|
318
|
+
perms.exec = false; // Default deny, user opts in
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// If the package accesses env
|
|
322
|
+
if (report.categoryCounts['environment']) {
|
|
323
|
+
perms.env = []; // User must fill in allowed vars
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
manifest.permissions[name] = perms;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return manifest;
|
|
330
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShieldPM — Sandbox Runner
|
|
3
|
+
* Executes commands (especially postinstall scripts) in a restricted environment
|
|
4
|
+
* with network blocking, timeout enforcement, and output capture.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
8
|
+
import { platform } from 'node:os';
|
|
9
|
+
|
|
10
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface SandboxOptions {
|
|
13
|
+
/** Working directory for the command */
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/** Timeout in milliseconds (default: 30000) */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
/** Block network access (default: true) */
|
|
18
|
+
blockNetwork?: boolean;
|
|
19
|
+
/** Block environment variables (default: true) */
|
|
20
|
+
blockEnv?: boolean;
|
|
21
|
+
/** Allowed environment variable names to pass through */
|
|
22
|
+
allowedEnvVars?: string[];
|
|
23
|
+
/** Maximum stdout/stderr size in bytes (default: 1MB) */
|
|
24
|
+
maxOutputSize?: number;
|
|
25
|
+
/** Enable verbose logging of sandbox decisions */
|
|
26
|
+
verbose?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SandboxResult {
|
|
30
|
+
/** Process exit code (null if killed) */
|
|
31
|
+
exitCode: number | null;
|
|
32
|
+
/** Captured stdout */
|
|
33
|
+
stdout: string;
|
|
34
|
+
/** Captured stderr */
|
|
35
|
+
stderr: string;
|
|
36
|
+
/** Warnings generated during execution */
|
|
37
|
+
warnings: string[];
|
|
38
|
+
/** Actions that were blocked */
|
|
39
|
+
blocked: string[];
|
|
40
|
+
/** Whether the process was killed due to timeout */
|
|
41
|
+
timedOut: boolean;
|
|
42
|
+
/** Duration in milliseconds */
|
|
43
|
+
durationMs: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Safe environment builder ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const SAFE_ENV_VARS = new Set([
|
|
49
|
+
'PATH',
|
|
50
|
+
'HOME',
|
|
51
|
+
'USER',
|
|
52
|
+
'SHELL',
|
|
53
|
+
'LANG',
|
|
54
|
+
'LC_ALL',
|
|
55
|
+
'TERM',
|
|
56
|
+
'TMPDIR',
|
|
57
|
+
'TMP',
|
|
58
|
+
'TEMP',
|
|
59
|
+
'NODE_ENV',
|
|
60
|
+
'NODE_PATH',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const SENSITIVE_ENV_VARS = new Set([
|
|
64
|
+
'AWS_ACCESS_KEY_ID',
|
|
65
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
66
|
+
'AWS_SESSION_TOKEN',
|
|
67
|
+
'GITHUB_TOKEN',
|
|
68
|
+
'GH_TOKEN',
|
|
69
|
+
'NPM_TOKEN',
|
|
70
|
+
'NPM_AUTH_TOKEN',
|
|
71
|
+
'DOCKER_PASSWORD',
|
|
72
|
+
'SSH_AUTH_SOCK',
|
|
73
|
+
'GPG_TTY',
|
|
74
|
+
'DATABASE_URL',
|
|
75
|
+
'REDIS_URL',
|
|
76
|
+
'API_KEY',
|
|
77
|
+
'SECRET_KEY',
|
|
78
|
+
'PRIVATE_KEY',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function buildSandboxEnv(
|
|
82
|
+
blockNetwork: boolean,
|
|
83
|
+
blockEnv: boolean,
|
|
84
|
+
allowedEnvVars: string[]
|
|
85
|
+
): Record<string, string> {
|
|
86
|
+
const env: Record<string, string> = {};
|
|
87
|
+
|
|
88
|
+
if (blockEnv) {
|
|
89
|
+
// Only pass through safe variables
|
|
90
|
+
const allowed = new Set([...SAFE_ENV_VARS, ...allowedEnvVars]);
|
|
91
|
+
for (const key of allowed) {
|
|
92
|
+
if (process.env[key] !== undefined) {
|
|
93
|
+
env[key] = process.env[key]!;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
// Pass through everything except sensitive vars
|
|
98
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
99
|
+
if (!SENSITIVE_ENV_VARS.has(key) && value !== undefined) {
|
|
100
|
+
env[key] = value;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Also pass explicitly allowed
|
|
104
|
+
for (const key of allowedEnvVars) {
|
|
105
|
+
if (process.env[key] !== undefined) {
|
|
106
|
+
env[key] = process.env[key]!;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Block network via proxy settings
|
|
112
|
+
if (blockNetwork) {
|
|
113
|
+
env['HTTP_PROXY'] = 'http://blocked.shieldpm.local:0';
|
|
114
|
+
env['HTTPS_PROXY'] = 'http://blocked.shieldpm.local:0';
|
|
115
|
+
env['http_proxy'] = 'http://blocked.shieldpm.local:0';
|
|
116
|
+
env['https_proxy'] = 'http://blocked.shieldpm.local:0';
|
|
117
|
+
env['no_proxy'] = '';
|
|
118
|
+
env['NODE_OPTIONS'] = [
|
|
119
|
+
env['NODE_OPTIONS'] ?? '',
|
|
120
|
+
'--dns-result-order=verbatim',
|
|
121
|
+
].filter(Boolean).join(' ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Prevent spawning of sub-shells from modifying real config
|
|
125
|
+
env['npm_config_ignore_scripts'] = 'true';
|
|
126
|
+
|
|
127
|
+
return env;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Platform-specific restrictions ───────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function buildPlatformArgs(): string[] {
|
|
133
|
+
const args: string[] = [];
|
|
134
|
+
|
|
135
|
+
if (platform() === 'linux') {
|
|
136
|
+
// On Linux, we could use unshare for network namespace isolation
|
|
137
|
+
// For now, we rely on proxy blocking; future: seccomp/landlock
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return args;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Output truncation ────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
function truncateOutput(output: string, maxSize: number): string {
|
|
146
|
+
if (Buffer.byteLength(output) <= maxSize) return output;
|
|
147
|
+
|
|
148
|
+
const truncated = output.slice(0, maxSize);
|
|
149
|
+
return truncated + `\n... [output truncated at ${Math.round(maxSize / 1024)}KB]`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Main runner ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run a command inside a restricted sandbox environment.
|
|
156
|
+
*/
|
|
157
|
+
export async function runSandboxed(
|
|
158
|
+
command: string,
|
|
159
|
+
args: string[] = [],
|
|
160
|
+
options: SandboxOptions = {}
|
|
161
|
+
): Promise<SandboxResult> {
|
|
162
|
+
const {
|
|
163
|
+
cwd = process.cwd(),
|
|
164
|
+
timeout = 30_000,
|
|
165
|
+
blockNetwork = true,
|
|
166
|
+
blockEnv = true,
|
|
167
|
+
allowedEnvVars = [],
|
|
168
|
+
maxOutputSize = 1024 * 1024, // 1MB
|
|
169
|
+
verbose = false,
|
|
170
|
+
} = options;
|
|
171
|
+
|
|
172
|
+
const warnings: string[] = [];
|
|
173
|
+
const blocked: string[] = [];
|
|
174
|
+
|
|
175
|
+
// Build restricted environment
|
|
176
|
+
const env = buildSandboxEnv(blockNetwork, blockEnv, allowedEnvVars);
|
|
177
|
+
|
|
178
|
+
if (blockNetwork) {
|
|
179
|
+
blocked.push('network: HTTP/HTTPS proxied to blocked endpoint');
|
|
180
|
+
}
|
|
181
|
+
if (blockEnv) {
|
|
182
|
+
const removedCount = Object.keys(process.env).length - Object.keys(env).length;
|
|
183
|
+
blocked.push(`environment: ${removedCount} env vars stripped`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const startTime = Date.now();
|
|
187
|
+
let timedOut = false;
|
|
188
|
+
|
|
189
|
+
return new Promise<SandboxResult>((resolve) => {
|
|
190
|
+
let child: ChildProcess;
|
|
191
|
+
let stdoutChunks: Buffer[] = [];
|
|
192
|
+
let stderrChunks: Buffer[] = [];
|
|
193
|
+
let stdoutSize = 0;
|
|
194
|
+
let stderrSize = 0;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
child = spawn(command, args, {
|
|
198
|
+
cwd,
|
|
199
|
+
env,
|
|
200
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
201
|
+
shell: true,
|
|
202
|
+
// Kill the entire process group on timeout
|
|
203
|
+
detached: false,
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
resolve({
|
|
207
|
+
exitCode: 1,
|
|
208
|
+
stdout: '',
|
|
209
|
+
stderr: `Failed to spawn process: ${err instanceof Error ? err.message : String(err)}`,
|
|
210
|
+
warnings,
|
|
211
|
+
blocked,
|
|
212
|
+
timedOut: false,
|
|
213
|
+
durationMs: Date.now() - startTime,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Capture stdout
|
|
219
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
220
|
+
if (stdoutSize < maxOutputSize) {
|
|
221
|
+
stdoutChunks.push(chunk);
|
|
222
|
+
stdoutSize += chunk.length;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Capture stderr
|
|
227
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
228
|
+
if (stderrSize < maxOutputSize) {
|
|
229
|
+
stderrChunks.push(chunk);
|
|
230
|
+
stderrSize += chunk.length;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Watch for suspicious patterns in stderr
|
|
234
|
+
const text = chunk.toString();
|
|
235
|
+
if (/ECONNREFUSED|ENOTFOUND|blocked\.shieldpm/.test(text)) {
|
|
236
|
+
warnings.push('Process attempted network access (blocked by sandbox)');
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Timeout
|
|
241
|
+
const timer = setTimeout(() => {
|
|
242
|
+
timedOut = true;
|
|
243
|
+
warnings.push(`Process killed: exceeded ${timeout}ms timeout`);
|
|
244
|
+
child.kill('SIGKILL');
|
|
245
|
+
}, timeout);
|
|
246
|
+
|
|
247
|
+
// Completion
|
|
248
|
+
child.on('close', (code) => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
|
|
251
|
+
const stdout = truncateOutput(Buffer.concat(stdoutChunks).toString('utf-8'), maxOutputSize);
|
|
252
|
+
const stderr = truncateOutput(Buffer.concat(stderrChunks).toString('utf-8'), maxOutputSize);
|
|
253
|
+
|
|
254
|
+
if (code !== 0 && !timedOut) {
|
|
255
|
+
warnings.push(`Process exited with code ${code}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
resolve({
|
|
259
|
+
exitCode: code,
|
|
260
|
+
stdout,
|
|
261
|
+
stderr,
|
|
262
|
+
warnings,
|
|
263
|
+
blocked,
|
|
264
|
+
timedOut,
|
|
265
|
+
durationMs: Date.now() - startTime,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
child.on('error', (err) => {
|
|
270
|
+
clearTimeout(timer);
|
|
271
|
+
resolve({
|
|
272
|
+
exitCode: 1,
|
|
273
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
274
|
+
stderr: err.message,
|
|
275
|
+
warnings: [...warnings, `Spawn error: ${err.message}`],
|
|
276
|
+
blocked,
|
|
277
|
+
timedOut: false,
|
|
278
|
+
durationMs: Date.now() - startTime,
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Close stdin immediately
|
|
283
|
+
child.stdin?.end();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Run an npm postinstall script in the sandbox.
|
|
289
|
+
*/
|
|
290
|
+
export async function runPostInstall(
|
|
291
|
+
packageDir: string,
|
|
292
|
+
script: string,
|
|
293
|
+
options: SandboxOptions = {}
|
|
294
|
+
): Promise<SandboxResult> {
|
|
295
|
+
return runSandboxed('sh', ['-c', script], {
|
|
296
|
+
cwd: packageDir,
|
|
297
|
+
timeout: 30_000,
|
|
298
|
+
blockNetwork: true,
|
|
299
|
+
blockEnv: true,
|
|
300
|
+
...options,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShieldPM — Terminal color helpers (zero dependencies)
|
|
3
|
+
* Simple ANSI escape code wrapper, no chalk needed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const isColorSupported = (): boolean => {
|
|
7
|
+
if (process.env.NO_COLOR !== undefined) return false;
|
|
8
|
+
if (process.env.FORCE_COLOR !== undefined) return true;
|
|
9
|
+
if (!process.stdout.isTTY) return false;
|
|
10
|
+
return true;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const enabled = isColorSupported();
|
|
14
|
+
|
|
15
|
+
const wrap = (open: string, close: string) => {
|
|
16
|
+
return (text: string): string => {
|
|
17
|
+
if (!enabled) return text;
|
|
18
|
+
return `\x1b[${open}m${text}\x1b[${close}m`;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Foreground colors
|
|
23
|
+
export const red = wrap('31', '39');
|
|
24
|
+
export const green = wrap('32', '39');
|
|
25
|
+
export const yellow = wrap('33', '39');
|
|
26
|
+
export const blue = wrap('34', '39');
|
|
27
|
+
export const magenta = wrap('35', '39');
|
|
28
|
+
export const cyan = wrap('36', '39');
|
|
29
|
+
export const white = wrap('37', '39');
|
|
30
|
+
export const gray = wrap('90', '39');
|
|
31
|
+
|
|
32
|
+
// Styles
|
|
33
|
+
export const bold = wrap('1', '22');
|
|
34
|
+
export const dim = wrap('2', '22');
|
|
35
|
+
export const italic = wrap('3', '23');
|
|
36
|
+
export const underline = wrap('4', '24');
|
|
37
|
+
|
|
38
|
+
// Reset
|
|
39
|
+
export const reset = wrap('0', '0');
|
|
40
|
+
|
|
41
|
+
// Bright variants
|
|
42
|
+
export const redBright = wrap('91', '39');
|
|
43
|
+
export const greenBright = wrap('92', '39');
|
|
44
|
+
export const yellowBright = wrap('93', '39');
|
|
45
|
+
export const blueBright = wrap('94', '39');
|
|
46
|
+
export const cyanBright = wrap('96', '39');
|
|
47
|
+
|
|
48
|
+
// Background colors
|
|
49
|
+
export const bgRed = wrap('41', '49');
|
|
50
|
+
export const bgGreen = wrap('42', '49');
|
|
51
|
+
export const bgYellow = wrap('43', '49');
|
|
52
|
+
|
|
53
|
+
// Composable: bold + color
|
|
54
|
+
export const boldRed = (t: string) => bold(red(t));
|
|
55
|
+
export const boldGreen = (t: string) => bold(green(t));
|
|
56
|
+
export const boldYellow = (t: string) => bold(yellow(t));
|
|
57
|
+
export const boldCyan = (t: string) => bold(cyan(t));
|
|
58
|
+
export const boldBlue = (t: string) => bold(blue(t));
|