pkg-gate 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 +113 -0
- package/bin/cli.js +139 -0
- package/package.json +42 -0
- package/src/gate.js +256 -0
- package/src/index.d.ts +116 -0
- package/src/index.js +54 -0
- package/src/questions.js +66 -0
- package/src/resolver.js +133 -0
- package/src/simulator.js +119 -0
- package/src/tui.js +501 -0
- package/src/utils.js +79 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { resolveManifest } from './resolver.js';
|
|
2
|
+
import { evaluatePackage } from './gate.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pre-install security gate for npm lifecycle scripts using TypeSafe System One.
|
|
6
|
+
*
|
|
7
|
+
* @param {string | object} input - Package name ('esbuild'), path ('./package.json'), or manifest object
|
|
8
|
+
* @param {object} [options] - Evaluation options
|
|
9
|
+
* @param {string} [options.apiKey] - TypeSafe API key (falls back to process.env.TYPESAFE_API_KEY)
|
|
10
|
+
* @param {boolean} [options.mock] - Force local offline simulation
|
|
11
|
+
* @param {boolean} [options.script] - Treat string input as a raw script command
|
|
12
|
+
* @returns {Promise<import('./gate.js').GateReport>}
|
|
13
|
+
*/
|
|
14
|
+
function looksLikeShellCommand(input) {
|
|
15
|
+
if (typeof input !== 'string') return false;
|
|
16
|
+
const trimmed = input.trim();
|
|
17
|
+
if (trimmed.startsWith('.') || trimmed.startsWith('/') || trimmed.endsWith('.json')) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return (
|
|
21
|
+
/(\||\&\&|;|\|\||`|\$\(|\b(curl|wget|bash|sh|node -e|eval|exec|sudo|chmod|rm -rf)\b)/.test(trimmed) ||
|
|
22
|
+
(trimmed.includes(' ') && !trimmed.startsWith('@'))
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function pkgGate(input, options = {}) {
|
|
27
|
+
const isScript = Boolean(options.script || looksLikeShellCommand(input));
|
|
28
|
+
|
|
29
|
+
if (isScript && typeof input === 'string') {
|
|
30
|
+
return evaluatePackage(
|
|
31
|
+
{
|
|
32
|
+
name: 'inline-script',
|
|
33
|
+
version: '0.0.0',
|
|
34
|
+
description: 'Single inline lifecycle script',
|
|
35
|
+
scripts: { postinstall: input },
|
|
36
|
+
},
|
|
37
|
+
options
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const manifest = await resolveManifest(input);
|
|
42
|
+
return evaluatePackage(manifest, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Backward compatibility alias
|
|
46
|
+
export const pkgGuard = pkgGate;
|
|
47
|
+
|
|
48
|
+
export { evaluatePackage } from './gate.js';
|
|
49
|
+
export { resolveManifest } from './resolver.js';
|
|
50
|
+
export { THRESHOLDS, createLifecycleQuestions } from './questions.js';
|
|
51
|
+
export { simulateLifecycleEvaluation } from './simulator.js';
|
|
52
|
+
export { formatReport } from './utils.js';
|
|
53
|
+
export { renderTUI, drawBox, getTerminalWidth } from './tui.js';
|
|
54
|
+
|
package/src/questions.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { choice, noul, score } from '@typesafe-ai/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic decision thresholds.
|
|
5
|
+
* Centralized here for easy review and calibration.
|
|
6
|
+
*/
|
|
7
|
+
export const THRESHOLDS = {
|
|
8
|
+
// Score >= 2.0 triggers automatic block
|
|
9
|
+
BLOCK_SCORE: 2.0,
|
|
10
|
+
// Score >= 1.2 triggers warning / confirmation
|
|
11
|
+
WARN_SCORE: 1.2,
|
|
12
|
+
// Secret harvesting probability >= 0.6 triggers block
|
|
13
|
+
BLOCK_SECRET_NOUL: 0.6,
|
|
14
|
+
// Secret harvesting probability >= 0.3 triggers warning
|
|
15
|
+
WARN_SECRET_NOUL: 0.3,
|
|
16
|
+
// Arbitrary remote code download/exec >= 0.6 triggers block
|
|
17
|
+
BLOCK_REMOTE_EXEC_NOUL: 0.6,
|
|
18
|
+
// Arbitrary remote code download/exec >= 0.3 triggers warning
|
|
19
|
+
WARN_REMOTE_EXEC_NOUL: 0.3,
|
|
20
|
+
// Confidence floor: decisions with lower confidence route to human review
|
|
21
|
+
CONFIDENCE_FLOOR: 0.5,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The battery of TypeSafe System One questions asked for each lifecycle script.
|
|
26
|
+
* Evaluated in parallel within a single System One call.
|
|
27
|
+
*/
|
|
28
|
+
export function createLifecycleQuestions() {
|
|
29
|
+
return {
|
|
30
|
+
script_intent: choice(
|
|
31
|
+
'What is the primary intent and runtime behavior of `script_content` executed in lifecycle hook `lifecycle_hook`?',
|
|
32
|
+
{
|
|
33
|
+
build_tool: 'Standard compilation or native build (e.g. node-gyp, esbuild, tsc, cargo)',
|
|
34
|
+
binary_download: 'Downloads official pre-compiled native binaries or assets from a known repository or CDN',
|
|
35
|
+
benign_notice: 'Informational message, donation banner, or benign no-op command',
|
|
36
|
+
system_recon: 'Gathers host system details, environment variables, network interfaces, or user directories',
|
|
37
|
+
credential_access: 'Reads or extracts secrets, authentication tokens (NPM_TOKEN, AWS, SSH keys), or credential files',
|
|
38
|
+
obfuscated_exec: 'Executes obfuscated code, base64 payloads, or pipes curl/wget to bash/sh/node',
|
|
39
|
+
other: 'General package maintenance or utility task',
|
|
40
|
+
}
|
|
41
|
+
),
|
|
42
|
+
threat_severity: score(
|
|
43
|
+
'How severe is the security risk of executing `script_content` during npm install of `package.name`?',
|
|
44
|
+
[
|
|
45
|
+
'Safe: standard build or benign command with no external network or secret access',
|
|
46
|
+
'Low risk: fetches known binary from release domain or routine package setup',
|
|
47
|
+
'Suspicious: makes non-standard outbound connections, inspects system environment, or runs encoded commands',
|
|
48
|
+
'Critical threat: credential harvesting, token exfiltration, backdoor dropper, or destructive action',
|
|
49
|
+
]
|
|
50
|
+
),
|
|
51
|
+
accesses_secrets: noul(
|
|
52
|
+
'Does `script_content` attempt to access, extract, or transmit environment variables, private credentials, or authentication tokens?',
|
|
53
|
+
{
|
|
54
|
+
true: 'It accesses or exfiltrates environment secrets, auth tokens, or private credentials',
|
|
55
|
+
false: 'It does not attempt to access credentials or environment secrets',
|
|
56
|
+
}
|
|
57
|
+
),
|
|
58
|
+
remote_execution: noul(
|
|
59
|
+
'Does `script_content` download and execute arbitrary unverified external code or pipe remote scripts directly into a shell?',
|
|
60
|
+
{
|
|
61
|
+
true: 'It downloads and executes unverified remote code or uses curl/wget piping to a shell',
|
|
62
|
+
false: 'It only runs local tools or verified fixed installers',
|
|
63
|
+
}
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves an input (package name, file path, directory, or object) into a normalized manifest.
|
|
6
|
+
*
|
|
7
|
+
* @param {string | object} input
|
|
8
|
+
* @returns {Promise<{ name: string, version: string, scripts: Record<string, string>, raw: object }>}
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveManifest(input) {
|
|
11
|
+
if (!input) {
|
|
12
|
+
throw new Error('No package or manifest provided to pkg-gate');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Case 1: Plain manifest object
|
|
16
|
+
if (typeof input === 'object' && input !== null) {
|
|
17
|
+
return normalizeManifest(input);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof input !== 'string') {
|
|
21
|
+
throw new TypeError(`Expected package name or path string, got ${typeof input}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const trimmed = input.trim();
|
|
25
|
+
|
|
26
|
+
// Case 2: Local file path or directory
|
|
27
|
+
if (trimmed.startsWith('.') || trimmed.startsWith('/') || trimmed.endsWith('.json')) {
|
|
28
|
+
return loadLocalManifest(trimmed);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Case 3: Check if local directory/file exists with that name before querying registry
|
|
32
|
+
try {
|
|
33
|
+
const localPath = resolve(process.cwd(), trimmed);
|
|
34
|
+
const stats = await stat(localPath);
|
|
35
|
+
if (stats.isDirectory() || stats.isFile()) {
|
|
36
|
+
return loadLocalManifest(localPath);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Not a local path, continue to npm registry
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Case 4: Fetch from npm registry
|
|
43
|
+
return fetchRegistryManifest(trimmed);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadLocalManifest(filePath) {
|
|
47
|
+
let target = resolve(process.cwd(), filePath);
|
|
48
|
+
let stats;
|
|
49
|
+
try {
|
|
50
|
+
stats = await stat(target);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.code === 'ENOENT') {
|
|
53
|
+
throw new Error(`File or directory not found: "${filePath}". Please pass a valid package.json path or npm package name.`);
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (stats.isDirectory()) {
|
|
59
|
+
target = join(target, 'package.json');
|
|
60
|
+
try {
|
|
61
|
+
await stat(target);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err.code === 'ENOENT') {
|
|
64
|
+
throw new Error(`Directory "${filePath}" does not contain a package.json file.`);
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const content = await readFile(target, 'utf8');
|
|
72
|
+
const json = JSON.parse(content);
|
|
73
|
+
return normalizeManifest(json);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof SyntaxError) {
|
|
76
|
+
throw new Error(`Invalid JSON syntax in "${target}": ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async function fetchRegistryManifest(packageName) {
|
|
84
|
+
// Support scoped packages like @foo/bar or specific versions like foo@1.2.3
|
|
85
|
+
let name = packageName;
|
|
86
|
+
let tagOrVersion = 'latest';
|
|
87
|
+
|
|
88
|
+
if (packageName.startsWith('@')) {
|
|
89
|
+
const parts = packageName.split('@');
|
|
90
|
+
if (parts.length === 3) {
|
|
91
|
+
name = `@${parts[1]}`;
|
|
92
|
+
tagOrVersion = parts[2];
|
|
93
|
+
}
|
|
94
|
+
} else if (packageName.includes('@')) {
|
|
95
|
+
const parts = packageName.split('@');
|
|
96
|
+
name = parts[0];
|
|
97
|
+
tagOrVersion = parts[1];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const encodedName = name.startsWith('@')
|
|
101
|
+
? `@${encodeURIComponent(name.slice(1))}`
|
|
102
|
+
: encodeURIComponent(name);
|
|
103
|
+
|
|
104
|
+
const url = `https://registry.npmjs.org/${encodedName}/${tagOrVersion}`;
|
|
105
|
+
const res = await fetch(url, {
|
|
106
|
+
headers: {
|
|
107
|
+
Accept: 'application/json',
|
|
108
|
+
'User-Agent': 'pkg-gate/0.1.0',
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
if (res.status === 404) {
|
|
114
|
+
throw new Error(`Package "${packageName}" not found on npm registry`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Failed to fetch "${packageName}" from npm: ${res.status} ${res.statusText}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const json = await res.json();
|
|
120
|
+
return normalizeManifest(json);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeManifest(manifest) {
|
|
124
|
+
return {
|
|
125
|
+
name: manifest.name || 'unnamed-package',
|
|
126
|
+
version: manifest.version || '0.0.0',
|
|
127
|
+
description: manifest.description || '',
|
|
128
|
+
scripts: manifest.scripts || {},
|
|
129
|
+
dependencies: manifest.dependencies || {},
|
|
130
|
+
devDependencies: manifest.devDependencies || {},
|
|
131
|
+
raw: manifest,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/simulator.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline evaluation engine.
|
|
3
|
+
* Emits calibrated TypeSafe System One schema answers when running offline or without an API key.
|
|
4
|
+
*/
|
|
5
|
+
export function simulateLifecycleEvaluation(hookName, scriptContent, pkg) {
|
|
6
|
+
const content = String(scriptContent || '').toLowerCase();
|
|
7
|
+
const rawContent = String(scriptContent || '');
|
|
8
|
+
|
|
9
|
+
// High-risk indicators: exfiltration, token theft, reverse shells, obfuscated curl piping
|
|
10
|
+
const stealsSecrets =
|
|
11
|
+
/(?:npm_token|aws_access_key|id_rsa|\.ssh|\.npmrc|\.aws|process\.env|env\s*\|\s*curl)/i.test(rawContent);
|
|
12
|
+
|
|
13
|
+
const pipesRemote =
|
|
14
|
+
/(?:curl|wget)\s+.*\|\s*(?:bash|sh|node)|eval\s*\(\s*buffer\.from|eval\s*\(\s*atob/i.test(rawContent);
|
|
15
|
+
|
|
16
|
+
const isObfuscated =
|
|
17
|
+
/(?:\\x[0-9a-f]{2}){4,}|(?:[A-Za-z0-9+/]{40,}={0,2})/i.test(rawContent);
|
|
18
|
+
|
|
19
|
+
const isRecon =
|
|
20
|
+
/(?:uname\s+-a|whoami|cat\s+\/etc\/passwd|ipconfig|ifconfig|hostname)/i.test(rawContent);
|
|
21
|
+
|
|
22
|
+
const isNativeBuild =
|
|
23
|
+
/(?:node-gyp\s+rebuild|cmake-js|prebuild-install|cargo\s+build|tsc\b)/i.test(content);
|
|
24
|
+
|
|
25
|
+
const isBinaryDownload =
|
|
26
|
+
/(?:download.*binary|node\s+install\.js|release\/download|github\.com\/.*\/releases)/i.test(content);
|
|
27
|
+
|
|
28
|
+
const isNotice =
|
|
29
|
+
/(?:opencollective|fund|echo\s+["'].*thank|exit\s+0)/i.test(content);
|
|
30
|
+
|
|
31
|
+
// Derive calibrated probabilities
|
|
32
|
+
let intent = 'other';
|
|
33
|
+
let severityScore = 0.2;
|
|
34
|
+
let secretProb = 0.05;
|
|
35
|
+
let remoteExecProb = 0.05;
|
|
36
|
+
let confidence = 0.92;
|
|
37
|
+
|
|
38
|
+
if (stealsSecrets || (pipesRemote && isObfuscated)) {
|
|
39
|
+
intent = stealsSecrets ? 'credential_access' : 'obfuscated_exec';
|
|
40
|
+
severityScore = 2.85;
|
|
41
|
+
secretProb = stealsSecrets ? 0.96 : 0.75;
|
|
42
|
+
remoteExecProb = pipesRemote ? 0.94 : 0.65;
|
|
43
|
+
confidence = 0.89;
|
|
44
|
+
} else if (pipesRemote) {
|
|
45
|
+
intent = 'obfuscated_exec';
|
|
46
|
+
severityScore = 2.45;
|
|
47
|
+
secretProb = 0.40;
|
|
48
|
+
remoteExecProb = 0.92;
|
|
49
|
+
confidence = 0.85;
|
|
50
|
+
} else if (isRecon) {
|
|
51
|
+
intent = 'system_recon';
|
|
52
|
+
severityScore = 1.65;
|
|
53
|
+
secretProb = 0.55;
|
|
54
|
+
remoteExecProb = 0.25;
|
|
55
|
+
confidence = 0.78;
|
|
56
|
+
} else if (isBinaryDownload) {
|
|
57
|
+
intent = 'binary_download';
|
|
58
|
+
severityScore = 0.85;
|
|
59
|
+
secretProb = 0.08;
|
|
60
|
+
remoteExecProb = 0.15;
|
|
61
|
+
confidence = 0.91;
|
|
62
|
+
} else if (isNativeBuild) {
|
|
63
|
+
intent = 'build_tool';
|
|
64
|
+
severityScore = 0.15;
|
|
65
|
+
secretProb = 0.02;
|
|
66
|
+
remoteExecProb = 0.03;
|
|
67
|
+
confidence = 0.98;
|
|
68
|
+
} else if (isNotice) {
|
|
69
|
+
intent = 'benign_notice';
|
|
70
|
+
severityScore = 0.05;
|
|
71
|
+
secretProb = 0.01;
|
|
72
|
+
remoteExecProb = 0.01;
|
|
73
|
+
confidence = 0.99;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
model: 'jev-latest (offline simulator)',
|
|
78
|
+
answers: {
|
|
79
|
+
script_intent: {
|
|
80
|
+
type: 'choice',
|
|
81
|
+
choice: intent,
|
|
82
|
+
probabilities: {
|
|
83
|
+
[intent]: 0.85,
|
|
84
|
+
other: 0.15,
|
|
85
|
+
},
|
|
86
|
+
confidence,
|
|
87
|
+
},
|
|
88
|
+
threat_severity: {
|
|
89
|
+
type: 'score',
|
|
90
|
+
score: severityScore,
|
|
91
|
+
legend: {
|
|
92
|
+
'0': 'Safe: standard build or benign command with no external network or secret access',
|
|
93
|
+
'1': 'Low risk: fetches known binary from release domain or routine package setup',
|
|
94
|
+
'2': 'Suspicious: makes non-standard outbound connections, inspects system environment, or runs encoded commands',
|
|
95
|
+
'3': 'Critical threat: credential harvesting, token exfiltration, backdoor dropper, or destructive action',
|
|
96
|
+
},
|
|
97
|
+
probabilities: {
|
|
98
|
+
'0': severityScore < 1.0 ? 0.8 : 0.05,
|
|
99
|
+
'1': severityScore >= 0.5 && severityScore < 1.5 ? 0.7 : 0.1,
|
|
100
|
+
'2': severityScore >= 1.5 && severityScore < 2.5 ? 0.75 : 0.1,
|
|
101
|
+
'3': severityScore >= 2.0 ? 0.85 : 0.05,
|
|
102
|
+
},
|
|
103
|
+
confidence,
|
|
104
|
+
},
|
|
105
|
+
accesses_secrets: {
|
|
106
|
+
type: 'noul',
|
|
107
|
+
noul: secretProb,
|
|
108
|
+
},
|
|
109
|
+
remote_execution: {
|
|
110
|
+
type: 'noul',
|
|
111
|
+
noul: remoteExecProb,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
usage: {
|
|
115
|
+
input_tokens: 180,
|
|
116
|
+
output_tokens: 38,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|