@sverka/ui 0.2.16 → 0.2.18
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/dist/index.mjs +6 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -156,6 +156,9 @@ function serveDashboard(res, artifactsDir) {
|
|
|
156
156
|
res.writeHead(200, HTML_HEADERS);
|
|
157
157
|
res.end(html);
|
|
158
158
|
}
|
|
159
|
+
/** Engine run ids are `randomUUID()` values — a leading path segment in
|
|
160
|
+
* this shape is the per-run artifact dir, not a step/pipeline name. */
|
|
161
|
+
const RUN_ID_SEGMENT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
159
162
|
/** Read a SARIF file, normalize findings, and render the HTML report. */
|
|
160
163
|
function serveReport(res, artifactsDir, filename) {
|
|
161
164
|
if (filename === "" || filename.includes("..") || filename.includes("\\") || filename.startsWith("/")) {
|
|
@@ -181,9 +184,11 @@ function serveReport(res, artifactsDir, filename) {
|
|
|
181
184
|
const raw = readFileSync(filePath, "utf8");
|
|
182
185
|
const sarif = JSON.parse(raw);
|
|
183
186
|
const dir = dirname(filename).split(sep).join("/");
|
|
187
|
+
const dirSegments = dir.split("/");
|
|
188
|
+
const checkIdPrefix = dir === "." ? "" : dirSegments[0] !== void 0 && RUN_ID_SEGMENT.test(dirSegments[0]) ? dirSegments.slice(1).join("/") : dir;
|
|
184
189
|
const findings = normalizeSarif(sarif, {
|
|
185
190
|
root: artifactsDir,
|
|
186
|
-
checkIdPrefix
|
|
191
|
+
checkIdPrefix,
|
|
187
192
|
defaultConfidence: .5
|
|
188
193
|
});
|
|
189
194
|
const html = generateSarifHtml(findings);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/dashboard.ts","../src/server.ts"],"sourcesContent":["// @sverka/ui — dashboard HTML generator.\n\n/** Render the dashboard page listing available SARIF files. */\nexport function renderDashboard(\n artifactsDir: string,\n files: readonly string[],\n): string {\n const fileList =\n files.length === 0\n ? '<p class=\"empty\">No SARIF files found. Run <code>sverka run --format sarif</code> to generate findings.</p>'\n : `<ul class=\"file-list\">\n ${files.map((f) => `<li><a href=\"/report/${encodeURIComponent(f)}\">${escapeHtml(f)}</a></li>`).join(\"\\n \")}\n </ul>`;\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Sverka Dashboard</title>\n <style>${CSS}</style>\n</head>\n<body>\n <header>\n <h1>Sverka Dashboard</h1>\n <p class=\"path\">Artifacts: <code>${escapeHtml(artifactsDir)}</code></p>\n </header>\n <main>\n <h2>SARIF Reports</h2>\n ${fileList}\n </main>\n <footer>\n <a href=\"https://sverka.dev\">sverka.dev</a>\n </footer>\n</body>\n</html>`;\n}\n\nfunction escapeHtml(text: string): string {\n return text\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\")\n .replaceAll(\"'\", \"'\");\n}\n\nconst CSS = String.raw`\n:root { color-scheme: dark; }\n* { box-sizing: border-box; }\nbody {\n margin: 0;\n padding: 0;\n background: #0d1117;\n color: #c9d1d9;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n line-height: 1.6;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n}\nheader {\n padding: 1.5rem 2rem;\n background: #161b22;\n border-bottom: 1px solid #30363d;\n}\nheader h1 { margin: 0 0 0.5rem 0; font-size: 1.5rem; color: #f0f6fc; }\n.path { font-size: 0.875rem; color: #8b949e; margin: 0; }\npath code { color: #58a6ff; }\nmain { flex: 1; padding: 2rem; }\nh2 { font-size: 1.25rem; color: #f0f6fc; margin: 0 0 1rem 0; }\n.file-list { list-style: none; padding: 0; }\n.file-list li { margin-bottom: 0.5rem; }\n.file-list a {\n display: block;\n padding: 0.75rem 1rem;\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 6px;\n color: #58a6ff;\n text-decoration: none;\n font-family: monospace;\n font-size: 0.9rem;\n}\n.file-list a:hover { border-color: #58a6ff; background: #1c2330; }\n.empty { color: #8b949e; padding: 2rem; text-align: center; }\ncode { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 3px; }\nfooter { padding: 1rem 2rem; border-top: 1px solid #21262d; text-align: center; }\nfooter a { color: #58a6ff; text-decoration: none; }\n`;\n","// @sverka/ui — local web dashboard server.\n\nimport {\n createServer,\n type Server,\n type IncomingMessage,\n type ServerResponse,\n} from \"node:http\";\nimport { createHash } from \"node:crypto\";\nimport {\n readdirSync,\n readFileSync,\n existsSync,\n realpathSync,\n type Dirent,\n} from \"node:fs\";\nimport { join, dirname, relative, resolve, sep } from \"node:path\";\nimport {\n normalizeSarif,\n type Finding,\n type SarifLog,\n} from \"@sverka/verification\";\nimport { generateSarifHtml } from \"@sverka/sarif-viewer-web\";\nimport { renderDashboard } from \"./dashboard.js\";\n\nexport interface UiServerOptions {\n /** Directory to scan for SARIF files (default: .sverka/artifacts). */\n readonly artifactsDir: string;\n /** Port to listen on (default: 3000). */\n readonly port?: number;\n /** Host to bind (default: localhost). */\n readonly host?: string;\n}\n\nexport interface UiServer {\n readonly port: number;\n readonly url: string;\n close(): void;\n}\n\n/** Security headers for all HTML responses — defense-in-depth against XSS.\n * The CSP blocks inline scripts (script-src 'none') even if escaping fails.\n * generateSarifHtml escapes all user-controlled data via escapeHtml(). */\nconst HTML_HEADERS = {\n \"content-type\": \"text/html; charset=utf-8\",\n \"content-security-policy\":\n \"default-src 'self'; script-src 'none'; style-src 'unsafe-inline'\",\n \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * Start a local web dashboard server that lists SARIF files and\n * renders individual findings reports. Returns when the server is\n * listening.\n */\nexport function startUiServer(options: UiServerOptions): Promise<UiServer> {\n const port = options.port ?? 3000;\n const host = options.host ?? \"localhost\";\n const artifactsDir = resolve(options.artifactsDir);\n\n const server: Server = createServer(\n (req: IncomingMessage, res: ServerResponse) => {\n handleRequest(req, res, artifactsDir);\n },\n );\n\n return new Promise((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n // Get the actual port (supports port 0 = ephemeral).\n const address = server.address();\n const actualPort =\n typeof address === \"object\" && address ? address.port : port;\n\n // Keep the error listener active for errors after listen (e.g. ECONNRESET).\n server.removeListener(\"error\", onError);\n server.on(\"error\", () => {\n // Swallow post-listen errors — the server is already running.\n });\n\n resolve({\n port: actualPort,\n url: `http://${host}:${actualPort}`,\n close: () => {\n server.close((err) => {\n if (\n err &&\n (err as NodeJS.ErrnoException).code !== \"ERR_SERVER_NOT_RUNNING\"\n ) {\n // Ignore — server may already be closed.\n }\n });\n },\n });\n });\n });\n}\n\nfunction handleRequest(\n req: IncomingMessage,\n res: ServerResponse,\n artifactsDir: string,\n): void {\n const url = req.url ?? \"/\";\n\n if (url === \"/\" || url === \"/dashboard\") {\n serveDashboard(res, artifactsDir);\n return;\n }\n\n if (url.startsWith(\"/report/\")) {\n let filename: string;\n try {\n filename = decodeURIComponent(url.slice(\"/report/\".length));\n } catch {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid URL encoding\");\n return;\n }\n serveReport(res, artifactsDir, filename);\n return;\n }\n\n if (url === \"/health\") {\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ok\" }));\n return;\n }\n\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"Not found\");\n}\n\n/** List SARIF files and render the dashboard page.\n * All user-controlled data (artifactsDir, filenames) is escaped via\n * escapeHtml() in renderDashboard. CSP blocks inline scripts as\n * defense-in-depth. */\nfunction serveDashboard(res: ServerResponse, artifactsDir: string): void {\n const files = listSarifFiles(artifactsDir);\n const html = renderDashboard(artifactsDir, files);\n res.writeHead(200, HTML_HEADERS);\n res.end(html); // CodeQL: stored XSS — escapeHtml + CSP mitigate\n}\n\n/** Read a SARIF file, normalize findings, and render the HTML report. */\nfunction serveReport(\n res: ServerResponse,\n artifactsDir: string,\n filename: string,\n): void {\n // Validate the relative path — nested step directories are allowed\n // (artifacts are stored as <pipeline>/<step>/<file>.sarif) but\n // traversal, backslashes, and absolute paths are not.\n if (\n filename === \"\" ||\n filename.includes(\"..\") ||\n filename.includes(\"\\\\\") ||\n filename.startsWith(\"/\")\n ) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid filename\");\n return;\n }\n\n const filePath = join(artifactsDir, filename);\n\n // Resolve real paths and verify the file is inside artifactsDir.\n // This also prevents symlink-based escapes.\n try {\n const realArtifacts = realpathSync(artifactsDir);\n const realFile = realpathSync(filePath);\n if (\n !realFile.startsWith(realArtifacts + sep) &&\n realFile !== realArtifacts\n ) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid filename\");\n return;\n }\n } catch {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"File not found\");\n return;\n }\n\n try {\n const raw = readFileSync(filePath, \"utf8\");\n const sarif = JSON.parse(raw) as SarifLog;\n // Mirror collectFindings: checkId is prefixed with the step directory\n // relative to the artifacts root (\"\" for top-level files).\n const dir = dirname(filename).split(sep).join(\"/\");\n const findings = normalizeSarif(sarif, {\n root: artifactsDir,\n checkIdPrefix: dir === \".\" ? \"\" : dir,\n defaultConfidence: 0.5,\n });\n const html = generateSarifHtml(findings);\n res.writeHead(200, reportHeaders(html));\n res.end(html); // CodeQL: stored XSS — escapeHtml + hash-locked CSP mitigate\n } catch (e) {\n res.writeHead(500, { \"content-type\": \"text/plain\" });\n res.end(\n `Error rendering report: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n}\n\n/** Response headers for report pages. The generated report ships inline\n * <script> blocks for filter/search/sort — allow exactly those scripts by\n * SHA-256 hash instead of disabling them with script-src 'none'. */\nfunction reportHeaders(html: string): Record<string, string> {\n return {\n ...HTML_HEADERS,\n \"content-security-policy\": `default-src 'self'; script-src ${inlineScriptHashes(html)}; style-src 'unsafe-inline'`,\n };\n}\n\n/** Compute a CSP script-src source list of SHA-256 hashes for every inline\n * <script> block in the HTML. Falls back to 'none' when there are none. */\nfunction inlineScriptHashes(html: string): string {\n const hashes: string[] = [];\n for (const match of html.matchAll(\n /<script\\b[^>]*>([\\s\\S]*?)<\\/script[^>]*>/gi,\n )) {\n const digest = createHash(\"sha256\")\n .update(match[1] ?? \"\")\n .digest(\"base64\");\n hashes.push(`'sha256-${digest}'`);\n }\n return hashes.length > 0 ? hashes.join(\" \") : \"'none'\";\n}\n\n/** List .sarif and .sarif.json files under the artifacts directory,\n * recursively. Artifacts are stored as <pipeline>/<step>/<file>.sarif —\n * returned paths are relative to `dir` with forward slashes. */\nfunction listSarifFiles(dir: string): string[] {\n if (!existsSync(dir)) return [];\n const files: string[] = [];\n collectSarifFiles(dir, dir, files);\n return files.sort((a, b) => a.localeCompare(b));\n}\n\nfunction collectSarifFiles(root: string, dir: string, out: string[]): void {\n let entries: Dirent[];\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n const entryPath = join(dir, entry.name);\n // Prevent traversal outside the artifacts root via ../ or links.\n const resolvedEntry = resolve(entryPath);\n if (resolvedEntry !== root && !resolvedEntry.startsWith(root + sep)) {\n continue;\n }\n if (entry.isDirectory()) {\n collectSarifFiles(root, entryPath, out);\n } else if (\n entry.name.endsWith(\".sarif\") ||\n entry.name.endsWith(\".sarif.json\")\n ) {\n out.push(relative(root, entryPath).split(sep).join(\"/\"));\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/dashboard.ts","../src/server.ts"],"sourcesContent":["// @sverka/ui — dashboard HTML generator.\n\n/** Render the dashboard page listing available SARIF files. */\nexport function renderDashboard(\n artifactsDir: string,\n files: readonly string[],\n): string {\n const fileList =\n files.length === 0\n ? '<p class=\"empty\">No SARIF files found. Run <code>sverka run --format sarif</code> to generate findings.</p>'\n : `<ul class=\"file-list\">\n ${files.map((f) => `<li><a href=\"/report/${encodeURIComponent(f)}\">${escapeHtml(f)}</a></li>`).join(\"\\n \")}\n </ul>`;\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Sverka Dashboard</title>\n <style>${CSS}</style>\n</head>\n<body>\n <header>\n <h1>Sverka Dashboard</h1>\n <p class=\"path\">Artifacts: <code>${escapeHtml(artifactsDir)}</code></p>\n </header>\n <main>\n <h2>SARIF Reports</h2>\n ${fileList}\n </main>\n <footer>\n <a href=\"https://sverka.dev\">sverka.dev</a>\n </footer>\n</body>\n</html>`;\n}\n\nfunction escapeHtml(text: string): string {\n return text\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\")\n .replaceAll(\"'\", \"'\");\n}\n\nconst CSS = String.raw`\n:root { color-scheme: dark; }\n* { box-sizing: border-box; }\nbody {\n margin: 0;\n padding: 0;\n background: #0d1117;\n color: #c9d1d9;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n line-height: 1.6;\n min-height: 100vh;\n display: flex;\n flex-direction: column;\n}\nheader {\n padding: 1.5rem 2rem;\n background: #161b22;\n border-bottom: 1px solid #30363d;\n}\nheader h1 { margin: 0 0 0.5rem 0; font-size: 1.5rem; color: #f0f6fc; }\n.path { font-size: 0.875rem; color: #8b949e; margin: 0; }\npath code { color: #58a6ff; }\nmain { flex: 1; padding: 2rem; }\nh2 { font-size: 1.25rem; color: #f0f6fc; margin: 0 0 1rem 0; }\n.file-list { list-style: none; padding: 0; }\n.file-list li { margin-bottom: 0.5rem; }\n.file-list a {\n display: block;\n padding: 0.75rem 1rem;\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 6px;\n color: #58a6ff;\n text-decoration: none;\n font-family: monospace;\n font-size: 0.9rem;\n}\n.file-list a:hover { border-color: #58a6ff; background: #1c2330; }\n.empty { color: #8b949e; padding: 2rem; text-align: center; }\ncode { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 3px; }\nfooter { padding: 1rem 2rem; border-top: 1px solid #21262d; text-align: center; }\nfooter a { color: #58a6ff; text-decoration: none; }\n`;\n","// @sverka/ui — local web dashboard server.\n\nimport {\n createServer,\n type Server,\n type IncomingMessage,\n type ServerResponse,\n} from \"node:http\";\nimport { createHash } from \"node:crypto\";\nimport {\n readdirSync,\n readFileSync,\n existsSync,\n realpathSync,\n type Dirent,\n} from \"node:fs\";\nimport { join, dirname, relative, resolve, sep } from \"node:path\";\nimport {\n normalizeSarif,\n type Finding,\n type SarifLog,\n} from \"@sverka/verification\";\nimport { generateSarifHtml } from \"@sverka/sarif-viewer-web\";\nimport { renderDashboard } from \"./dashboard.js\";\n\nexport interface UiServerOptions {\n /** Directory to scan for SARIF files (default: .sverka/artifacts). */\n readonly artifactsDir: string;\n /** Port to listen on (default: 3000). */\n readonly port?: number;\n /** Host to bind (default: localhost). */\n readonly host?: string;\n}\n\nexport interface UiServer {\n readonly port: number;\n readonly url: string;\n close(): void;\n}\n\n/** Security headers for all HTML responses — defense-in-depth against XSS.\n * The CSP blocks inline scripts (script-src 'none') even if escaping fails.\n * generateSarifHtml escapes all user-controlled data via escapeHtml(). */\nconst HTML_HEADERS = {\n \"content-type\": \"text/html; charset=utf-8\",\n \"content-security-policy\":\n \"default-src 'self'; script-src 'none'; style-src 'unsafe-inline'\",\n \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * Start a local web dashboard server that lists SARIF files and\n * renders individual findings reports. Returns when the server is\n * listening.\n */\nexport function startUiServer(options: UiServerOptions): Promise<UiServer> {\n const port = options.port ?? 3000;\n const host = options.host ?? \"localhost\";\n const artifactsDir = resolve(options.artifactsDir);\n\n const server: Server = createServer(\n (req: IncomingMessage, res: ServerResponse) => {\n handleRequest(req, res, artifactsDir);\n },\n );\n\n return new Promise((resolve, reject) => {\n const onError = (err: Error): void => reject(err);\n server.on(\"error\", onError);\n\n server.listen(port, host, () => {\n // Get the actual port (supports port 0 = ephemeral).\n const address = server.address();\n const actualPort =\n typeof address === \"object\" && address ? address.port : port;\n\n // Keep the error listener active for errors after listen (e.g. ECONNRESET).\n server.removeListener(\"error\", onError);\n server.on(\"error\", () => {\n // Swallow post-listen errors — the server is already running.\n });\n\n resolve({\n port: actualPort,\n url: `http://${host}:${actualPort}`,\n close: () => {\n server.close((err) => {\n if (\n err &&\n (err as NodeJS.ErrnoException).code !== \"ERR_SERVER_NOT_RUNNING\"\n ) {\n // Ignore — server may already be closed.\n }\n });\n },\n });\n });\n });\n}\n\nfunction handleRequest(\n req: IncomingMessage,\n res: ServerResponse,\n artifactsDir: string,\n): void {\n const url = req.url ?? \"/\";\n\n if (url === \"/\" || url === \"/dashboard\") {\n serveDashboard(res, artifactsDir);\n return;\n }\n\n if (url.startsWith(\"/report/\")) {\n let filename: string;\n try {\n filename = decodeURIComponent(url.slice(\"/report/\".length));\n } catch {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid URL encoding\");\n return;\n }\n serveReport(res, artifactsDir, filename);\n return;\n }\n\n if (url === \"/health\") {\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify({ status: \"ok\" }));\n return;\n }\n\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"Not found\");\n}\n\n/** List SARIF files and render the dashboard page.\n * All user-controlled data (artifactsDir, filenames) is escaped via\n * escapeHtml() in renderDashboard. CSP blocks inline scripts as\n * defense-in-depth. */\nfunction serveDashboard(res: ServerResponse, artifactsDir: string): void {\n const files = listSarifFiles(artifactsDir);\n const html = renderDashboard(artifactsDir, files);\n res.writeHead(200, HTML_HEADERS);\n res.end(html); // CodeQL: stored XSS — escapeHtml + CSP mitigate\n}\n\n/** Engine run ids are `randomUUID()` values — a leading path segment in\n * this shape is the per-run artifact dir, not a step/pipeline name. */\nconst RUN_ID_SEGMENT =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Read a SARIF file, normalize findings, and render the HTML report. */\nfunction serveReport(\n res: ServerResponse,\n artifactsDir: string,\n filename: string,\n): void {\n // Validate the relative path — nested step directories are allowed\n // (artifacts are stored as <pipeline>/<step>/<file>.sarif) but\n // traversal, backslashes, and absolute paths are not.\n if (\n filename === \"\" ||\n filename.includes(\"..\") ||\n filename.includes(\"\\\\\") ||\n filename.startsWith(\"/\")\n ) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid filename\");\n return;\n }\n\n const filePath = join(artifactsDir, filename);\n\n // Resolve real paths and verify the file is inside artifactsDir.\n // This also prevents symlink-based escapes.\n try {\n const realArtifacts = realpathSync(artifactsDir);\n const realFile = realpathSync(filePath);\n if (\n !realFile.startsWith(realArtifacts + sep) &&\n realFile !== realArtifacts\n ) {\n res.writeHead(400, { \"content-type\": \"text/plain\" });\n res.end(\"Invalid filename\");\n return;\n }\n } catch {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"File not found\");\n return;\n }\n\n try {\n const raw = readFileSync(filePath, \"utf8\");\n const sarif = JSON.parse(raw) as SarifLog;\n // Mirror collectFindings: checkId is prefixed with the step directory\n // relative to the artifacts root (\"\" for top-level files). Engine\n // artifacts nest under <runId> (a UUID — collectFindings scans the\n // run dir as its root), so drop a leading run segment.\n const dir = dirname(filename).split(sep).join(\"/\");\n const dirSegments = dir.split(\"/\");\n const checkIdPrefix =\n dir === \".\"\n ? \"\"\n : dirSegments[0] !== undefined && RUN_ID_SEGMENT.test(dirSegments[0])\n ? dirSegments.slice(1).join(\"/\")\n : dir;\n const findings = normalizeSarif(sarif, {\n root: artifactsDir,\n checkIdPrefix,\n defaultConfidence: 0.5,\n });\n const html = generateSarifHtml(findings);\n res.writeHead(200, reportHeaders(html));\n res.end(html); // CodeQL: stored XSS — escapeHtml + hash-locked CSP mitigate\n } catch (e) {\n res.writeHead(500, { \"content-type\": \"text/plain\" });\n res.end(\n `Error rendering report: ${e instanceof Error ? e.message : String(e)}`,\n );\n }\n}\n\n/** Response headers for report pages. The generated report ships inline\n * <script> blocks for filter/search/sort — allow exactly those scripts by\n * SHA-256 hash instead of disabling them with script-src 'none'. */\nfunction reportHeaders(html: string): Record<string, string> {\n return {\n ...HTML_HEADERS,\n \"content-security-policy\": `default-src 'self'; script-src ${inlineScriptHashes(html)}; style-src 'unsafe-inline'`,\n };\n}\n\n/** Compute a CSP script-src source list of SHA-256 hashes for every inline\n * <script> block in the HTML. Falls back to 'none' when there are none. */\nfunction inlineScriptHashes(html: string): string {\n const hashes: string[] = [];\n for (const match of html.matchAll(\n /<script\\b[^>]*>([\\s\\S]*?)<\\/script[^>]*>/gi,\n )) {\n const digest = createHash(\"sha256\")\n .update(match[1] ?? \"\")\n .digest(\"base64\");\n hashes.push(`'sha256-${digest}'`);\n }\n return hashes.length > 0 ? hashes.join(\" \") : \"'none'\";\n}\n\n/** List .sarif and .sarif.json files under the artifacts directory,\n * recursively. Artifacts are stored as <pipeline>/<step>/<file>.sarif —\n * returned paths are relative to `dir` with forward slashes. */\nfunction listSarifFiles(dir: string): string[] {\n if (!existsSync(dir)) return [];\n const files: string[] = [];\n collectSarifFiles(dir, dir, files);\n return files.sort((a, b) => a.localeCompare(b));\n}\n\nfunction collectSarifFiles(root: string, dir: string, out: string[]): void {\n let entries: Dirent[];\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n return;\n }\n for (const entry of entries) {\n const entryPath = join(dir, entry.name);\n // Prevent traversal outside the artifacts root via ../ or links.\n const resolvedEntry = resolve(entryPath);\n if (resolvedEntry !== root && !resolvedEntry.startsWith(root + sep)) {\n continue;\n }\n if (entry.isDirectory()) {\n collectSarifFiles(root, entryPath, out);\n } else if (\n entry.name.endsWith(\".sarif\") ||\n entry.name.endsWith(\".sarif.json\")\n ) {\n out.push(relative(root, entryPath).split(sep).join(\"/\"));\n }\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sverka/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "Sverka UI — local web dashboard for viewing SARIF findings and run results",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"typecheck": "tsc --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@sverka/verification": "^0.2.
|
|
38
|
-
"@sverka/sarif-viewer-web": "^0.2.
|
|
37
|
+
"@sverka/verification": "^0.2.18",
|
|
38
|
+
"@sverka/sarif-viewer-web": "^0.2.18"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"tsdown": "^0.23.0",
|