@khanhcan148/mk 0.1.14 → 0.1.15
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 +1 -1
- package/package.json +1 -1
- package/src/commands/update.js +31 -2
- package/src/lib/auth.js +3 -1
- package/src/lib/download.js +32 -3
- package/src/lib/releases.js +9 -0
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ User → /mk-* command (skill) → spawns utility agents → agents use knowledg
|
|
|
134
134
|
| `/mk-docs` | Generate and update project documentation; maintains AGENTS.md; Impact Areas analysis produces human-readable "What changed / Who is affected / What could go wrong" narrative |
|
|
135
135
|
| `/mk-git` | Git operations: branch, commit, push, PR, merge |
|
|
136
136
|
| `/mk-research` | Deep multi-source research on technical topics |
|
|
137
|
-
| `/mk-spike` | Investigate external service integrations: fetch API docs, evaluate options, produce spike.md with Go/No-Go |
|
|
137
|
+
| `/mk-spike` | Investigate external service integrations: fetch API docs, evaluate options, produce <service-slug>-spike.md with Go/No-Go |
|
|
138
138
|
| `/mk-overview` | Synthesize project artifacts into multi-tier stakeholder overview: Executive Brief, Product Report, Technical Report |
|
|
139
139
|
| `/mk-workflow` | Trace REST endpoint call chains with upstream caller detection, variant branching, side effects/feature flags, Mermaid diagrams |
|
|
140
140
|
| `/mk-log-analysis` | Analyze production logs from Datadog or Azure Application Insights via MCP; progressive severity triage, pattern detection, mandatory stack trace investigation, mk-debug integration |
|
package/package.json
CHANGED
package/src/commands/update.js
CHANGED
|
@@ -13,6 +13,32 @@ import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
|
|
|
13
13
|
import { fetchLatestRelease, compareVersions } from '../lib/releases.js';
|
|
14
14
|
import { isEmptyDir } from '../lib/fs-utils.js';
|
|
15
15
|
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Security: strip terminal escape sequences from untrusted content
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip terminal escape sequences from a string to prevent terminal injection
|
|
22
|
+
* when printing content sourced from the GitHub API (e.g. release notes).
|
|
23
|
+
*
|
|
24
|
+
* Removes:
|
|
25
|
+
* - CSI sequences: ESC [ ... <letter> (e.g. color codes, cursor movement, screen clear)
|
|
26
|
+
* - OSC sequences: ESC ] ... BEL/ST (e.g. window title manipulation)
|
|
27
|
+
* - Fe two-character sequences: ESC <char> (e.g. ESC c = RIS terminal reset, ESC P = DCS)
|
|
28
|
+
* - Raw C0 control characters (0x00-0x08, 0x0b, 0x0c, 0x0e-0x1f) excluding
|
|
29
|
+
* printable whitespace (\t, \n, \r which are 0x09, 0x0a, 0x0d)
|
|
30
|
+
*
|
|
31
|
+
* @param {string} str
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function stripTerminalEscapes(str) {
|
|
35
|
+
return str
|
|
36
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences
|
|
37
|
+
.replace(/\x1b\].*?(\x07|\x1b\\)/gs, '') // OSC sequences (dotAll for multiline)
|
|
38
|
+
.replace(/\x1b[^[\]]/g, '') // Fe two-char sequences (ESC c, ESC P, etc.)
|
|
39
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // raw control chars (preserve \t \n \r)
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
// ---------------------------------------------------------------------------
|
|
17
43
|
// Prompt helper
|
|
18
44
|
// ---------------------------------------------------------------------------
|
|
@@ -327,10 +353,13 @@ export async function updateAction(options = {}, deps = {}) {
|
|
|
327
353
|
chalk.cyan(`Update available: v${local} -> v${remote}\n`)
|
|
328
354
|
);
|
|
329
355
|
|
|
330
|
-
// Show release notes (if any), truncated to 500 chars
|
|
356
|
+
// Show release notes (if any), truncated to 500 chars.
|
|
357
|
+
// S4: Strip terminal escape sequences before printing to prevent injection via
|
|
358
|
+
// crafted GitHub release bodies (CSI/OSC sequences can clear screen, set window titles, etc.).
|
|
331
359
|
const body = release.body;
|
|
332
360
|
if (body && body.trim().length > 0) {
|
|
333
|
-
const
|
|
361
|
+
const rawNotes = body.length > 500 ? body.slice(0, 500) + '...' : body;
|
|
362
|
+
const notes = stripTerminalEscapes(rawNotes);
|
|
334
363
|
process.stdout.write('\nRelease notes:\n');
|
|
335
364
|
process.stdout.write(notes + '\n\n');
|
|
336
365
|
}
|
package/src/lib/auth.js
CHANGED
|
@@ -127,7 +127,9 @@ export async function startDeviceFlow(opts = {}) {
|
|
|
127
127
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
128
128
|
Accept: 'application/json'
|
|
129
129
|
},
|
|
130
|
-
|
|
130
|
+
// Empty scope sufficient for public repos (5000 req/hr). Set MK_OAUTH_SCOPE=repo for private forks.
|
|
131
|
+
// Use URLSearchParams to prevent parameter injection via env var containing '&' chars.
|
|
132
|
+
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, scope: process.env.MK_OAUTH_SCOPE || '' }).toString()
|
|
131
133
|
});
|
|
132
134
|
|
|
133
135
|
if (!codeRes.ok) {
|
package/src/lib/download.js
CHANGED
|
@@ -17,15 +17,22 @@ const TARBALL_URL = `${GITHUB_API}/repos/${KIT_REPO}/tarball/${KIT_BRANCH}`;
|
|
|
17
17
|
* from crafted tarballs with large size fields. 50 MB is well above any kit file. */
|
|
18
18
|
const MAX_ENTRY_SIZE = 50 * 1024 * 1024; // 52428800 bytes
|
|
19
19
|
|
|
20
|
+
/** Hostnames allowed for kit downloads. Hoisted to module scope to avoid rebuilding on each call. */
|
|
21
|
+
const ALLOWED_HOSTS = new Set(['github.com', 'api.github.com', 'codeload.github.com']);
|
|
22
|
+
|
|
23
|
+
/** HTTP status codes that indicate a redirect. Hoisted to module scope alongside ALLOWED_HOSTS. */
|
|
24
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* Validate that the download URL's hostname is a GitHub domain.
|
|
22
28
|
* Prevents SSRF: caller-supplied URLs (e.g. tarballUrl from GitHub API JSON) could be
|
|
23
29
|
* redirected to an attacker-controlled host, exfiltrating the Bearer token.
|
|
24
30
|
* Allowed: api.github.com, *.github.com (e.g. codeload.github.com)
|
|
31
|
+
* Exported so that releases.js can validate tarball_url at the source.
|
|
25
32
|
* @param {string} url
|
|
26
33
|
* @throws {Error} if hostname is not a GitHub domain
|
|
27
34
|
*/
|
|
28
|
-
function assertGitHubHostname(url) {
|
|
35
|
+
export function assertGitHubHostname(url) {
|
|
29
36
|
let parsed;
|
|
30
37
|
try {
|
|
31
38
|
parsed = new URL(url);
|
|
@@ -33,7 +40,6 @@ function assertGitHubHostname(url) {
|
|
|
33
40
|
throw new Error(`SSRF guard: invalid URL "${url}"`);
|
|
34
41
|
}
|
|
35
42
|
const { hostname } = parsed;
|
|
36
|
-
const ALLOWED_HOSTS = new Set(['github.com', 'api.github.com', 'codeload.github.com']);
|
|
37
43
|
if (!ALLOWED_HOSTS.has(hostname)) {
|
|
38
44
|
throw new Error(
|
|
39
45
|
`SSRF guard: URL hostname "${hostname}" is not allowed. ` +
|
|
@@ -289,12 +295,35 @@ export async function downloadAndExtractKit(token, opts = {}) {
|
|
|
289
295
|
Authorization: `Bearer ${token}`,
|
|
290
296
|
Accept: 'application/vnd.github.v3+json'
|
|
291
297
|
},
|
|
292
|
-
redirect: '
|
|
298
|
+
// S1: Use redirect: 'manual' to prevent the Authorization header from being forwarded
|
|
299
|
+
// to the redirect target (e.g. codeload.github.com). We handle the redirect manually:
|
|
300
|
+
// validate the Location hostname, then re-fetch without the auth header.
|
|
301
|
+
redirect: 'manual'
|
|
293
302
|
});
|
|
294
303
|
} catch (err) {
|
|
295
304
|
throw new Error(`Network connection failed: ${err.message}`);
|
|
296
305
|
}
|
|
297
306
|
|
|
307
|
+
// Handle 3xx redirects: validate Location hostname, then re-fetch without auth header.
|
|
308
|
+
// This prevents the Bearer token from leaking to third-party servers via a compromised redirect.
|
|
309
|
+
if (REDIRECT_STATUSES.has(res.status)) {
|
|
310
|
+
const location = res.headers.get('location');
|
|
311
|
+
if (!location) {
|
|
312
|
+
throw new Error(`GitHub API redirect (${res.status}) had no Location header`);
|
|
313
|
+
}
|
|
314
|
+
// Validate the redirect target is a GitHub hostname before following
|
|
315
|
+
assertGitHubHostname(location);
|
|
316
|
+
try {
|
|
317
|
+
res = await fetch(location, {
|
|
318
|
+
// No Authorization header on the redirect target — the token is only for api.github.com
|
|
319
|
+
headers: { Accept: 'application/vnd.github.v3+json' },
|
|
320
|
+
redirect: 'follow'
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
throw new Error(`Network connection failed on redirect: ${err.message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
298
327
|
if (!res.ok) {
|
|
299
328
|
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
|
300
329
|
}
|
package/src/lib/releases.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import semver from 'semver';
|
|
2
2
|
import { GITHUB_API, KIT_REPO } from './constants.js';
|
|
3
|
+
import { assertGitHubHostname } from './download.js';
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// GitHub Releases API helpers
|
|
@@ -53,6 +54,14 @@ export async function fetchLatestRelease(token) {
|
|
|
53
54
|
|
|
54
55
|
const { tag_name: tag, body = null, tarball_url: tarballUrl } = data;
|
|
55
56
|
|
|
57
|
+
// S3: Validate tarball_url hostname before returning it to callers.
|
|
58
|
+
// A compromised GitHub API response could supply an attacker-controlled URL for SSRF.
|
|
59
|
+
try {
|
|
60
|
+
assertGitHubHostname(tarballUrl);
|
|
61
|
+
} catch {
|
|
62
|
+
return { available: false, reason: 'Tarball URL failed hostname validation' };
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
// Parse version: try clean() first (handles v-prefix), then coerce() as fallback.
|
|
57
66
|
// coerce('release-0.2.0') => '0.2.0', coerce('totally-not-semver') => null
|
|
58
67
|
const version = semver.clean(tag) ?? (semver.coerce(tag) ? semver.coerce(tag).version : null);
|