diff --git a/.github/workflows/sysinternals.yml b/.github/workflows/sysinternals.yml new file mode 100644 index 00000000..9979067d --- /dev/null +++ b/.github/workflows/sysinternals.yml @@ -0,0 +1,25 @@ +name: Check Sysinternals mirror + +on: + workflow_dispatch: + schedule: + - cron: '17 6 * * 1' + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Clone this repo + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .node-version + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check Sysinternals files + run: npm run sysinternals:check diff --git a/README.md b/README.md index 083210f9..cf679249 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ npm run audit:known npm run sysinternals:check npm run build npm run smoke +npm run validate ``` The build renders the Astro site, copies non-Markdown files from `content/` @@ -38,6 +39,7 @@ referenced by a `resources` or `attachments` shortcode unless they are an intentional mirror/bulk asset listed in `scripts/content-policy.json`. `npm run check:links` validates internal Markdown links, anchors, images, and downloadable assets. External links are inventoried without network calls. +`npm run validate` runs the full local validation gate. Use `npm run sysinternals:check` to compare the published Sysinternals files with `https://live.sysinternals.com/`. Use `npm run sysinternals:sync` to @@ -46,12 +48,9 @@ directories, marker files, and files over the 25MB Cloudflare Pages limit. ## Security Notes -`npm audit` currently reports upstream `esbuild` advisories through Astro/Vite. -Use `npm run audit:known` in CI and local checks: it allows only the documented -Astro/Vite/esbuild advisory chain and fails on any new vulnerability. Do not run -`npm audit fix --force` for that advisory, because npm currently proposes an -invalid Astro downgrade path. Keep Astro/Vite updated through Renovate and review -the advisory again when a compatible upstream fix is available. +`npm run audit:known` expects a clean `npm audit` result and fails on any +reported vulnerability. Keep Astro/Vite updated through Renovate and review +dependency advisories before adding any exception. ## Contributing diff --git a/content/tools/windows/sysinternals/files/Tcpvcon.exe b/content/tools/windows/sysinternals/files/tcpvcon.exe similarity index 100% rename from content/tools/windows/sysinternals/files/Tcpvcon.exe rename to content/tools/windows/sysinternals/files/tcpvcon.exe diff --git a/package-lock.json b/package-lock.json index 06fb02be..88961782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "dependencies": { "@astrojs/check": "^0.9.9", "@astrojs/sitemap": "^3.7.2", - "astro": "^6.3.7", - "gray-matter": "^4.0.3", + "astro": "^6.4.7", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "pagefind": "^1.1.1", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "yaml": "^2.9.0" } }, "node_modules/@astrojs/check": { @@ -2137,9 +2137,9 @@ } }, "node_modules/astro": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.6.tgz", - "integrity": "sha512-48OBTBKR9ctbf+DQxpOuxGl8ebfn59zTuNQMBzptmG/Mi/H8IdfMSbJgGuX1I/4U6g9yazG1p4BHlf4+2hWU4Q==", + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.7.tgz", + "integrity": "sha512-5vsXx0H52u23Jpshs9tM81D03Tb3Oh2Vt2Zo0bpqjXN+njkAWjFyGjTfmWJLAcrCQd9Q+iWB1eqfhR1sZJEaUA==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^4.0.0", @@ -2823,19 +2823,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2848,18 +2835,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2997,43 +2972,6 @@ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "license": "ISC" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -3274,15 +3212,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3353,9 +3282,19 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3376,15 +3315,6 @@ "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", "license": "MIT" }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -4985,19 +4915,6 @@ "node": ">=11.0.0" } }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -5130,12 +5047,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/stream-replace-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", @@ -5156,15 +5067,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/svgo": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", @@ -5635,9 +5537,9 @@ } }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", diff --git a/package.json b/package.json index 7a1d76d7..c1311314 100644 --- a/package.json +++ b/package.json @@ -14,20 +14,23 @@ "audit:known": "node scripts/check-audit.mjs", "sysinternals:check": "node scripts/sync-sysinternals.mjs --check", "sysinternals:sync": "node scripts/sync-sysinternals.mjs --write", - "smoke": "node scripts/smoke-build.mjs" + "smoke": "node scripts/smoke-build.mjs", + "validate": "npm run check && npm run check:content && npm run check:links && npm run build && npm run smoke && npm run audit:known" }, "dependencies": { "@astrojs/check": "^0.9.9", "@astrojs/sitemap": "^3.7.2", - "astro": "^6.3.7", - "gray-matter": "^4.0.3", + "astro": "^6.4.7", "markdown-it": "^14.1.0", "markdown-it-anchor": "^9.2.0", "pagefind": "^1.1.1", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "yaml": "^2.9.0" }, "overrides": { "esbuild": "0.28.1", + "js-yaml": "4.2.0", + "vite": "7.3.5", "volar-service-yaml": "0.0.71" } } diff --git a/scripts/check-audit.mjs b/scripts/check-audit.mjs index 77eb1118..28704d0c 100644 --- a/scripts/check-audit.mjs +++ b/scripts/check-audit.mjs @@ -1,8 +1,5 @@ import { spawnSync } from "node:child_process"; -const expectedSources = new Set([1120679, 1120680]); -const expectedNames = new Set(["astro", "esbuild", "vite"]); - const result = spawnSync("npm", ["audit", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] @@ -35,43 +32,16 @@ if (vulnerabilities.length === 0) { process.exit(0); } -const unexpected = vulnerabilities.filter((vulnerability) => !isExpected(vulnerability)); -if (unexpected.length) { - console.error("npm audit found unexpected vulnerabilities:"); - for (const vulnerability of unexpected) { - console.error(`- ${vulnerability.name} (${vulnerability.severity})`); - } - process.exit(1); -} - -const advisories = [...collectSources(vulnerabilities)].sort((a, b) => a - b).join(", "); -console.warn(`npm audit: only known upstream Astro/Vite/esbuild advisories found (${advisories})`); - -function isExpected(vulnerability) { - if (!expectedNames.has(vulnerability.name)) return false; - - const sources = collectSources([vulnerability]); - if (sources.size > 0) { - return [...sources].every((source) => expectedSources.has(source)); - } - - return asArray(vulnerability.via).every((via) => { - if (typeof via === "string") return expectedNames.has(via); - return expectedSources.has(via.source); - }); -} - -function collectSources(vulnerabilities) { - const sources = new Set(); - for (const vulnerability of vulnerabilities) { - for (const via of asArray(vulnerability.via)) { - if (typeof via === "object" && Number.isInteger(via.source)) { - sources.add(via.source); - } +console.error("npm audit found vulnerabilities:"); +for (const vulnerability of vulnerabilities) { + console.error(`- ${vulnerability.name} (${vulnerability.severity})`); + for (const via of asArray(vulnerability.via)) { + if (typeof via === "object" && via.title) { + console.error(` - ${via.title}`); } } - return sources; } +process.exit(1); function asArray(value) { return Array.isArray(value) ? value : []; diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs index 4c87d00c..de4758cf 100644 --- a/scripts/check-content.mjs +++ b/scripts/check-content.mjs @@ -1,6 +1,6 @@ import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; -import matter from "gray-matter"; +import { parseFrontmatter } from "../src/lib/frontmatter.mjs"; const root = process.cwd(); const contentDir = path.join(root, "content"); @@ -91,8 +91,8 @@ async function walk(dir) { async function parsePages() { for (const file of markdownFiles) { const raw = await readFile(file, "utf8"); - const parsed = matter(raw); const relativeFile = slash(path.relative(contentDir, file)); + const parsed = parseFrontmatter(raw, relativeFile); const slug = slugFromFile(relativeFile); pages.push({ diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index f7f1b0cf..fda9c2aa 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; -import matter from "gray-matter"; import MarkdownIt from "markdown-it"; +import { parseFrontmatter } from "../src/lib/frontmatter.mjs"; const root = process.cwd(); const contentDir = path.join(root, "content"); @@ -42,7 +42,7 @@ function collectContent() { const relative = slash(path.relative(contentDir, file)); if (relative.endsWith(".md")) { const raw = readFileSync(file, "utf8"); - const parsed = matter(raw); + const parsed = parseFrontmatter(raw, relative); if (parsed.data.draft === true) continue; const slug = slugFromFile(relative); @@ -66,11 +66,11 @@ function collectContent() { function checkMarkdownFile(file) { const raw = readFileSync(file, "utf8"); - const parsed = matter(raw); + const source = slash(path.relative(root, file)); + const parsed = parseFrontmatter(raw, source); const tokens = md.parse(parsed.content, {}); const page = pagesByFile.get(slash(path.relative(root, file))) ?? null; const baseDir = page ? path.join(contentDir, page.sourceDir) : path.dirname(file); - const source = slash(path.relative(root, file)); for (const token of tokens) { collectTokenLinks(token, source, baseDir, page); diff --git a/src/lib/content.ts b/src/lib/content.ts index ddb05f6a..c706653f 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import matter from "gray-matter"; import MarkdownIt from "markdown-it"; import anchor from "markdown-it-anchor"; +import { parseFrontmatter } from "./frontmatter.mjs"; const root = process.cwd(); const contentRoot = path.join(root, "content"); @@ -132,7 +132,7 @@ export function sortPages(a: KbPage, b: KbPage) { function parsePage(file: string): KbPage { const relativeFile = slash(path.relative(contentRoot, file)); const raw = fs.readFileSync(file, "utf8"); - const parsed = matter(raw); + const parsed = parseFrontmatter(raw, relativeFile); const slug = slugFromFile(relativeFile); const url = slug ? `/${slug}/` : "/"; const title = normalizeTitle(parsed.data.title) || titleFromSlug(slug || "Knowledge Base"); diff --git a/src/lib/frontmatter.d.ts b/src/lib/frontmatter.d.ts new file mode 100644 index 00000000..3a4e9f39 --- /dev/null +++ b/src/lib/frontmatter.d.ts @@ -0,0 +1,7 @@ +export function parseFrontmatter( + source: string, + file?: string +): { + data: Record; + content: string; +}; diff --git a/src/lib/frontmatter.mjs b/src/lib/frontmatter.mjs new file mode 100644 index 00000000..a9013858 --- /dev/null +++ b/src/lib/frontmatter.mjs @@ -0,0 +1,60 @@ +import { parseDocument } from "yaml"; + +const delimiter = "---"; + +export function parseFrontmatter(source, file = "content") { + const cleanSource = String(source).replace(/^\uFEFF/, ""); + + if (!cleanSource.startsWith(`${delimiter}\n`) && !cleanSource.startsWith(`${delimiter}\r\n`)) { + return { data: {}, content: cleanSource }; + } + + const firstLineEnd = cleanSource.indexOf("\n"); + const bodyStart = firstLineEnd + 1; + const closing = findClosingDelimiter(cleanSource, bodyStart); + + if (closing === -1) { + throw new Error(`${file}: missing closing frontmatter delimiter`); + } + + const frontmatter = cleanSource.slice(bodyStart, closing.start); + const content = cleanSource.slice(closing.end).replace(/^\r?\n/, ""); + const document = parseDocument(frontmatter, { merge: true, prettyErrors: false }); + + if (document.errors.length) { + throw new Error(`${file}: invalid frontmatter YAML: ${document.errors[0].message}`); + } + + const data = document.toJSON() ?? {}; + if (!isPlainObject(data)) { + throw new Error(`${file}: frontmatter must be a YAML object`); + } + + return { data, content }; +} + +function findClosingDelimiter(source, start) { + let lineStart = start; + + while (lineStart < source.length) { + const lineEnd = source.indexOf("\n", lineStart); + const end = lineEnd === -1 ? source.length : lineEnd; + const line = source.slice(lineStart, end).replace(/\r$/, ""); + + if (line === delimiter) { + return { + start: lineStart, + end: lineEnd === -1 ? end : lineEnd + 1 + }; + } + + if (lineEnd === -1) break; + lineStart = lineEnd + 1; + } + + return -1; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +}