UNPKG

vanta-auditor-tui

Version:

Beautiful terminal UI for exporting Vanta audit evidence with ZIP support and progress tracking

244 lines 11.4 kB
#!/usr/bin/env node import React, { useState } from "react"; import { render, Box, Text } from "ink"; import { Gradient } from "./lib/inkModules.js"; import { CredentialsForm } from "./components/CredentialsForm.js"; import { AuditSelector } from "./components/AuditSelector.js"; import { ExportOptions } from "./components/ExportOptions.js"; import { DownloadProgress } from "./components/DownloadProgress.js"; import { ArchiveProgress } from "./components/ArchiveProgress.js"; import { LoadingSpinner } from "./components/LoadingSpinner.js"; import { createVantaClient } from "./lib/vantaClient.js"; import { downloadAll } from "./lib/downloader.js"; import { gatherEvidenceItems } from "./lib/evidenceGatherer.js"; import { createZipArchive } from "./lib/archiver.js"; import { theme } from "./theme.js"; import path from "node:path"; import fs from "node:fs"; // Polyfill fetch/Headers/Web Streams for Node 16 import { fetch as undiciFetch, Headers as UndiciHeaders, Request as UndiciRequest, Response as UndiciResponse } from "undici"; import { ReadableStream as WebReadableStream } from "stream/web"; import { TextEncoder as NodeTextEncoder, TextDecoder as NodeTextDecoder } from "node:util"; // @ts-ignore if (typeof globalThis.fetch === "undefined") { // @ts-ignore globalThis.fetch = undiciFetch; // @ts-ignore globalThis.Headers = UndiciHeaders; // @ts-ignore globalThis.Request = UndiciRequest; // @ts-ignore globalThis.Response = UndiciResponse; } // @ts-ignore if (typeof globalThis.ReadableStream === "undefined") { // @ts-ignore globalThis.ReadableStream = WebReadableStream; } // @ts-ignore // Blob/FormData polyfills are not critical for our flows and may be omitted on Node 16 // @ts-ignore if (typeof globalThis.TextEncoder === "undefined") { // @ts-ignore globalThis.TextEncoder = NodeTextEncoder; } // @ts-ignore if (typeof globalThis.TextDecoder === "undefined") { // @ts-ignore globalThis.TextDecoder = NodeTextDecoder; } function App() { const [step, setStep] = useState("credentials"); const [token, setToken] = useState(""); const [region, setRegion] = useState("us"); const [serverURL, setServerURL] = useState(); const [sdk, setSdk] = useState(null); const [audits, setAudits] = useState([]); const [selectedAuditId, setSelectedAuditId] = useState(null); const [exportOptions, setExportOptions] = useState(null); const [progress, setProgress] = useState([]); const [gatherProgress, setGatherProgress] = useState(null); const [archiveProgress, setArchiveProgress] = useState(null); const [error, setError] = useState(null); const [downloadResult, setDownloadResult] = useState(null); const [verboseMode, setVerboseMode] = useState(process.argv.includes("--verbose")); // Step 1: credentials const handleCredentials = async (r) => { setStep("authenticating"); try { setToken(r.token ?? ""); setRegion(r.region ?? "us"); setServerURL(r.serverURL ?? undefined); const client = await createVantaClient({ token: r.token, clientId: r.clientId, clientSecret: r.clientSecret, scope: r.scope, region: r.region ?? "us", serverURL: r.serverURL, debug: r.debug }); setSdk(client); // Fetch audits (paginated) const auditsList = await fetchAllAudits(client); const items = auditsList.map((a) => ({ id: a.id ?? a.auditId ?? a.uuid ?? String(a), name: `${a.customerDisplayName || a.customerOrganizationName || 'Unknown'} - ${a.framework || 'Audit'}`, framework: a.framework ?? a.standard ?? undefined, status: a.completionDate ? 'Completed' : (a.auditEndDate && new Date(a.auditEndDate) < new Date() ? 'Expired' : 'Active') })); // If empty, keep moving to audits step but show helpful message in UI setAudits(items); setStep("audits"); } catch (e) { setError(`Authentication failed: ${e?.message ?? String(e)}`); setStep("error"); } }; // Step 2: audit selection → options const handleSelectAudit = (auditId) => { setSelectedAuditId(auditId); setStep("options"); }; // Step 3: options → downloading const handleOptions = async (opts) => { setExportOptions(opts); setStep("downloading"); const startTime = Date.now(); try { if (!sdk || !selectedAuditId) throw new Error("missing sdk or audit id"); // Starting evidence gathering // Discover evidence artifacts const items = await gatherEvidenceItems(sdk, selectedAuditId, verboseMode, (gProgress) => { setGatherProgress(gProgress); }); if (items.length === 0) { throw new Error("No evidence items found for this audit. The audit may not have any attached evidence."); } // Evidence items ready for download const downloads = items.map((it, idx) => ({ url: it.downloadUrl, fileName: it.fileName ?? `artifact-${idx + 1}`, evidenceKey: it.evidenceKey })); // Determine output directory - use temp if creating ZIP const downloadDir = opts.createZip ? path.join(opts.outputDir, ".tmp-download") : opts.outputDir; const updates = []; const result = await downloadAll(downloads, { outputDir: downloadDir, structure: opts.structure, folderPrefix: opts.folderPrefix, concurrency: 6, maxRetries: 3, verbose: verboseMode }, (u) => { updates.push(u); setProgress([...updates]); }); // Create ZIP if requested if (opts.createZip && opts.zipName) { setStep("archiving"); const zipPath = path.join(opts.outputDir, opts.zipName); // Creating ZIP archive await createZipArchive({ sourceDir: downloadDir, outputPath: zipPath, compressionLevel: 9, verbose: verboseMode }, (progress) => { setArchiveProgress(progress); }); // Clean up temp directory // Cleaning up temporary files // Remove temp directory fs.rmSync(downloadDir, { recursive: true, force: true }); } const endTime = Date.now(); setDownloadResult({ ...result, totalSize: updates.reduce((acc, u) => acc + (u.totalBytes || 0), 0), startTime, endTime }); setStep("done"); } catch (e) { setError(`Download failed: ${e?.message ?? String(e)}`); setStep("error"); } }; return (React.createElement(Box, { flexDirection: "column" }, React.createElement(Box, { flexDirection: "column", marginBottom: 1 }, React.createElement(Gradient, { name: "vice" }, React.createElement(Text, { bold: true }, "\uD83E\uDD99 Vanta Auditor TUI")), React.createElement(Text, { color: theme.colors.primary }, "Your Friendly Audit Llama")), step === "credentials" && React.createElement(CredentialsForm, { onSubmit: handleCredentials }), step === "authenticating" && (React.createElement(LoadingSpinner, { message: "\uD83E\uDD99 Authenticating with Vanta...", subMessage: "Validating credentials and fetching audits" })), step === "audits" && React.createElement(AuditSelector, { audits: audits, onSelect: handleSelectAudit }), step === "options" && selectedAuditId && (React.createElement(ExportOptions, { auditId: selectedAuditId, onSubmit: handleOptions })), step === "downloading" && React.createElement(DownloadProgress, { updates: progress, gatherProgress: gatherProgress }), step === "archiving" && React.createElement(ArchiveProgress, { progress: archiveProgress }), step === "done" && downloadResult && (React.createElement(Box, { flexDirection: "column" }, React.createElement(Text, { color: theme.colors.success }, "\u2728 All done! Your audit evidence is ready!"), React.createElement(Text, null, " "), exportOptions?.createZip ? (React.createElement(Text, { color: theme.colors.text }, "\uD83D\uDCE6 ZIP archive: ", path.join(exportOptions.outputDir, exportOptions.zipName || "audit.zip"))) : (React.createElement(Text, { color: theme.colors.text }, "\uD83D\uDCC1 Files saved to: ", exportOptions?.outputDir)), React.createElement(Text, { color: theme.colors.text }, "\uD83D\uDCCA Summary:"), React.createElement(Text, { color: theme.colors.success }, " \u2713 Downloaded: ", downloadResult.successes, " files"), downloadResult.failures > 0 && (React.createElement(Text, { color: theme.colors.error }, " \u2717 Failed: ", downloadResult.failures, " files")), downloadResult.skipped > 0 && (React.createElement(Text, { color: theme.colors.warning }, " \u21B7 Skipped: ", downloadResult.skipped, " files (already exist)")), React.createElement(Text, { color: theme.colors.text }, " \uD83D\uDCBE Total size: ", formatBytes(downloadResult.totalSize)), React.createElement(Text, { color: theme.colors.text }, " \u23F1\uFE0F Duration: ", ((downloadResult.endTime - downloadResult.startTime) / 1000).toFixed(1), "s"), React.createElement(Text, null, " "), React.createElement(Text, { color: theme.colors.primaryLight }, "\uD83E\uDD99 Happy auditing!"))), step === "error" && React.createElement(Text, { color: theme.colors.error }, error))); } // Evidence gathering moved to lib/evidenceGatherer.ts async function fetchAllAudits(sdk) { const all = []; let cursor = undefined; for (let i = 0; i < 50; i++) { const res = await sdk.audits.list({ pageSize: 100, pageCursor: cursor }); const pageData = res?.results?.data ?? []; all.push(...pageData); const pageInfo = res?.results?.pageInfo; if (pageInfo?.hasNextPage && pageInfo?.endCursor) { cursor = pageInfo.endCursor; } else { break; } } return all; } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; } render(React.createElement(App, null)); //# sourceMappingURL=index.js.map