vanta-auditor-tui
Version:
Beautiful terminal UI for exporting Vanta audit evidence with ZIP support and progress tracking
244 lines • 11.4 kB
JavaScript
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