@natjswenson/devlog 0.1.5 → 0.1.7

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.
@@ -7,9 +7,24 @@ import './DevLogPage.css';
7
7
 
8
8
  const ENTRIES_PER_PAGE = 10;
9
9
 
10
+ // Strict allowlist of URL schemes permitted in markdown links/images.
11
+ // react-markdown 9's default sanitizer already blocks `javascript:`,
12
+ // `vbscript:`, `file:`. We narrow further: only http/https/mailto.
13
+ // Anything else (data:, blob:, ftp:, custom schemes) is replaced with `#`.
14
+ const SAFE_URL_SCHEME = /^(https?:|mailto:|#|\/|\.\.?\/|[^:]*$)/i;
15
+ function safeUrlTransform(url) {
16
+ if (typeof url !== 'string') return '#';
17
+ if (SAFE_URL_SCHEME.test(url)) return url;
18
+ return '#';
19
+ }
20
+
10
21
  function formatDate(dateStr) {
11
- const [year, month, day] = dateStr.split('-');
12
- const date = new Date(year, month - 1, day);
22
+ if (typeof dateStr !== 'string') return '';
23
+ const parts = dateStr.split('-');
24
+ if (parts.length !== 3) return '';
25
+ const [year, month, day] = parts;
26
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
27
+ if (isNaN(date.getTime())) return '';
13
28
  return date.toLocaleDateString('en-US', {
14
29
  month: 'short',
15
30
  day: 'numeric',
@@ -139,7 +154,11 @@ export default function DevLogPage({
139
154
  <div className="devlog-content-inner">
140
155
  {isExpanded && content && (
141
156
  <div className="devlog-content" onClick={(e) => e.stopPropagation()}>
142
- <ReactMarkdown remarkPlugins={[remarkGfm]}>
157
+ <ReactMarkdown
158
+ remarkPlugins={[remarkGfm]}
159
+ urlTransform={safeUrlTransform}
160
+ skipHtml
161
+ >
143
162
  {content}
144
163
  </ReactMarkdown>
145
164
  </div>
@@ -1,24 +1,44 @@
1
1
  import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
2
  import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
3
3
 
4
+ // Allowlist of frontmatter keys we recognize. Anything else is ignored —
5
+ // prevents prototype-pollution via crafted keys like `__proto__`.
6
+ const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
7
+
4
8
  /**
5
9
  * Parse YAML-ish frontmatter from a markdown string.
6
10
  * Returns { metadata: { title, date, project, summary }, body: string }
7
11
  */
8
12
  function parseFrontmatter(raw) {
9
13
  const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
10
- if (!match) return { metadata: {}, body: raw };
11
-
12
- const frontmatter = match[1];
13
- const body = match[2];
14
- const metadata = {};
14
+ // Use Object.create(null) so the returned object has no prototype chain.
15
+ const metadata = Object.create(null);
16
+ if (!match) return { metadata, body: raw };
15
17
 
16
- for (const line of frontmatter.split('\n')) {
18
+ for (const line of match[1].split('\n')) {
17
19
  const m = line.match(/^(\w+)\s*:\s*"?([^"]*)"?\s*$/);
18
- if (m) metadata[m[1]] = m[2].trim();
20
+ if (m && FRONTMATTER_KEYS.has(m[1])) metadata[m[1]] = m[2].trim();
19
21
  }
20
22
 
21
- return { metadata, body };
23
+ return { metadata, body: match[2] };
24
+ }
25
+
26
+ // Schema validation for fetched manifest. Reject anything that isn't shaped
27
+ // like { entries: [{ date, file, title, summary }, ...] } so a hostile commit
28
+ // to the dev-log repo can't crash the page.
29
+ function validateManifest(data) {
30
+ if (!data || typeof data !== 'object') return null;
31
+ if (!Array.isArray(data.entries)) return null;
32
+ const entries = [];
33
+ for (const e of data.entries) {
34
+ if (!e || typeof e !== 'object') continue;
35
+ const { date, file, title, summary } = e;
36
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
37
+ if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
38
+ if (typeof title !== 'string' || typeof summary !== 'string') continue;
39
+ entries.push({ date, file, title, summary });
40
+ }
41
+ return { entries };
22
42
  }
23
43
 
24
44
  /**
@@ -51,7 +71,9 @@ export function useDevLogEntries(project, configOverride) {
51
71
  setError(null);
52
72
 
53
73
  try {
54
- const url = `${config.baseUrl}/${project}/manifest.json`;
74
+ // Encode project key to neutralize any path-traversal characters
75
+ // (the project key is allowlisted upstream, but defense-in-depth).
76
+ const url = `${config.baseUrl}/${encodeURIComponent(project)}/manifest.json`;
55
77
  const res = await fetch(url);
56
78
  if (!res.ok) {
57
79
  if (res.status === 404) {
@@ -61,7 +83,9 @@ export function useDevLogEntries(project, configOverride) {
61
83
  throw new Error(`Failed to fetch manifest (${res.status})`);
62
84
  }
63
85
  const data = await res.json();
64
- setEntries(data.entries || []);
86
+ const validated = validateManifest(data);
87
+ if (!validated) throw new Error('Manifest failed schema validation');
88
+ setEntries(validated.entries);
65
89
  } catch (err) {
66
90
  setError(err.message);
67
91
  } finally {
@@ -76,8 +100,15 @@ export function useDevLogEntries(project, configOverride) {
76
100
  const fetchEntryContent = useCallback(async (filename) => {
77
101
  if (contentCache.current.has(filename)) return;
78
102
 
103
+ // Final filename gate (manifest validation already enforces the same
104
+ // pattern — keep this here so any consumer calling fetchEntryContent
105
+ // directly is also protected).
106
+ if (typeof filename !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(filename)) {
107
+ return;
108
+ }
109
+
79
110
  try {
80
- const url = `${config.baseUrl}/${project}/${filename}`;
111
+ const url = `${config.baseUrl}/${encodeURIComponent(project)}/${filename}`;
81
112
  const res = await fetch(url);
82
113
  if (!res.ok) throw new Error(`Failed to fetch entry (${res.status})`);
83
114
  const raw = await res.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/devlog",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Daily dev log generator — Claude Code skill + preview app for publishing git-based dev logs to your site",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
@@ -29,6 +29,8 @@
29
29
  "preview/",
30
30
  "examples/",
31
31
  "SKILL.md",
32
+ "SECURITY.md",
33
+ "CHANGELOG.md",
32
34
  "config.example.json",
33
35
  "README.md",
34
36
  "LICENSE"
@@ -36,14 +38,17 @@
36
38
  "engines": {
37
39
  "node": ">=18"
38
40
  },
41
+ "scripts": {
42
+ "audit": "npm audit --audit-level=moderate"
43
+ },
39
44
  "dependencies": {
40
- "@vitejs/plugin-react": "^4.3.0",
41
- "kleur": "^4.1.5",
42
- "prompts": "^2.4.2",
43
- "react": "^18.3.0",
44
- "react-dom": "^18.3.0",
45
- "react-markdown": "^9.0.0",
46
- "remark-gfm": "^4.0.0",
47
- "vite": "^5.4.0"
45
+ "@vitejs/plugin-react": "6.0.1",
46
+ "kleur": "4.1.5",
47
+ "prompts": "2.4.2",
48
+ "react": "18.3.1",
49
+ "react-dom": "18.3.1",
50
+ "react-markdown": "9.1.0",
51
+ "remark-gfm": "4.0.1",
52
+ "vite": "8.0.10"
48
53
  }
49
54
  }
package/preview/App.jsx CHANGED
@@ -6,12 +6,24 @@ const owner = import.meta.env.VITE_DEVLOG_OWNER;
6
6
  const repo = import.meta.env.VITE_DEVLOG_REPO;
7
7
  const branch = import.meta.env.VITE_DEVLOG_BRANCH || 'main';
8
8
  const projectsRaw = import.meta.env.VITE_DEVLOG_PROJECTS;
9
+ const isDev = import.meta.env.DEV;
9
10
 
10
11
  const isDemo = !owner || !repo;
11
12
 
12
13
  let realProjects = [];
13
14
  try {
14
- realProjects = projectsRaw ? JSON.parse(projectsRaw) : [];
15
+ const parsed = projectsRaw ? JSON.parse(projectsRaw) : [];
16
+ // Schema-validate: must be array of {key, label?} where key matches a safe
17
+ // allowlist. Anything else gets dropped silently.
18
+ if (Array.isArray(parsed)) {
19
+ for (const p of parsed) {
20
+ if (!p || typeof p !== 'object') continue;
21
+ if (typeof p.key !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(p.key)) continue;
22
+ if (p.key.includes('..')) continue;
23
+ const label = typeof p.label === 'string' ? p.label : p.key;
24
+ realProjects.push({ key: p.key, label });
25
+ }
26
+ }
15
27
  } catch {
16
28
  realProjects = [];
17
29
  }
@@ -49,16 +61,39 @@ function NoProjectsScreen() {
49
61
  Env vars say you have a repo (<code>{owner}/{repo}</code>) but no projects.
50
62
  That's like buying a stage and forgetting to invite a band.
51
63
  </p>
52
- <p>Add at least one project to your config:</p>
64
+ <p>Add a project the easy way:</p>
65
+ <pre>npx @natjswenson/devlog add-project</pre>
66
+ <p>Or edit your config directly:</p>
53
67
  <pre>{`{
68
+ "targetRepo": "${owner}/${repo}",
69
+ "branch": "${branch}",
54
70
  "projects": [
55
- { "key": "myproject", "path": "...", "remote": "${owner}/myproject" }
71
+ { "key": "myproject", "label": "My Project", "path": "...", "remote": "${owner}/myproject" }
56
72
  ]
57
73
  }`}</pre>
74
+ <p>Then re-run <code>npx @natjswenson/devlog preview</code>.</p>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // Production builds without env vars: show a clear "setup required" screen,
80
+ // not the demo banner with broken fetches (since installDemoFetch is gated to DEV).
81
+ function SetupRequiredScreen() {
82
+ return (
83
+ <div className="empty-screen">
84
+ <h1>Setup required</h1>
85
+ <p>
86
+ This preview was built without the env vars that point it at a dev-log repo.
87
+ Set these in your hosting environment (Vercel/Netlify/Cloudflare/wherever):
88
+ </p>
89
+ <pre>{`VITE_DEVLOG_OWNER=your-github-username
90
+ VITE_DEVLOG_REPO=daily-dev-log
91
+ VITE_DEVLOG_BRANCH=main
92
+ VITE_DEVLOG_PROJECTS=[{"key":"myproject","label":"My Project"}]`}</pre>
58
93
  <p>
59
- Then run <code>npx @natjswenson/devlog preview</code> again, or set
60
- <code> VITE_DEVLOG_PROJECTS</code> directly in <code>preview/.env.local</code>.
94
+ Or, if you're trying this locally, the friendlier path is:
61
95
  </p>
96
+ <pre>npx @natjswenson/devlog init &amp;&amp; npx @natjswenson/devlog preview</pre>
62
97
  </div>
63
98
  );
64
99
  }
@@ -66,6 +101,12 @@ function NoProjectsScreen() {
66
101
  export default function App() {
67
102
  const [activeKey, setActiveKey] = useState((projects && projects[0]?.key) || DEMO_PROJECT_KEY);
68
103
 
104
+ // Production build with no env vars: show actionable setup screen
105
+ // (demo fetch override is DEV-only, so demo entries would 404 here).
106
+ if (isDemo && !isDev) {
107
+ return <SetupRequiredScreen />;
108
+ }
109
+
69
110
  if (!isDemo && !projects) {
70
111
  return <NoProjectsScreen />;
71
112
  }
@@ -3,6 +3,20 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <!--
7
+ Content-Security-Policy: defense-in-depth for the preview app.
8
+ - 'self' for scripts/styles (no inline JS, no eval)
9
+ - 'unsafe-inline' for styles only — required by Vite's HMR runtime in dev
10
+ and react-markdown's prismjs (none used here, but standard practice)
11
+ - connect to raw.githubusercontent.com for entry fetches; api.github.com
12
+ for any future repo metadata; demo-mode.local for the in-browser fetch
13
+ override that powers demo mode without env vars
14
+ - no objects, no frames, no plugins
15
+ Production build: replace 'unsafe-inline' with hashes/nonces if you
16
+ configure Vite to emit them.
17
+ -->
18
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com https://api.github.com https://demo-mode.local; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'none';" />
19
+ <meta name="referrer" content="no-referrer-when-downgrade" />
6
20
  <title>devlog preview</title>
7
21
  <style>
8
22
  :root {
package/preview/main.jsx CHANGED
@@ -4,7 +4,10 @@ import App from './App.jsx';
4
4
  import { installDemoFetch } from './demo.js';
5
5
  import './preview.css';
6
6
 
7
- if (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO) {
7
+ // Gate the global window.fetch override behind DEV. In production builds
8
+ // (e.g. when adopters deploy the preview directory standalone), demo content
9
+ // must NOT silently intercept fetches — show the empty-state UX instead.
10
+ if (import.meta.env.DEV && (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO)) {
8
11
  installDemoFetch();
9
12
  }
10
13
 
@@ -19,8 +19,16 @@ export default defineConfig({
19
19
  'style-to-js',
20
20
  ],
21
21
  },
22
+ // Bind preview server to localhost only — the dev server has no auth and
23
+ // serves transformed source modules. Don't expose it to LAN by default.
24
+ // (npm audit flags moderate CVEs in dev-server CORS handling for any
25
+ // deployment that allows cross-origin reads; localhost-binding is
26
+ // defense-in-depth on top of vite's own patches.)
22
27
  server: {
28
+ host: 'localhost',
23
29
  port: 5173,
24
30
  open: true,
31
+ strictPort: false,
32
+ cors: false,
25
33
  },
26
34
  });