Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
245
extensions/security-report.ts
Normal file
245
extensions/security-report.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// ABOUTME: Dedicated browser viewer for network/security analysis reports.
|
||||
// ABOUTME: Renders structured defensive security assessments with findings, mitigations, and source sections.
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
import { outputLine } from "./lib/output-box.ts";
|
||||
import { applyExtensionDefaults } from "./lib/themeMap.ts";
|
||||
import { generateSecurityReportHTML, type SecurityReportData, type SecurityReportFinding } from "./lib/security-report-html.ts";
|
||||
import { upsertPersistedReport } from "./lib/report-index.ts";
|
||||
import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts";
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
try { execSync(`open \"${url}\"`, { stdio: "ignore" }); } catch {
|
||||
try { execSync(`xdg-open \"${url}\"`, { stdio: "ignore" }); } catch {
|
||||
try { execSync(`start \"${url}\"`, { stdio: "ignore" }); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseList(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(/\r?\n|;/).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseFindings(markdown: string): SecurityReportFinding[] {
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
const findings: SecurityReportFinding[] = [];
|
||||
let current: SecurityReportFinding | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const findingMatch = line.match(/^[-*]\s+\[(critical|high|medium|low|info)\]\s+(.+)$/i);
|
||||
if (findingMatch) {
|
||||
if (current) findings.push(current);
|
||||
current = {
|
||||
severity: findingMatch[1].toLowerCase() as SecurityReportFinding["severity"],
|
||||
title: findingMatch[2].trim(),
|
||||
category: "general",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
const categoryMatch = line.match(/^\s*category:\s*(.+)$/i);
|
||||
if (categoryMatch) {
|
||||
current.category = categoryMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const evidenceMatch = line.match(/^\s*evidence:\s*(.+)$/i);
|
||||
if (evidenceMatch) {
|
||||
current.evidence = evidenceMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const recMatch = line.match(/^\s*recommendation:\s*(.+)$/i);
|
||||
if (recMatch) {
|
||||
current.recommendation = recMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) findings.push(current);
|
||||
return findings;
|
||||
}
|
||||
|
||||
function startServer(report: SecurityReportData): Promise<{ port: number; server: Server; waitForClose: () => Promise<void> }> {
|
||||
return new Promise((resolveSetup) => {
|
||||
let resolveResult!: () => void;
|
||||
const resultPromise = new Promise<void>((resolve) => { resolveResult = resolve; });
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(generateSecurityReportHTML(report));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/logo.png") {
|
||||
try {
|
||||
const logoPath = join(dirname(fileURLToPath(import.meta.url)), "assets", "agent-logo.png");
|
||||
const logo = readFileSync(logoPath);
|
||||
res.writeHead(200, { "Content-Type": "image/png" });
|
||||
res.end(logo);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/save") {
|
||||
const desktop = join(homedir(), "Desktop");
|
||||
if (!existsSync(desktop)) mkdirSync(desktop, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const filePath = join(desktop, `security-report-${ts}.html`);
|
||||
writeFileSync(filePath, generateSecurityReportHTML(report), "utf-8");
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, path: filePath }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/result") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
resolveResult();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.on("close", () => resolveResult());
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as any;
|
||||
resolveSetup({ port: addr.port, server, waitForClose: () => resultPromise });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let activeServer: Server | null = null;
|
||||
let activeSession: { kind: "report"; title: string; url: string; server: Server; onClose: () => void } | null = null;
|
||||
|
||||
function cleanup() {
|
||||
if (activeServer) {
|
||||
try { activeServer.close(); } catch {}
|
||||
activeServer = null;
|
||||
}
|
||||
if (activeSession) {
|
||||
clearActiveViewer(activeSession);
|
||||
activeSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
pi.registerTool({
|
||||
name: "show_security_report",
|
||||
label: "Show Security Report",
|
||||
description: "Open a dedicated security analysis report viewer for defensive local/network assessments. Supports a summary, findings, mitigations, and sections for intelligence, inspection, and scan results.",
|
||||
parameters: Type.Object({
|
||||
title: Type.Optional(Type.String({ description: "Report title" })),
|
||||
summary: Type.String({ description: "Executive summary for the report" }),
|
||||
scope: Type.Optional(Type.String({ description: "Scope of the assessment" })),
|
||||
findings_markdown: Type.Optional(Type.String({ description: "Structured findings in markdown bullets like '- [high] Open service exposure' with optional category/evidence/recommendation lines." })),
|
||||
mitigations: Type.Optional(Type.String({ description: "Mitigation list separated by newlines or semicolons" })),
|
||||
intelligence: Type.Optional(Type.String({ description: "Threat intelligence section text" })),
|
||||
inspection: Type.Optional(Type.String({ description: "Passive inspection section text" })),
|
||||
scan: Type.Optional(Type.String({ description: "Port analysis section text" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const p = params as any;
|
||||
const report: SecurityReportData = {
|
||||
title: p.title || "Security Analysis Report",
|
||||
summary: p.summary,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: p.scope,
|
||||
intelligence: p.intelligence,
|
||||
inspection: p.inspection,
|
||||
scan: p.scan,
|
||||
findings: parseFindings(p.findings_markdown || ""),
|
||||
mitigations: parseList(p.mitigations),
|
||||
};
|
||||
|
||||
cleanup();
|
||||
const { port, server, waitForClose } = await startServer(report);
|
||||
activeServer = server;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
activeSession = {
|
||||
kind: "report",
|
||||
title: report.title,
|
||||
url,
|
||||
server,
|
||||
onClose: () => {
|
||||
activeServer = null;
|
||||
activeSession = null;
|
||||
},
|
||||
};
|
||||
registerActiveViewer(activeSession);
|
||||
openBrowser(url);
|
||||
notifyViewerOpen(ctx, activeSession);
|
||||
|
||||
try {
|
||||
await waitForClose();
|
||||
try {
|
||||
upsertPersistedReport({
|
||||
category: "completion",
|
||||
title: report.title,
|
||||
summary: report.summary,
|
||||
sourcePath: join(ctx.cwd || process.cwd(), ".context", "network-security-chain-design.md"),
|
||||
viewerPath: join(ctx.cwd || process.cwd(), ".context", "network-security-chain-design.md"),
|
||||
viewerLabel: report.title,
|
||||
tags: ["security", "report", "network"],
|
||||
metadata: {
|
||||
scope: report.scope,
|
||||
findings: report.findings.length,
|
||||
mitigations: report.mitigations.length,
|
||||
},
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Security analysis report closed." }],
|
||||
details: { findings: report.findings.length, mitigations: report.mitigations.length },
|
||||
};
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
renderCall(args, theme) {
|
||||
const p = args as any;
|
||||
const text = theme.fg("toolTitle", theme.bold("show_security_report ")) + theme.fg("accent", p.title || "Security Analysis Report");
|
||||
return new Text(outputLine(theme, "accent", text), 0, 0);
|
||||
},
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as any;
|
||||
return new Text(outputLine(theme, "success", `Security report closed — ${details?.findings ?? 0} findings`), 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user