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:
3
.github/workflows/publish-npm.yml
vendored
3
.github/workflows/publish-npm.yml
vendored
@@ -5,6 +5,9 @@
|
||||
|
||||
name: Publish to npm
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
1268
apps/web-app/package-lock.json
generated
1268
apps/web-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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} |`,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user