@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanhcan148/mk",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "CLI to install and manage MyClaudeKit (.claude/) in your projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 notes = body.length > 500 ? body.slice(0, 500) + '...' : body;
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
- body: `client_id=${GITHUB_CLIENT_ID}&scope=repo`
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) {
@@ -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: 'follow'
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
  }
@@ -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);