362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
// ABOUTME: Curated security news/advisory retrieval for trusted sources like CISA, NVD, OWASP, and CVE.
|
|
// ABOUTME: Registers a security_news tool that returns trust-ranked, freshness-aware advisory data.
|
|
|
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { Text } from "@mariozechner/pi-tui";
|
|
|
|
const SOURCE_IDS = ["cisa", "owasp", "nvd", "cve"] as const;
|
|
type SourceId = typeof SOURCE_IDS[number];
|
|
|
|
type SecurityNewsAction = "sources" | "latest" | "search" | "cve_lookup";
|
|
|
|
interface SecuritySource {
|
|
id: SourceId;
|
|
name: string;
|
|
tier: 1 | 2;
|
|
trustScore: number;
|
|
category: string;
|
|
description: string;
|
|
homepage: string;
|
|
fetchLatest?: (query?: string) => Promise<SecurityNewsItem[]>;
|
|
lookupCve?: (cveId: string) => Promise<SecurityNewsItem[]>;
|
|
}
|
|
|
|
interface SecurityNewsItem {
|
|
title: string;
|
|
summary: string;
|
|
url: string;
|
|
source: SourceId;
|
|
sourceName: string;
|
|
category: string;
|
|
publishedAt?: string;
|
|
trustScore: number;
|
|
tags: string[];
|
|
cveIds?: string[];
|
|
}
|
|
|
|
const CISA_KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
|
|
const NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0";
|
|
const OWASP_NEWS_URL = "https://owasp.org/www-project-top-ten/";
|
|
const CVE_API_URL = "https://cveawg.mitre.org/api/cve/";
|
|
|
|
function normalizeText(value: unknown): string {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function safeArray<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? value as T[] : [];
|
|
}
|
|
|
|
function containsQuery(item: SecurityNewsItem, query?: string): boolean {
|
|
if (!query) return true;
|
|
const haystack = [item.title, item.summary, item.tags.join(" "), ...(item.cveIds || [])].join(" ").toLowerCase();
|
|
return query.toLowerCase().split(/\s+/).filter(Boolean).every((term) => haystack.includes(term));
|
|
}
|
|
|
|
function dedupeItems(items: SecurityNewsItem[]): SecurityNewsItem[] {
|
|
const seen = new Set<string>();
|
|
return items.filter((item) => {
|
|
const key = `${item.source}:${item.url}:${(item.cveIds || []).join(",")}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function extractCveIds(...values: string[]): string[] {
|
|
const matches = new Set<string>();
|
|
for (const value of values) {
|
|
const found = value.match(/CVE-\d{4}-\d{4,7}/gi) || [];
|
|
for (const id of found) matches.add(id.toUpperCase());
|
|
}
|
|
return [...matches];
|
|
}
|
|
|
|
async function fetchJson(url: string): Promise<any> {
|
|
const resp = await fetch(url, {
|
|
headers: {
|
|
"User-Agent": "pi-agent-security-news/1.0",
|
|
"Accept": "application/json, text/plain;q=0.9, */*;q=0.8",
|
|
},
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Fetch failed (${resp.status}) for ${url}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
async function fetchText(url: string): Promise<string> {
|
|
const resp = await fetch(url, {
|
|
headers: {
|
|
"User-Agent": "pi-agent-security-news/1.0",
|
|
"Accept": "text/html, text/plain;q=0.9, */*;q=0.8",
|
|
},
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Fetch failed (${resp.status}) for ${url}`);
|
|
}
|
|
return resp.text();
|
|
}
|
|
|
|
async function fetchCisaKev(query?: string): Promise<SecurityNewsItem[]> {
|
|
const data = await fetchJson(CISA_KEV_URL);
|
|
const vulns = safeArray<any>(data?.vulnerabilities).slice(0, 50);
|
|
return vulns
|
|
.map((item) => {
|
|
const cveId = normalizeText(item.cveID).toUpperCase();
|
|
const title = `${cveId} — ${normalizeText(item.vulnerabilityName) || "Known Exploited Vulnerability"}`;
|
|
const summary = [
|
|
normalizeText(item.vendorProject),
|
|
normalizeText(item.product),
|
|
normalizeText(item.shortDescription),
|
|
normalizeText(item.requiredAction) ? `Required action: ${normalizeText(item.requiredAction)}` : "",
|
|
].filter(Boolean).join(" | ");
|
|
return {
|
|
title,
|
|
summary,
|
|
url: "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
|
source: "cisa" as const,
|
|
sourceName: "CISA KEV",
|
|
category: "known-exploited-vulnerability",
|
|
publishedAt: normalizeText(item.dateAdded),
|
|
trustScore: 10,
|
|
tags: ["cisa", "kev", "vulnerability", "advisory"],
|
|
cveIds: cveId ? [cveId] : [],
|
|
} satisfies SecurityNewsItem;
|
|
})
|
|
.filter((item) => containsQuery(item, query));
|
|
}
|
|
|
|
async function fetchNvdLatest(query?: string): Promise<SecurityNewsItem[]> {
|
|
const data = await fetchJson(`${NVD_API_URL}?resultsPerPage=20`);
|
|
const vulns = safeArray<any>(data?.vulnerabilities);
|
|
return vulns.map((entry) => {
|
|
const cve = entry?.cve || {};
|
|
const cveId = normalizeText(cve.id).toUpperCase();
|
|
const descriptions = safeArray<any>(cve.descriptions);
|
|
const desc = descriptions.find((d) => d?.lang === "en")?.value || descriptions[0]?.value || "";
|
|
return {
|
|
title: `${cveId} — ${desc.slice(0, 120) || "NVD Advisory"}`,
|
|
summary: normalizeText(desc),
|
|
url: cveId ? `https://nvd.nist.gov/vuln/detail/${cveId}` : "https://nvd.nist.gov/",
|
|
source: "nvd" as const,
|
|
sourceName: "NVD",
|
|
category: "cve",
|
|
publishedAt: normalizeText(cve.published),
|
|
trustScore: 10,
|
|
tags: ["nvd", "cve", "vulnerability"],
|
|
cveIds: cveId ? [cveId] : [],
|
|
} satisfies SecurityNewsItem;
|
|
}).filter((item) => containsQuery(item, query));
|
|
}
|
|
|
|
async function fetchNvdByCve(cveId: string): Promise<SecurityNewsItem[]> {
|
|
const data = await fetchJson(`${NVD_API_URL}?cveId=${encodeURIComponent(cveId)}`);
|
|
const vulns = safeArray<any>(data?.vulnerabilities);
|
|
return vulns.map((entry) => {
|
|
const cve = entry?.cve || {};
|
|
const descriptions = safeArray<any>(cve.descriptions);
|
|
const desc = descriptions.find((d) => d?.lang === "en")?.value || descriptions[0]?.value || "";
|
|
return {
|
|
title: `${cveId.toUpperCase()} — ${desc.slice(0, 120) || "NVD Advisory"}`,
|
|
summary: normalizeText(desc),
|
|
url: `https://nvd.nist.gov/vuln/detail/${cveId.toUpperCase()}`,
|
|
source: "nvd" as const,
|
|
sourceName: "NVD",
|
|
category: "cve",
|
|
publishedAt: normalizeText(cve.published),
|
|
trustScore: 10,
|
|
tags: ["nvd", "cve", "vulnerability"],
|
|
cveIds: [cveId.toUpperCase()],
|
|
} satisfies SecurityNewsItem;
|
|
});
|
|
}
|
|
|
|
async function fetchCveById(cveId: string): Promise<SecurityNewsItem[]> {
|
|
const data = await fetchJson(`${CVE_API_URL}${encodeURIComponent(cveId)}`);
|
|
const title = normalizeText(data?.cveMetadata?.cveId || cveId.toUpperCase());
|
|
const descriptions = safeArray<any>(data?.containers?.cna?.descriptions);
|
|
const desc = descriptions.find((d) => d?.lang === "en")?.value || descriptions[0]?.value || "";
|
|
return [{
|
|
title: `${title} — ${desc.slice(0, 120) || "CVE Record"}`,
|
|
summary: normalizeText(desc),
|
|
url: `https://www.cve.org/CVERecord?id=${title}`,
|
|
source: "cve",
|
|
sourceName: "CVE / MITRE",
|
|
category: "cve-record",
|
|
publishedAt: normalizeText(data?.cveMetadata?.datePublished),
|
|
trustScore: 9,
|
|
tags: ["cve", "mitre", "vulnerability"],
|
|
cveIds: [title],
|
|
}];
|
|
}
|
|
|
|
async function fetchOwaspLatest(query?: string): Promise<SecurityNewsItem[]> {
|
|
const html = await fetchText(OWASP_NEWS_URL);
|
|
const text = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
const item: SecurityNewsItem = {
|
|
title: "OWASP Top 10 Web Application Security Risks",
|
|
summary: text.slice(0, 500),
|
|
url: OWASP_NEWS_URL,
|
|
source: "owasp",
|
|
sourceName: "OWASP",
|
|
category: "owasp-guidance",
|
|
trustScore: 8,
|
|
tags: ["owasp", "web-security", "guidance", ...extractCveIds(text)],
|
|
cveIds: extractCveIds(text),
|
|
};
|
|
return containsQuery(item, query) ? [item] : [];
|
|
}
|
|
|
|
const SOURCES: SecuritySource[] = [
|
|
{
|
|
id: "cisa",
|
|
name: "CISA KEV",
|
|
tier: 1,
|
|
trustScore: 10,
|
|
category: "government",
|
|
description: "Known Exploited Vulnerabilities catalog from CISA.",
|
|
homepage: "https://www.cisa.gov/known-exploited-vulnerabilities-catalog",
|
|
fetchLatest: fetchCisaKev,
|
|
},
|
|
{
|
|
id: "nvd",
|
|
name: "NVD",
|
|
tier: 1,
|
|
trustScore: 10,
|
|
category: "government",
|
|
description: "National Vulnerability Database CVE feed and API.",
|
|
homepage: "https://nvd.nist.gov/",
|
|
fetchLatest: fetchNvdLatest,
|
|
lookupCve: fetchNvdByCve,
|
|
},
|
|
{
|
|
id: "owasp",
|
|
name: "OWASP",
|
|
tier: 2,
|
|
trustScore: 8,
|
|
category: "non-profit",
|
|
description: "OWASP guidance and project advisories relevant to application and network security.",
|
|
homepage: OWASP_NEWS_URL,
|
|
fetchLatest: fetchOwaspLatest,
|
|
},
|
|
{
|
|
id: "cve",
|
|
name: "CVE / MITRE",
|
|
tier: 2,
|
|
trustScore: 9,
|
|
category: "non-profit",
|
|
description: "Canonical CVE record service operated by MITRE/CVE program.",
|
|
homepage: "https://www.cve.org/",
|
|
lookupCve: fetchCveById,
|
|
},
|
|
];
|
|
|
|
function formatItem(item: SecurityNewsItem): string {
|
|
const lines = [
|
|
`- ${item.title}`,
|
|
` Source: ${item.sourceName} | Trust: ${item.trustScore}/10 | Category: ${item.category}`,
|
|
item.publishedAt ? ` Published: ${item.publishedAt}` : "",
|
|
item.cveIds?.length ? ` CVEs: ${item.cveIds.join(", ")}` : "",
|
|
` URL: ${item.url}`,
|
|
` Summary: ${item.summary}`,
|
|
].filter(Boolean);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function formatSource(source: SecuritySource): string {
|
|
return `- ${source.name} (${source.id}) — Tier ${source.tier}, Trust ${source.trustScore}/10\n ${source.description}\n ${source.homepage}`;
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.registerTool({
|
|
name: "security_news",
|
|
label: "Security News",
|
|
description: "Curated security news and advisory retrieval from trusted sources such as CISA, NVD, OWASP, and CVE. Supports source listing, latest advisories, filtered search, and CVE lookup.",
|
|
parameters: Type.Object({
|
|
action: Type.String({ description: "Action to perform: sources, latest, search, cve_lookup" }),
|
|
query: Type.Optional(Type.String({ description: "Optional search filter for latest/search actions" })),
|
|
source: Type.Optional(Type.String({ description: "Optional source filter: cisa, owasp, nvd, cve" })),
|
|
cve_id: Type.Optional(Type.String({ description: "Specific CVE ID for cve_lookup action" })),
|
|
limit: Type.Optional(Type.Number({ description: "Maximum number of results to return (default 10)" })),
|
|
}),
|
|
async execute(_toolCallId, params) {
|
|
const action = normalizeText((params as any).action) as SecurityNewsAction;
|
|
const query = normalizeText((params as any).query) || undefined;
|
|
const sourceId = normalizeText((params as any).source) as SourceId | "";
|
|
const cveId = normalizeText((params as any).cve_id).toUpperCase();
|
|
const limit = typeof (params as any).limit === "number" ? Math.max(1, Math.min(25, (params as any).limit)) : 10;
|
|
|
|
if (!["sources", "latest", "search", "cve_lookup"].includes(action)) {
|
|
return { content: [{ type: "text" as const, text: `Unknown action: ${action}` }], details: { error: "invalid_action" } };
|
|
}
|
|
|
|
if (action === "sources") {
|
|
const text = ["Trusted security news/advisory sources:", "", ...SOURCES.map(formatSource)].join("\n");
|
|
return { content: [{ type: "text" as const, text }], details: { action, count: SOURCES.length } };
|
|
}
|
|
|
|
const selectedSources = sourceId ? SOURCES.filter((s) => s.id === sourceId) : SOURCES;
|
|
if (sourceId && selectedSources.length === 0) {
|
|
return { content: [{ type: "text" as const, text: `Unknown source: ${sourceId}` }], details: { error: "invalid_source" } };
|
|
}
|
|
|
|
try {
|
|
let items: SecurityNewsItem[] = [];
|
|
if (action === "cve_lookup") {
|
|
if (!/^CVE-\d{4}-\d{4,7}$/i.test(cveId)) {
|
|
return { content: [{ type: "text" as const, text: "cve_lookup requires a valid CVE ID like CVE-2024-12345." }], details: { error: "invalid_cve" } };
|
|
}
|
|
for (const source of selectedSources.filter((s) => s.lookupCve)) {
|
|
items.push(...await source.lookupCve!(cveId));
|
|
}
|
|
} else {
|
|
for (const source of selectedSources.filter((s) => s.fetchLatest)) {
|
|
items.push(...await source.fetchLatest!(query));
|
|
}
|
|
}
|
|
|
|
items = dedupeItems(items)
|
|
.filter((item) => action !== "search" || containsQuery(item, query))
|
|
.sort((a, b) => b.trustScore - a.trustScore)
|
|
.slice(0, limit);
|
|
|
|
if (items.length === 0) {
|
|
return {
|
|
content: [{ type: "text" as const, text: "No trusted security news results matched the request." }],
|
|
details: { action, count: 0 },
|
|
};
|
|
}
|
|
|
|
const heading = action === "cve_lookup"
|
|
? `Trusted advisory results for ${cveId}:`
|
|
: action === "search"
|
|
? `Trusted security news results for \"${query || ""}\":`
|
|
: "Latest trusted security advisories:";
|
|
|
|
const text = [heading, "", ...items.map(formatItem)].join("\n\n");
|
|
return {
|
|
content: [{ type: "text" as const, text }],
|
|
details: { action, count: items.length, items },
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{ type: "text" as const, text: `security_news failed: ${error.message}` }],
|
|
details: { action, error: error.message },
|
|
};
|
|
}
|
|
},
|
|
renderCall(args, theme) {
|
|
const p = args as any;
|
|
const label = `${p.action || "security_news"}${p.source ? `:${p.source}` : ""}`;
|
|
return new Text(theme.fg("toolTitle", theme.bold("security_news ")) + theme.fg("accent", label), 0, 0);
|
|
},
|
|
renderResult(result, _options, theme) {
|
|
const details = result.details as any;
|
|
if (details?.error) return new Text(theme.fg("error", `security_news error: ${details.error}`), 0, 0);
|
|
return new Text(theme.fg("success", `security_news ${details?.count ?? 0} result(s)`), 0, 0);
|
|
},
|
|
});
|
|
}
|