fix(security): Remediate scanning and dependency alerts

Harden template and example code paths, redact sensitive output, and pin safe transitive npm packages. Consolidate the todo backend on better-sqlite3 so the example no longer pulls the vulnerable sqlite3 chain and still passes build and CRUD smoke checks.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sickn33
2026-03-18 18:05:45 +01:00
parent d2c593e719
commit 3b6ef3add8
21 changed files with 711 additions and 2821 deletions

View File

@@ -5,6 +5,9 @@
name: Publish to npm
permissions:
contents: read
on:
release:
types: [published]

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,9 @@
"tailwindcss": "^4.2.0",
"typescript": "^5.4.0",
"vite": "^7.3.1",
"vitest": "^2.0.0"
"vitest": "^3.2.4"
},
"overrides": {
"flatted": "^3.4.0"
}
}

View File

@@ -1081,6 +1081,7 @@ def run_audit(
inj_report: dict = {"findings": [], "score": 100, "total_findings": 0}
quick_report: dict = {"findings": [], "score": 100, "total_findings": 0}
all_findings: list[dict] = []
report_findings: list[dict] = []
if need_scanners:
logger.info("Running scanners for phases %s...", [p for p in phases_list if p >= 3])
@@ -1121,6 +1122,7 @@ def run_audit(
+ quick_report.get("findings", [])
)
all_findings = score_calculator._deduplicate_findings(raw)
report_findings = score_calculator.redact_findings_for_report(all_findings)
# ------------------------------------------------------------------
# Collect source files if needed for phase 6
@@ -1142,7 +1144,7 @@ def run_audit(
if 2 in phases_list:
# Phase 2 benefits from phase 1 data and findings
surface = phases_data.get("phase1") or _phase1_surface_mapping(target, verbose=verbose)
phases_data["phase2"] = _phase2_threat_modeling_hints(surface, all_findings)
phases_data["phase2"] = _phase2_threat_modeling_hints(surface, report_findings)
if 3 in phases_list:
phases_data["phase3"] = _phase3_security_checklist(
@@ -1164,10 +1166,10 @@ def run_audit(
)
if 4 in phases_list:
phases_data["phase4"] = _phase4_red_team_scenarios(all_findings, auth_score)
phases_data["phase4"] = _phase4_red_team_scenarios(report_findings, auth_score)
if 5 in phases_list:
phases_data["phase5"] = _phase5_blue_team_recommendations(all_findings, auth_score)
phases_data["phase5"] = _phase5_blue_team_recommendations(report_findings, auth_score)
if 6 in phases_list:
phases_data["phase6"] = _phase6_verdict(
@@ -1227,7 +1229,7 @@ def run_audit(
"phases_run": phases_list,
"phases": phases_data,
"total_findings": len(all_findings),
"findings": all_findings,
"findings": report_findings,
"report_path": str(report_path),
}

View File

@@ -64,6 +64,17 @@ import quick_scan # noqa: E402
# ---------------------------------------------------------------------------
logger = setup_logging("007-score-calculator")
_SENSITIVE_FINDING_KEYS = {
"snippet",
"secret",
"token",
"password",
"access_token",
"app_secret",
"authorization_code",
"client_secret",
}
# ---------------------------------------------------------------------------
# Positive-signal patterns (auth, encryption, resilience, monitoring)
@@ -360,6 +371,38 @@ def _bar(score: float, width: int = 20) -> str:
return "[" + "#" * filled + "." * (width - filled) + "]"
def _redact_report_value(value):
"""Recursively redact sensitive values from report payloads."""
if isinstance(value, dict):
return {key: _redact_report_value(value[key]) for key in value}
if isinstance(value, list):
return [_redact_report_value(item) for item in value]
return value
def redact_findings_for_report(findings: list[dict]) -> list[dict]:
"""Return findings safe to serialize in user-facing reports."""
redacted: list[dict] = []
for finding in findings:
safe_finding: dict = {}
finding_type = str(finding.get("type", "")).lower()
for key, value in finding.items():
key_lower = key.lower()
if key_lower in _SENSITIVE_FINDING_KEYS:
safe_finding[key] = "[redacted]"
continue
if finding_type == "secret" and key_lower in {"entropy", "match", "raw", "value"}:
safe_finding[key] = "[redacted]"
continue
safe_finding[key] = _redact_report_value(value)
redacted.append(safe_finding)
return redacted
def format_text_report(
target: str,
domain_scores: dict[str, float],
@@ -430,6 +473,7 @@ def build_json_report(
elapsed: float,
) -> dict:
"""Build a structured JSON report."""
safe_findings = redact_findings_for_report(all_findings)
return {
"report": "score_calculator",
"target": target,
@@ -444,7 +488,7 @@ def build_json_report(
"emoji": verdict["emoji"],
},
"scanner_summaries": scanner_summaries,
"findings": all_findings,
"findings": safe_findings,
}

View File

@@ -20,7 +20,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Generative Art Viewer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js" integrity="sha512-bcfltY+lNLlNxz38yBBm/HLaUB1gTV6I0e+fahbF9pS6roIdzUytozWdnFV8ZnM6cSAG5EbmO0ag0a/fLZSG4Q==" crossorigin="anonymous"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
@@ -596,4 +596,4 @@
});
</script>
</body>
</html>
</html>

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import argparse
import asyncio
import html
import json
import os
import sys
@@ -47,6 +48,15 @@ db = Database()
db.init()
def _mask_secret(value: str, keep: int = 4) -> str:
"""Mask secret-like values before showing them in terminal output."""
if not value:
return "(hidden)"
if len(value) <= keep:
return "*" * len(value)
return f"{value[:keep]}...masked"
# ── OAuth Callback Server ────────────────────────────────────────────────────
class OAuthCallbackHandler(BaseHTTPRequestHandler):
@@ -71,7 +81,8 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
self.send_response(400)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(f"<html><body><h2>Erro: {error}</h2></body></html>".encode())
safe_error = html.escape(error, quote=True)
self.wfile.write(f"<html><body><h2>Erro: {safe_error}</h2></body></html>".encode())
else:
self.send_response(404)
self.end_headers()
@@ -285,10 +296,9 @@ async def setup() -> None:
f"response_type=code"
)
print(f"\nAbrindo browser para autorização...")
# Mask client_id in auth URL to avoid logging credentials
masked_url = auth_url.replace(app_id, app_id[:4] + "...masked") if app_id else auth_url
print(f"URL: {masked_url}\n")
print("\nAbrindo browser para autorização...")
print(f"App ID em uso: {_mask_secret(app_id)}")
print("A URL de autorização não será exibida para evitar vazamento de credenciais.\n")
webbrowser.open(auth_url)
# Esperar callback

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,20 @@
"dev": "ts-node src/index.ts"
},
"dependencies": {
"better-sqlite3": "^9.0.0",
"better-sqlite3": "^12.8.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"sqlite3": "^5.1.7"
"express": "^4.18.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.20",
"@types/node": "^20.10.0",
"@types/sqlite3": "^3.1.11",
"ts-node": "^10.9.1",
"typescript": "^5.3.0"
},
"overrides": {
"diff": "4.0.4",
"qs": "^6.15.0"
}
}

View File

@@ -1,35 +0,0 @@
import sqlite3 from 'sqlite3';
import path from 'path';
const dbPath = path.join(__dirname, '../../todos.db');
const db = new sqlite3.Database(dbPath, (err: Error | null) => {
if (err) {
console.error('Database connection error:', err);
} else {
console.log('Connected to SQLite database');
}
});
// Initialize database schema
export const initDatabase = (): Promise<void> => {
return new Promise((resolve, reject) => {
db.run(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT 0,
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
)
`, (err: Error | null) => {
if (err) {
reject(err);
} else {
console.log('Database schema initialized');
resolve();
}
});
});
};
export default db;

View File

@@ -2,12 +2,25 @@ import { getDatabase } from './database';
import fs from 'fs';
import path from 'path';
const schemaPath = path.join(__dirname, './schema.sql');
function resolveSchemaPath(): string {
const candidates = [
path.join(__dirname, 'schema.sql'),
path.join(__dirname, '../../src/db/schema.sql'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
throw new Error(`Unable to locate schema.sql. Checked: ${candidates.join(', ')}`);
}
export function runMigrations(): void {
try {
const db = getDatabase();
const schema = fs.readFileSync(schemaPath, 'utf-8');
const schema = fs.readFileSync(resolveSchemaPath(), 'utf-8');
// Execute the schema SQL
db.exec(schema);
@@ -28,4 +41,3 @@ export function initializeDatabase(): void {
throw error;
}
}

View File

@@ -1,27 +1,60 @@
import { Router, Request, Response } from 'express';
import db from '../db/db';
import { getDatabase } from '../db';
import { ApiResponse, Todo } from '../types/index';
const router = Router();
const WINDOW_MS = 60_000;
const MAX_REQUESTS_PER_WINDOW = 60;
const requestCounts = new Map<string, { count: number; resetAt: number }>();
const db = getDatabase();
type TodoRow = Omit<Todo, 'completed'> & { completed: number };
function toTodo(row: TodoRow): Todo {
return {
...row,
completed: Boolean(row.completed),
};
}
function rateLimit(req: Request, res: Response, next: () => void): void {
const now = Date.now();
const clientKey = req.ip || 'unknown';
const entry = requestCounts.get(clientKey);
if (!entry || entry.resetAt <= now) {
requestCounts.set(clientKey, { count: 1, resetAt: now + WINDOW_MS });
next();
return;
}
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
res.status(429).json({ error: 'Too many requests. Please retry later.' });
return;
}
entry.count += 1;
next();
}
router.use(rateLimit);
// GET /api/todos - Retrieve all todos
router.get('/todos', (_req: Request, res: Response): void => {
db.all('SELECT * FROM todos ORDER BY createdAt DESC', (err: any, rows: Todo[]) => {
if (err) {
const errorResponse: ApiResponse<null> = {
success: false,
error: 'Database error',
};
res.status(500).json(errorResponse);
return;
}
try {
const rows = db.prepare('SELECT * FROM todos ORDER BY createdAt DESC').all() as TodoRow[];
const successResponse: ApiResponse<Todo[]> = {
success: true,
data: rows || [],
data: rows.map(toTodo),
};
res.json(successResponse);
});
} catch {
const errorResponse: ApiResponse<null> = {
success: false,
error: 'Database error',
};
res.status(500).json(errorResponse);
}
});
// POST /api/todos - Create new todo
@@ -37,30 +70,28 @@ router.post('/todos', (req: Request, res: Response): void => {
const trimmedTitle = title.trim();
const now = new Date().toISOString();
db.run(
'INSERT INTO todos (title, completed, createdAt, updatedAt) VALUES (?, ?, ?, ?)',
[trimmedTitle, 0, now, now],
function(this: any, err: Error | null) {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
try {
const insertResult = db
.prepare('INSERT INTO todos (title, completed, createdAt, updatedAt) VALUES (?, ?, ?, ?)')
.run(trimmedTitle, 0, now, now);
const row = db
.prepare('SELECT * FROM todos WHERE id = ?')
.get(insertResult.lastInsertRowid) as TodoRow | undefined;
// Return created todo
db.get('SELECT * FROM todos WHERE id = ?', [this.lastID], (err: any, row: Todo) => {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
const successResponse: ApiResponse<Todo> = {
success: true,
data: row,
};
res.status(201).json(successResponse);
});
if (!row) {
res.status(500).json({ error: 'Database error' });
return;
}
);
const successResponse: ApiResponse<Todo> = {
success: true,
data: toTodo(row),
};
res.status(201).json(successResponse);
} catch (error) {
const details = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: 'Database error', details });
}
});
// PATCH /api/todos/:id - Update todo completion status
@@ -74,45 +105,35 @@ router.patch('/todos/:id', (req: Request, res: Response): void => {
return;
}
// Check if todo exists
db.get('SELECT * FROM todos WHERE id = ?', [id], (err: any, row: Todo) => {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
try {
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(id) as TodoRow | undefined;
if (!row) {
res.status(404).json({ error: 'Todo not found' });
return;
}
const now = new Date().toISOString();
// Update todo
db.run(
'UPDATE todos SET completed = ?, updatedAt = ? WHERE id = ?',
[completed ? 1 : 0, now, id],
function(err: Error | null) {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
// Return updated todo
db.get('SELECT * FROM todos WHERE id = ?', [id], (err: any, updatedRow: Todo) => {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
const successResponse: ApiResponse<Todo> = {
success: true,
data: updatedRow,
};
res.json(successResponse);
});
}
db.prepare('UPDATE todos SET completed = ?, updatedAt = ? WHERE id = ?').run(
completed ? 1 : 0,
now,
id
);
});
const updatedRow = db.prepare('SELECT * FROM todos WHERE id = ?').get(id) as TodoRow | undefined;
if (!updatedRow) {
res.status(500).json({ error: 'Database error' });
return;
}
const successResponse: ApiResponse<Todo> = {
success: true,
data: toTodo(updatedRow),
};
res.json(successResponse);
} catch (error) {
const details = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: 'Database error', details });
}
});
// DELETE /api/todos/:id - Delete todo by id
@@ -125,31 +146,19 @@ router.delete('/todos/:id', (req: Request, res: Response): void => {
return;
}
// Check if todo exists
db.get('SELECT * FROM todos WHERE id = ?', [id], (err: any, row: Todo) => {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
try {
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(id) as TodoRow | undefined;
if (!row) {
res.status(404).json({ error: 'Todo not found' });
return;
}
// Delete todo
db.run(
'DELETE FROM todos WHERE id = ?',
[id],
function(err: Error | null) {
if (err) {
res.status(500).json({ error: 'Database error', details: err.message });
return;
}
res.json({ message: 'Todo deleted successfully' });
}
);
});
db.prepare('DELETE FROM todos WHERE id = ?').run(id);
res.json({ message: 'Todo deleted successfully' });
} catch (error) {
const details = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: 'Database error', details });
}
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,14 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react-swc": "^3.11.0",
"typescript": "^5.9.3",
"vite": "^6.4.1"
"vite": "^7.1.12"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"overrides": {
"rollup": "^4.59.0"
}
}

View File

@@ -18,6 +18,7 @@ import re
import sys
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.parse import urlparse
from patchright.sync_api import sync_playwright, BrowserContext
@@ -28,6 +29,19 @@ from config import BROWSER_STATE_DIR, STATE_FILE, AUTH_INFO_FILE, DATA_DIR
from browser_utils import BrowserFactory
def _get_hostname(url: str) -> str:
"""Extract a normalized hostname from a URL."""
try:
return (urlparse(url).hostname or "").lower()
except ValueError:
return ""
def _is_exact_host(url: str, expected_host: str) -> bool:
"""Return True when the URL hostname exactly matches the expected host."""
return _get_hostname(url) == expected_host
class AuthManager:
"""
Manages authentication and browser state for NotebookLM
@@ -114,7 +128,7 @@ class AuthManager:
page.goto("https://notebooklm.google.com", wait_until="domcontentloaded")
# Check if already authenticated
if "notebooklm.google.com" in page.url and "accounts.google.com" not in page.url:
if _is_exact_host(page.url, "notebooklm.google.com"):
print(" ✅ Already authenticated!")
self._save_browser_state(context)
return True
@@ -260,7 +274,7 @@ class AuthManager:
page.goto("https://notebooklm.google.com", wait_until="domcontentloaded", timeout=30000)
# Check if we can access NotebookLM
if "notebooklm.google.com" in page.url and "accounts.google.com" not in page.url:
if _is_exact_host(page.url, "notebooklm.google.com"):
print(" ✅ Authentication is valid")
return True
else:
@@ -355,4 +369,4 @@ def main():
if __name__ == "__main__":
main()
main()

View File

@@ -9,6 +9,7 @@ import time
import sys
from typing import Any, Dict, Optional
from pathlib import Path
from urllib.parse import urlparse
from patchright.sync_api import BrowserContext, Page
@@ -18,6 +19,14 @@ sys.path.insert(0, str(Path(__file__).parent))
from browser_utils import StealthUtils
def _get_hostname(url: str) -> str:
"""Extract a normalized hostname from a URL."""
try:
return (urlparse(url).hostname or "").lower()
except ValueError:
return ""
class BrowserSession:
"""
Represents a single persistent browser session for NotebookLM
@@ -61,7 +70,7 @@ class BrowserSession:
self.page.goto(self.notebook_url, wait_until="domcontentloaded", timeout=30000)
# Check if login is needed
if "accounts.google.com" in self.page.url:
if _get_hostname(self.page.url) == "accounts.google.com":
raise RuntimeError("Authentication required. Please run auth_manager.py setup first.")
# Wait for page to be ready
@@ -252,4 +261,4 @@ class BrowserSession:
if __name__ == "__main__":
# Example usage
print("Browser Session Module - Use ask_question.py for main interface")
print("This module provides low-level browser session management.")
print("This module provides low-level browser session management.")

View File

@@ -2,6 +2,8 @@ import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
import { WebhookPayload, IncomingMessage, StatusUpdate } from './types';
const SAFE_CHALLENGE_RE = /^[A-Za-z0-9._-]{1,200}$/;
/**
* Middleware para validar assinatura HMAC-SHA256 dos webhooks do WhatsApp.
*
@@ -66,12 +68,12 @@ export function handleWebhookVerification(verifyToken: string) {
const token = req.query['hub.verify_token'] as string;
const challenge = req.query['hub.challenge'] as string;
if (mode === 'subscribe' && token === verifyToken) {
if (mode === 'subscribe' && token === verifyToken && SAFE_CHALLENGE_RE.test(challenge)) {
console.log('Webhook verified successfully');
res.status(200).send(challenge);
res.type('text/plain').status(200).send(challenge);
} else {
console.warn('Webhook verification failed: invalid token');
res.sendStatus(403);
res.status(mode === 'subscribe' && token === verifyToken ? 400 : 403).send();
}
};
}

View File

@@ -1,6 +1,7 @@
"""WhatsApp Cloud API - Flask Application with Webhook Handler."""
import asyncio
import logging
import os
from dotenv import load_dotenv
@@ -17,11 +18,17 @@ from webhook_handler import (
load_dotenv()
app = Flask(__name__)
logger = logging.getLogger(__name__)
# Initialize WhatsApp client
whatsapp = WhatsAppClient()
def _is_debug_enabled() -> bool:
"""Allow debug only when explicitly enabled for local development."""
return os.environ.get("FLASK_DEBUG", "").lower() in {"1", "true", "yes", "on"}
# === Webhook Routes ===
@@ -60,22 +67,21 @@ async def handle_incoming_message(message: dict) -> None:
from_number = message["from"]
content = extract_message_content(message)
print(f"Message from {from_number}: [{content['type']}] {content.get('text', '')}")
logger.info("Received WhatsApp message type=%s message_id=%s", content["type"], message["id"])
# Mark as read
await whatsapp.mark_as_read(message["id"])
# TODO: Implement your message handling logic here
# Example: Echo back the message
# Example responses intentionally avoid reflecting user-provided content.
match content["type"]:
case "text":
await whatsapp.send_text(from_number, f"Recebi sua mensagem: \"{content['text']}\"")
await whatsapp.send_text(from_number, "Recebi sua mensagem. Como posso ajudar?")
case "button":
await whatsapp.send_text(from_number, f"Voce selecionou: {content['text']}")
await whatsapp.send_text(from_number, "Recebi sua selecao com sucesso.")
case "list":
await whatsapp.send_text(from_number, f"Voce escolheu: {content['text']}")
await whatsapp.send_text(from_number, "Recebi sua escolha com sucesso.")
case "image" | "document" | "video" | "audio":
await whatsapp.send_text(from_number, f"Recebi sua midia ({content['type']}).")
@@ -89,11 +95,11 @@ async def handle_incoming_message(message: dict) -> None:
def handle_status_update(status: dict) -> None:
"""Process a message status update."""
print(f"Status update: {status['id']} -> {status['status']}")
logger.info("WhatsApp status update id=%s status=%s", status["id"], status["status"])
if status["status"] == "failed":
errors = status.get("errors", [])
print(f"Message delivery failed: {errors}")
logger.warning("WhatsApp message delivery failed with %d error(s)", len(errors))
# === Health Check ===
@@ -109,7 +115,9 @@ def health():
if __name__ == "__main__":
port = int(os.environ.get("PORT", 3000))
print(f"WhatsApp webhook server running on port {port}")
print(f"Webhook URL: http://localhost:{port}/webhook")
print(f"Health check: http://localhost:{port}/health")
app.run(host="0.0.0.0", port=port, debug=True)
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
logger.info("WhatsApp webhook server running on port %s", port)
logger.info("Webhook URL: http://localhost:%s/webhook", port)
logger.info("Health check: http://localhost:%s/health", port)
# Keep debug disabled by default so the boilerplate is safe in shared environments.
app.run(host="0.0.0.0", port=port, debug=_is_debug_enabled())

View File

@@ -3,10 +3,14 @@
import hashlib
import hmac
import os
import re
from functools import wraps
from typing import Any
from flask import Request, abort, request
from flask import Response, abort, request
_SAFE_CHALLENGE_RE = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
def validate_hmac_signature(app_secret: str | None = None):
@@ -52,11 +56,14 @@ def verify_webhook(verify_token: str | None = None):
req_token = request.args.get("hub.verify_token")
challenge = request.args.get("hub.challenge")
if mode == "subscribe" and req_token == token:
return challenge, 200
else:
if mode != "subscribe" or req_token != token:
abort(403, "Verification failed")
if not challenge or not _SAFE_CHALLENGE_RE.fullmatch(challenge):
abort(400, "Invalid challenge")
return Response(challenge, status=200, mimetype="text/plain")
def parse_webhook_payload(data: dict[str, Any]) -> dict[str, list]:
"""

View File

@@ -573,6 +573,13 @@ function truncate(value, limit) {
return `${value.slice(0, limit - 3)}...`;
}
function escapeMarkdownTableCell(value) {
return String(value || "")
.replace(/\\/g, "\\\\")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, " ");
}
function renderCatalogMarkdown(catalog) {
const lines = [];
lines.push("# Skill Catalog");
@@ -595,12 +602,9 @@ function renderCatalogMarkdown(catalog) {
lines.push("| --- | --- | --- | --- |");
for (const skill of grouped) {
const description = truncate(skill.description, 160).replace(
/\|/g,
"\\|",
);
const tags = skill.tags.join(", ");
const triggers = skill.triggers.join(", ");
const description = escapeMarkdownTableCell(truncate(skill.description, 160));
const tags = escapeMarkdownTableCell(skill.tags.join(", "));
const triggers = escapeMarkdownTableCell(skill.triggers.join(", "));
lines.push(
`| \`${skill.id}\` | ${description} | ${tags} | ${triggers} |`,
);

View File

@@ -129,8 +129,8 @@ def extract_markdown_from_html(html_content: str) -> Optional[str]:
def convert_html_to_markdown(html: str) -> str:
"""Basic HTML to markdown conversion."""
# Remove scripts and styles
html = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r'<style[^>]*>.*?</style>', '', html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r'<script\b[^>]*>.*?</script\s*>', '', html, flags=re.DOTALL | re.IGNORECASE)
html = re.sub(r'<style\b[^>]*>.*?</style\s*>', '', html, flags=re.DOTALL | re.IGNORECASE)
# Headings
html = re.sub(r'<h1[^>]*>(.*?)</h1>', r'# \1', html, flags=re.DOTALL | re.IGNORECASE)