@h0tp/shucky 0.1.0 → 0.4.4
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/CHANGELOG.md +131 -29
- package/LICENSE +21 -21
- package/NOTICE +24 -0
- package/README.md +214 -119
- package/SKILL.md +168 -124
- package/bin/shucky.js +13 -13
- package/config.json +28 -28
- package/lib/agents.js +163 -0
- package/lib/approvals.js +50 -50
- package/lib/archive.js +173 -0
- package/lib/cli.js +782 -118
- package/lib/config.js +52 -52
- package/lib/discover.js +143 -0
- package/lib/fetch.js +303 -0
- package/lib/find.js +162 -0
- package/lib/lock.js +119 -0
- package/lib/place.js +247 -0
- package/lib/registry.js +141 -0
- package/lib/report.js +53 -53
- package/lib/rules.js +162 -162
- package/lib/safeurl.js +139 -0
- package/lib/scan.js +148 -148
- package/lib/sources.js +311 -0
- package/package.json +43 -41
package/lib/approvals.js
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
|
|
6
|
-
// Resolve the approvals file. Relative paths are resolved against the package root.
|
|
7
|
-
function approvalsPath(config) {
|
|
8
|
-
const file = (config && config.approvalsFile) || 'approved-skills.json';
|
|
9
|
-
return path.isAbsolute(file) ? file : path.join(__dirname, '..', file);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function loadApprovals(config) {
|
|
13
|
-
try {
|
|
14
|
-
const raw = JSON.parse(fs.readFileSync(approvalsPath(config), 'utf8'));
|
|
15
|
-
return Array.isArray(raw.approved) ? raw.approved : [];
|
|
16
|
-
} catch (e) {
|
|
17
|
-
return [];
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// An approval is pinned to an exact source + version/commit.
|
|
22
|
-
function isApproved(approvals, source, version) {
|
|
23
|
-
if (!source || !version) return null;
|
|
24
|
-
for (const a of approvals) {
|
|
25
|
-
if (String(a.source).toLowerCase() === String(source).toLowerCase() &&
|
|
26
|
-
String(a.version) === String(version)) {
|
|
27
|
-
return a;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function addApproval(config, entry) {
|
|
34
|
-
const p = approvalsPath(config);
|
|
35
|
-
let data = { approved: [] };
|
|
36
|
-
try {
|
|
37
|
-
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
38
|
-
if (Array.isArray(raw.approved)) data = raw;
|
|
39
|
-
} catch (e) { /* start fresh */ }
|
|
40
|
-
// Replace any existing approval for the same source+version.
|
|
41
|
-
data.approved = data.approved.filter(function (a) {
|
|
42
|
-
return !(String(a.source).toLowerCase() === String(entry.source).toLowerCase() &&
|
|
43
|
-
String(a.version) === String(entry.version));
|
|
44
|
-
});
|
|
45
|
-
data.approved.push(entry);
|
|
46
|
-
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
47
|
-
return p;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
module.exports = { loadApprovals, isApproved, addApproval, approvalsPath };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Resolve the approvals file. Relative paths are resolved against the package root.
|
|
7
|
+
function approvalsPath(config) {
|
|
8
|
+
const file = (config && config.approvalsFile) || 'approved-skills.json';
|
|
9
|
+
return path.isAbsolute(file) ? file : path.join(__dirname, '..', file);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadApprovals(config) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(fs.readFileSync(approvalsPath(config), 'utf8'));
|
|
15
|
+
return Array.isArray(raw.approved) ? raw.approved : [];
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// An approval is pinned to an exact source + version/commit.
|
|
22
|
+
function isApproved(approvals, source, version) {
|
|
23
|
+
if (!source || !version) return null;
|
|
24
|
+
for (const a of approvals) {
|
|
25
|
+
if (String(a.source).toLowerCase() === String(source).toLowerCase() &&
|
|
26
|
+
String(a.version) === String(version)) {
|
|
27
|
+
return a;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function addApproval(config, entry) {
|
|
34
|
+
const p = approvalsPath(config);
|
|
35
|
+
let data = { approved: [] };
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
38
|
+
if (Array.isArray(raw.approved)) data = raw;
|
|
39
|
+
} catch (e) { /* start fresh */ }
|
|
40
|
+
// Replace any existing approval for the same source+version.
|
|
41
|
+
data.approved = data.approved.filter(function (a) {
|
|
42
|
+
return !(String(a.source).toLowerCase() === String(entry.source).toLowerCase() &&
|
|
43
|
+
String(a.version) === String(entry.version));
|
|
44
|
+
});
|
|
45
|
+
data.approved.push(entry);
|
|
46
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { loadApprovals, isApproved, addApproval, approvalsPath };
|
package/lib/archive.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// shucky archive extractor — unpack a .tar.gz or .zip into a directory, safely.
|
|
4
|
+
// Pure Node (zlib only). This is the highest-risk parser in shucky, so it is defensive by default:
|
|
5
|
+
// - zip-slip: every entry path is resolved and must stay inside the destination
|
|
6
|
+
// - symlink/hardlink/device entries are DROPPED (never written) — same reason place.js drops them
|
|
7
|
+
// - caps: entry count, per-entry size, total uncompressed size, and zlib maxOutputLength (zip-bomb)
|
|
8
|
+
// - unrecognised entry types and unsupported compression methods are skipped, not trusted
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const zlib = require('zlib');
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LIMITS = {
|
|
15
|
+
maxEntries: 5000,
|
|
16
|
+
maxEntrySize: 64 * 1024 * 1024,
|
|
17
|
+
maxTotalSize: 256 * 1024 * 1024
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Resolve an archive entry name under destDir; return null if it escapes (zip-slip) or is absolute.
|
|
21
|
+
function safeJoin(destDir, name) {
|
|
22
|
+
if (!name) return null;
|
|
23
|
+
let n = String(name).replace(/\\/g, '/');
|
|
24
|
+
if (n.indexOf('\0') !== -1) return null;
|
|
25
|
+
if (n[0] === '/' || /^[a-zA-Z]:/.test(n)) return null; // absolute (unix / windows)
|
|
26
|
+
const full = path.resolve(destDir, n);
|
|
27
|
+
const rel = path.relative(destDir, full);
|
|
28
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
29
|
+
return full;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- tar ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function readField(block, start, len) {
|
|
35
|
+
const s = block.slice(start, start + len);
|
|
36
|
+
const nul = s.indexOf(0);
|
|
37
|
+
return s.slice(0, nul === -1 ? len : nul).toString('utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractTar(buf, destDir, limits) {
|
|
41
|
+
limits = Object.assign({}, DEFAULT_LIMITS, limits);
|
|
42
|
+
let offset = 0, entries = 0, total = 0, written = 0;
|
|
43
|
+
let paxPath = null;
|
|
44
|
+
while (offset + 512 <= buf.length) {
|
|
45
|
+
const header = buf.slice(offset, offset + 512);
|
|
46
|
+
offset += 512;
|
|
47
|
+
let allZero = true;
|
|
48
|
+
for (let i = 0; i < 512; i++) { if (header[i] !== 0) { allZero = false; break; } }
|
|
49
|
+
if (allZero) break; // end-of-archive marker
|
|
50
|
+
|
|
51
|
+
const ustarName = readField(header, 0, 100);
|
|
52
|
+
const prefix = readField(header, 345, 155);
|
|
53
|
+
const sizeOct = readField(header, 124, 12).replace(/[^0-7]/g, '');
|
|
54
|
+
const size = sizeOct ? parseInt(sizeOct, 8) : 0;
|
|
55
|
+
const typeflag = String.fromCharCode(header[156]) || '0';
|
|
56
|
+
|
|
57
|
+
if (size > limits.maxEntrySize) throw new Error('tar entry too large');
|
|
58
|
+
total += size;
|
|
59
|
+
if (total > limits.maxTotalSize) throw new Error('tar exceeds total size cap');
|
|
60
|
+
const data = buf.slice(offset, offset + size);
|
|
61
|
+
offset += Math.ceil(size / 512) * 512;
|
|
62
|
+
|
|
63
|
+
// pax / GNU extended headers: pull a long path for the NEXT entry, then move on.
|
|
64
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
65
|
+
const m = data.toString('utf8').match(/\d+ path=([^\n]*)\n/);
|
|
66
|
+
if (m) paxPath = m[1];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let name = paxPath || (prefix ? prefix + '/' + ustarName : ustarName);
|
|
71
|
+
paxPath = null;
|
|
72
|
+
if (!name) continue;
|
|
73
|
+
if (++entries > limits.maxEntries) throw new Error('tar has too many entries');
|
|
74
|
+
|
|
75
|
+
if (typeflag === '5') { // directory
|
|
76
|
+
const d = safeJoin(destDir, name);
|
|
77
|
+
if (d) fs.mkdirSync(d, { recursive: true });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// '0' / '\0' = regular file. Everything else (2=symlink, 1=hardlink, 3/4=device, fifo, …) is dropped.
|
|
81
|
+
if (typeflag !== '0' && typeflag !== '\0') continue;
|
|
82
|
+
|
|
83
|
+
const dest = safeJoin(destDir, name);
|
|
84
|
+
if (!dest) continue; // tar-slip → skip
|
|
85
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
86
|
+
fs.writeFileSync(dest, data);
|
|
87
|
+
written++;
|
|
88
|
+
}
|
|
89
|
+
return written;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractTarGz(buf, destDir, limits) {
|
|
93
|
+
limits = Object.assign({}, DEFAULT_LIMITS, limits);
|
|
94
|
+
let tar;
|
|
95
|
+
try { tar = zlib.gunzipSync(buf, { maxOutputLength: limits.maxTotalSize }); }
|
|
96
|
+
catch (e) { throw new Error('gunzip failed (corrupt or too large): ' + e.message); }
|
|
97
|
+
return extractTar(tar, destDir, limits);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- zip ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function extractZip(buf, destDir, limits) {
|
|
103
|
+
limits = Object.assign({}, DEFAULT_LIMITS, limits);
|
|
104
|
+
// Find End Of Central Directory record (sig 0x06054b50), scanning back from the end.
|
|
105
|
+
let eocd = -1;
|
|
106
|
+
const min = Math.max(0, buf.length - 22 - 65536);
|
|
107
|
+
for (let i = buf.length - 22; i >= min; i--) {
|
|
108
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
109
|
+
}
|
|
110
|
+
if (eocd < 0) throw new Error('not a valid zip (no end-of-central-directory)');
|
|
111
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
112
|
+
let cd = buf.readUInt32LE(eocd + 16);
|
|
113
|
+
let entries = 0, total = 0, written = 0;
|
|
114
|
+
|
|
115
|
+
for (let n = 0; n < count; n++) {
|
|
116
|
+
if (cd + 46 > buf.length || buf.readUInt32LE(cd) !== 0x02014b50) break; // central-dir header
|
|
117
|
+
const method = buf.readUInt16LE(cd + 10);
|
|
118
|
+
const compSize = buf.readUInt32LE(cd + 20);
|
|
119
|
+
const uncompSize = buf.readUInt32LE(cd + 24);
|
|
120
|
+
const nameLen = buf.readUInt16LE(cd + 28);
|
|
121
|
+
const extraLen = buf.readUInt16LE(cd + 30);
|
|
122
|
+
const commentLen = buf.readUInt16LE(cd + 32);
|
|
123
|
+
const extAttrs = buf.readUInt32LE(cd + 38);
|
|
124
|
+
const localOff = buf.readUInt32LE(cd + 42);
|
|
125
|
+
const name = buf.slice(cd + 46, cd + 46 + nameLen).toString('utf8');
|
|
126
|
+
cd += 46 + nameLen + extraLen + commentLen;
|
|
127
|
+
|
|
128
|
+
if (++entries > limits.maxEntries) throw new Error('zip has too many entries');
|
|
129
|
+
if (uncompSize > limits.maxEntrySize) throw new Error('zip entry too large');
|
|
130
|
+
total += uncompSize;
|
|
131
|
+
if (total > limits.maxTotalSize) throw new Error('zip exceeds total size cap');
|
|
132
|
+
|
|
133
|
+
if (name.endsWith('/')) { const d = safeJoin(destDir, name); if (d) fs.mkdirSync(d, { recursive: true }); continue; }
|
|
134
|
+
// Drop symlinks (unix mode S_IFLNK 0xA000 in the high 16 bits of external attrs).
|
|
135
|
+
if (((extAttrs >>> 16) & 0xF000) === 0xA000) continue;
|
|
136
|
+
|
|
137
|
+
const dest = safeJoin(destDir, name);
|
|
138
|
+
if (!dest) continue; // zip-slip → skip
|
|
139
|
+
|
|
140
|
+
if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) continue;
|
|
141
|
+
const lNameLen = buf.readUInt16LE(localOff + 26);
|
|
142
|
+
const lExtraLen = buf.readUInt16LE(localOff + 28);
|
|
143
|
+
const dataStart = localOff + 30 + lNameLen + lExtraLen;
|
|
144
|
+
const comp = buf.slice(dataStart, dataStart + compSize);
|
|
145
|
+
|
|
146
|
+
let data;
|
|
147
|
+
if (method === 0) data = comp; // stored
|
|
148
|
+
else if (method === 8) {
|
|
149
|
+
try { data = zlib.inflateRawSync(comp, { maxOutputLength: limits.maxEntrySize }); }
|
|
150
|
+
catch (e) { continue; } // corrupt entry → skip
|
|
151
|
+
} else continue; // unsupported method → skip
|
|
152
|
+
|
|
153
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
154
|
+
fs.writeFileSync(dest, data);
|
|
155
|
+
written++;
|
|
156
|
+
}
|
|
157
|
+
return written;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- dispatch -------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
function extractArchive(buf, destDir, opts) {
|
|
163
|
+
opts = opts || {};
|
|
164
|
+
if (!buf || buf.length < 4) throw new Error('empty or truncated archive');
|
|
165
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
166
|
+
const isGzip = buf[0] === 0x1f && buf[1] === 0x8b;
|
|
167
|
+
const isZip = buf[0] === 0x50 && buf[1] === 0x4b; // 'PK'
|
|
168
|
+
if (isZip || opts.format === 'zip') return { format: 'zip', written: extractZip(buf, destDir, opts.limits) };
|
|
169
|
+
if (isGzip || opts.format === 'tar.gz' || opts.format === 'tgz') return { format: 'tar.gz', written: extractTarGz(buf, destDir, opts.limits) };
|
|
170
|
+
throw new Error('unrecognized archive format (expected .tar.gz or .zip)');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { extractArchive, extractTarGz, extractZip, extractTar, safeJoin, DEFAULT_LIMITS };
|