feat(worker): render PDF security reports via Typst (#421)

This commit is contained in:
ezl-keygraph
2026-08-12 15:06:43 +05:30
committed by GitHub
parent d4cc2ab974
commit 1ae0a142f8
18 changed files with 1168 additions and 27 deletions
+8 -2
View File
@@ -9,6 +9,9 @@ const WORKER_ROOT = path.resolve(import.meta.dirname, '..');
export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts');
export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs');
/** Bundled Typst template that renders report.json into the PDF report. */
export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'report.typ');
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');
@@ -28,8 +31,11 @@ export const INTERNAL_DIR = '.shannon';
/** Filename of the assembled report inside the deliverables dir (internal, source of the surfaced copy) */
export const ASSEMBLED_REPORT_FILENAME = 'comprehensive_security_assessment_report.md';
/** Filename of the human-facing final report surfaced at the run directory root */
export const FINAL_REPORT_FILENAME = 'Security-Assessment-Report.md';
/** Filename of the compiled PDF report inside the deliverables dir (internal, source of the surfaced copy) */
export const ASSEMBLED_REPORT_PDF_FILENAME = 'comprehensive_security_assessment_report.pdf';
/** Filename of the human-facing PDF report surfaced at the run directory root */
export const FINAL_REPORT_PDF_FILENAME = 'Security-Assessment-Report.pdf';
/** Structured findings the report agent emits; the markdown report is rendered from it. */
export const REPORT_JSON_FILENAME = 'report.json';
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* Typst PDF renderer.
*
* Adapts the structured report.json into the Typst-shaped schema and compiles
* it to a PDF with the bundled report.typ template. Compilation runs in an
* isolated temp dir: the template is copied in and the adapted JSON is written
* beside it so `--root` can scope every file read to that dir, matching how the
* template resolves `--input data=/data.json`.
*
* The `typst` binary is installed in the worker image and resolved from PATH.
*/
import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { adaptReportToTypst } from './report-json-adapter.js';
import type { ReportData } from './report-renderer.js';
const execFileAsync = promisify(execFile);
const DEFAULT_TESTER = 'Shannon';
const DEFAULT_BRAND = 'Shannon | AI Pentester by Keygraph';
const DATA_FILENAME = 'data.json';
const TEMPLATE_FILENAME = 'report.typ';
const OUTPUT_FILENAME = 'report.pdf';
export interface RenderReportPdfOptions {
/** Structured report data (report.json contents), pre-assembly. */
readonly reportData: ReportData;
/** Absolute path to the bundled report.typ template. */
readonly templatePath: string;
/** Absolute path where the compiled PDF should be written. */
readonly outputPath: string;
/** Name shown on the cover/footer. Defaults to "Shannon". */
readonly tester?: string;
/** Wordmark shown on the cover. Defaults to "Shannon | AI Pentester by Keygraph". */
readonly brand?: string;
}
/**
* Compile the report to a PDF at `outputPath`.
*
* Throws if adaptation or `typst compile` fails; callers treat the PDF as a
* secondary artifact and should not let a failure here fail the run.
*/
export async function renderReportPdf(options: RenderReportPdfOptions): Promise<void> {
const { reportData, templatePath, outputPath } = options;
const tester = options.tester ?? DEFAULT_TESTER;
const brand = options.brand ?? DEFAULT_BRAND;
const typstData = adaptReportToTypst(reportData);
const workDir = await mkdtemp(path.join(tmpdir(), 'shannon-typst-'));
try {
const templateInWorkDir = path.join(workDir, TEMPLATE_FILENAME);
const dataInWorkDir = path.join(workDir, DATA_FILENAME);
const pdfInWorkDir = path.join(workDir, OUTPUT_FILENAME);
await copyFile(templatePath, templateInWorkDir);
// Ship the template's assets (e.g. the cover logo) so `--root`-scoped image reads resolve.
const assetsDir = path.join(path.dirname(templatePath), 'assets');
if (existsSync(assetsDir)) {
await cp(assetsDir, path.join(workDir, 'assets'), { recursive: true });
}
await writeFile(dataInWorkDir, JSON.stringify(typstData), 'utf-8');
await execFileAsync('typst', [
'compile',
'--root',
workDir,
'--input',
`data=/${DATA_FILENAME}`,
'--input',
`tester=${tester}`,
'--input',
`brand=${brand}`,
templateInWorkDir,
pdfInWorkDir,
]);
await mkdir(path.dirname(outputPath), { recursive: true });
await copyFile(pdfInWorkDir, outputPath);
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
@@ -0,0 +1,293 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* Programmatic adapter: report.json → Typst ReportData JSON.
*
* Converts the renderer-neutral structured report output (produced by the
* finding-collector + set-report-meta CLI) into the Typst-specific schema that
* report.typ consumes.
*
* All Typst-specific concepts (PascalCase enums, computed aggregations,
* exploitedByType grouping) are confined to this file. The rest of the
* pipeline knows nothing about the Typst shape.
*/
import type { AddFindingInput, AdditionalSection, StepItem, StructuredStep } from '../collectors/finding-collector.js';
import type {
ExploitsReportData,
FindingsReportData,
TypstCategory,
TypstConfidence,
ReportData as TypstReportData,
TypstSeverity,
TypstStatus,
} from './report-output-schema.js';
import type { ReportData } from './report-renderer.js';
// ============================================================================
// CASING TRANSFORMS
// ============================================================================
const SEVERITY_MAP: Record<string, TypstSeverity> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
};
const STATUS_MAP: Record<string, TypstStatus> = {
exploited: 'Exploited',
out_of_scope: 'OutOfScope',
blocked_by_constraints: 'BlockedByConstraints',
false_positive: 'FalsePositive',
};
const CONFIDENCE_MAP: Record<string, TypstConfidence> = {
high: 'High',
medium: 'Medium',
low: 'Low',
};
const VALID_CATEGORIES = new Set<TypstCategory>([
'Authentication',
'Authorization',
'XSS',
'Injection',
'SSRF',
'Other',
]);
function toTypstSeverity(s: string): TypstSeverity {
return SEVERITY_MAP[s] ?? 'Low';
}
function toTypstStatus(s: string): TypstStatus {
return STATUS_MAP[s] ?? 'Exploited';
}
function toTypstConfidence(s: string): TypstConfidence {
return CONFIDENCE_MAP[s] ?? 'Medium';
}
function toTypstCategory(s: string): TypstCategory {
if (VALID_CATEGORIES.has(s as TypstCategory)) return s as TypstCategory;
return 'Other';
}
// ============================================================================
// STEP / ITEM TRANSFORMS
// ============================================================================
function adaptStepItem(item: StepItem): StepItem {
return item;
}
function adaptStep(step: StructuredStep, index: number): { number: number; title?: string; items: StepItem[] } {
return {
number: index + 1,
...(step.title && { title: step.title }),
items: step.items.map(adaptStepItem),
};
}
function adaptAdditionalSection(section: AdditionalSection): { heading: string; items: StepItem[] } {
return {
heading: section.heading,
items: section.items.map(adaptStepItem),
};
}
// ============================================================================
// AGGREGATION HELPERS
// ============================================================================
interface CategoryGroup {
category: TypstCategory;
findings: AddFindingInput[];
}
function groupByCategory(findings: readonly AddFindingInput[]): CategoryGroup[] {
const map = new Map<TypstCategory, AddFindingInput[]>();
for (const f of findings) {
const cat = toTypstCategory(f.category);
const list = map.get(cat) ?? [];
list.push(f);
map.set(cat, list);
}
return Array.from(map.entries()).map(([category, fs]) => ({ category, findings: fs }));
}
function countBySeverity(findings: readonly AddFindingInput[]): Record<TypstSeverity, number> {
const counts: Record<string, number> = {
Critical: 0,
High: 0,
Medium: 0,
Low: 0,
};
for (const f of findings) {
const sev = toTypstSeverity(f.severity);
counts[sev] = (counts[sev] ?? 0) + 1;
}
return counts as Record<TypstSeverity, number>;
}
// ============================================================================
// EXPLOIT MODE ADAPTER
// ============================================================================
function adaptExploitsMode(data: ReportData): ExploitsReportData {
const { report_meta, findings } = data;
const groups = groupByCategory(findings);
const sevCounts = countBySeverity(findings);
const statusCounts = { Exploited: 0, OutOfScope: 0, BlockedByConstraints: 0, FalsePositive: 0 };
for (const f of findings) {
const s = toTypstStatus(f.status ?? 'exploited');
statusCounts[s]++;
}
const exploitedFindings = findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
return {
mode: 'exploits' as const,
meta: {
target: report_meta.target,
assessmentDate: report_meta.assessment_date,
classification: 'CONFIDENTIAL',
},
scope: report_meta.scope,
exploitedByType: groups.map((g) => {
const exploited = g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited');
if (exploited.length === 0) {
return {
category: g.category,
narrative: `No ${g.category.toLowerCase()} vulnerabilities were successfully exploited during this assessment.`,
};
}
return {
category: g.category,
bullets: exploited.map((f) => ({ id: f.finding_id, description: f.title })),
};
}),
summary: {
totalIdentified: findings.length,
successfullyExploited: exploitedFindings.length,
exploitedBreakdown: groups
.map((g) => ({
category: g.category,
count: g.findings.filter((f) => (f.status ?? 'exploited') === 'exploited').length,
}))
.filter((e) => e.count > 0),
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
},
findings: findings.map((f) => ({
id: f.finding_id,
title: f.title,
category: toTypstCategory(f.category),
severity: toTypstSeverity(f.severity),
status: toTypstStatus(f.status ?? 'exploited'),
summary: {
vulnerableLocation: f.vulnerable_location,
overview: f.overview,
impact: f.impact,
},
// This branch only runs for an exploitative report, where the schema made these
// required. The fallbacks keep the superset type honest rather than assuming.
prerequisites: f.prerequisites ?? '',
exploitationSteps: (f.exploitation_steps ?? []).map(adaptStep),
proofOfImpact: (f.proof_of_impact ?? []).map(adaptStepItem),
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
...(f.additional_sections &&
f.additional_sections.length > 0 && {
additionalSections: f.additional_sections.map(adaptAdditionalSection),
}),
})),
derivedCounts: {
bySeverity: sevCounts,
byStatus: statusCounts,
},
};
}
// ============================================================================
// FINDINGS MODE ADAPTER
// ============================================================================
function adaptFindingsMode(data: ReportData): FindingsReportData {
const { report_meta, findings } = data;
const groups = groupByCategory(findings);
const sevCounts = countBySeverity(findings);
const confidenceCounts = { High: 0, Medium: 0, Low: 0 };
for (const f of findings) {
const c = toTypstConfidence(f.confidence ?? 'medium');
confidenceCounts[c]++;
}
return {
mode: 'findings' as const,
meta: {
target: report_meta.target,
assessmentDate: report_meta.assessment_date,
classification: 'CONFIDENTIAL',
},
scope: report_meta.scope,
identifiedByType: groups.map((g) => {
if (g.findings.length === 0) {
return {
category: g.category,
narrative: `No ${g.category.toLowerCase()} vulnerabilities were identified during this assessment.`,
};
}
return {
category: g.category,
bullets: g.findings.map((f) => ({ id: f.finding_id, description: f.title })),
};
}),
summary: {
totalIdentified: findings.length,
identifiedBreakdown: groups.map((g) => ({
category: g.category,
count: g.findings.length,
})),
criticalFindings: findings.filter((f) => f.severity === 'critical').map((f) => `${f.finding_id}: ${f.title}`),
},
findings: findings.map((f) => ({
id: f.finding_id,
title: f.title,
category: toTypstCategory(f.category),
severity: toTypstSeverity(f.severity),
confidence: toTypstConfidence(f.confidence ?? 'medium'),
summary: {
vulnerableLocation: f.vulnerable_location,
overview: f.overview,
impact: f.impact,
},
...(f.notes && f.notes.length > 0 && { notes: f.notes.map(adaptStepItem) }),
...(f.additional_sections &&
f.additional_sections.length > 0 && {
additionalSections: f.additional_sections.map(adaptAdditionalSection),
}),
})),
derivedCounts: {
bySeverity: sevCounts,
byConfidence: confidenceCounts,
},
};
}
// ============================================================================
// PUBLIC API
// ============================================================================
export function adaptReportToTypst(data: ReportData): TypstReportData {
const exploitEnabled = data.report_meta.exploit ?? true;
if (exploitEnabled) {
return adaptExploitsMode(data);
}
return adaptFindingsMode(data);
}
@@ -0,0 +1,157 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* TypeScript types for the structured report the Typst template consumes, in two
* shapes keyed by a `mode` discriminator: `exploits` (exploit=true) and `findings`
* (exploit=false, analysis-only). Types only — the object is built programmatically
* in report-json-adapter.ts, so these exist to keep the adapter and report.typ in sync.
*/
// === Shared primitives ===
export type TypstSeverity = 'Critical' | 'High' | 'Medium' | 'Low';
export type TypstStatus = 'Exploited' | 'OutOfScope' | 'BlockedByConstraints' | 'FalsePositive';
export type TypstConfidence = 'High' | 'Medium' | 'Low';
export type TypstCategory = 'Authentication' | 'Authorization' | 'XSS' | 'Injection' | 'SSRF' | 'Other';
export interface CodeBlock {
readonly language: string;
readonly content: string;
}
export type StepItem =
| { readonly kind: 'prose'; readonly text: string }
| { readonly kind: 'code'; readonly block: CodeBlock };
export interface Step {
readonly number: number;
readonly title?: string;
readonly items: readonly StepItem[];
}
export interface AdditionalSection {
readonly heading: string;
readonly items: readonly StepItem[];
}
export interface FindingSummary {
readonly vulnerableLocation: string;
readonly overview: string;
readonly impact: string;
}
export interface Meta {
readonly target: string;
readonly assessmentDate: string;
readonly tester?: string;
readonly application?: string;
readonly classification: string;
}
export interface CategoryCount {
readonly category: TypstCategory;
readonly count: number;
readonly note?: string;
}
export type SeverityCounts = Record<TypstSeverity, number>;
export type StatusCounts = Record<TypstStatus, number>;
export type ConfidenceCounts = Record<TypstConfidence, number>;
export interface TypeEntryBullet {
readonly id: string;
readonly description: string;
}
// === Exploits-mode schema ===
export interface ExploitFinding {
readonly id: string;
readonly title: string;
readonly category: TypstCategory;
readonly severity: TypstSeverity;
readonly status: TypstStatus;
readonly summary: FindingSummary;
readonly prerequisites: string;
readonly exploitationSteps: readonly Step[];
readonly proofOfImpact: readonly StepItem[];
readonly notes?: readonly StepItem[];
readonly additionalSections?: readonly AdditionalSection[];
}
export interface ExploitedByTypeEntry {
readonly category: TypstCategory;
readonly bullets?: readonly TypeEntryBullet[];
readonly narrative?: string;
}
export interface ExploitsReportData {
readonly mode: 'exploits';
readonly meta: Meta;
readonly scope: string;
readonly exploitedByType: readonly ExploitedByTypeEntry[];
readonly summary: {
readonly totalIdentified: number;
readonly successfullyExploited: number;
readonly exploitedBreakdown: readonly CategoryCount[];
readonly outOfScope?: {
readonly total: number;
readonly breakdown?: readonly CategoryCount[];
readonly note?: string;
};
readonly blockedByConstraints?: {
readonly total: number;
readonly note?: string;
};
readonly criticalFindings: readonly string[];
};
readonly findings: readonly ExploitFinding[];
readonly derivedCounts: {
readonly bySeverity: SeverityCounts;
readonly byStatus: StatusCounts;
};
}
// === Findings-mode schema (analysis-only, exploit=false runs) ===
export interface AnalysisFinding {
readonly id: string;
readonly title: string;
readonly category: TypstCategory;
readonly severity: TypstSeverity;
readonly confidence: TypstConfidence;
readonly summary: FindingSummary;
readonly notes?: readonly StepItem[];
readonly additionalSections?: readonly AdditionalSection[];
}
export interface IdentifiedByTypeEntry {
readonly category: TypstCategory;
readonly bullets?: readonly TypeEntryBullet[];
readonly narrative?: string;
}
export interface FindingsReportData {
readonly mode: 'findings';
readonly meta: Meta;
readonly scope: string;
readonly identifiedByType: readonly IdentifiedByTypeEntry[];
readonly summary: {
readonly totalIdentified: number;
readonly identifiedBreakdown: readonly CategoryCount[];
readonly criticalFindings: readonly string[];
};
readonly findings: readonly AnalysisFinding[];
readonly derivedCounts: {
readonly bySeverity: SeverityCounts;
readonly byConfidence: ConfidenceCounts;
};
}
// === Discriminated union for downstream consumers that handle both ===
export type ReportData = ExploitsReportData | FindingsReportData;
+10 -8
View File
@@ -7,8 +7,9 @@
import { fs, path } from 'zx';
import {
ASSEMBLED_REPORT_FILENAME,
ASSEMBLED_REPORT_PDF_FILENAME,
deliverablesDir,
FINAL_REPORT_FILENAME,
FINAL_REPORT_PDF_FILENAME,
resolveSessionJsonPath,
SARIF_FILENAME,
} from '../paths.js';
@@ -174,7 +175,8 @@ export async function injectModelIntoReport(
/**
* Surface the run's deliverables at the run directory's top level, so a customer opening the run
* folder sees the report without digging through internals. Sources stay in the deliverables dir
* (git-checkpointed, used by resume).
* (git-checkpointed, used by resume). The PDF is the customer-facing report surfaced here; the
* markdown remains in the deliverables dir but is not surfaced.
*
* The SARIF log is surfaced beside it when present, since a CI step consuming it needs a stable
* path and cannot be expected to reach into the internals directory. It is absent whenever the
@@ -188,13 +190,13 @@ export async function copyReportToRunRoot(
): Promise<void> {
const dir = deliverablesDir(repoPath, deliverablesSubdir);
const source = path.join(dir, ASSEMBLED_REPORT_FILENAME);
if (await fs.pathExists(source)) {
const destination = path.join(runDir, FINAL_REPORT_FILENAME);
await fs.copy(source, destination, { overwrite: true });
logger.info(`Surfaced report at ${destination}`);
const pdfSource = path.join(dir, ASSEMBLED_REPORT_PDF_FILENAME);
if (await fs.pathExists(pdfSource)) {
const destination = path.join(runDir, FINAL_REPORT_PDF_FILENAME);
await fs.copy(pdfSource, destination, { overwrite: true });
logger.info(`Surfaced PDF report at ${destination}`);
} else {
logger.warn(`Final report not found, skipping ${FINAL_REPORT_FILENAME}`);
logger.warn(`PDF report not found, skipping ${FINAL_REPORT_PDF_FILENAME}`);
}
const sarifSource = path.join(dir, SARIF_FILENAME);
+1 -1
View File
@@ -154,7 +154,7 @@ function buildMessageMarkdown(finding: AddFindingInput): string {
parts.push('', '**Remediation**', '', finding.remediation);
// Exploitation steps and proof of impact are deliberately absent: SARIF has no structural home
// for them, and flattening them into prose would imply this file carries the evidence.
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.md`');
parts.push('', 'Full exploitation evidence: `Security-Assessment-Report.pdf`');
return parts.join('\n');
}
+27
View File
@@ -27,11 +27,13 @@ import type { WorkflowSummary } from '../audit/workflow-logger.js';
import type { CheckpointContext } from '../interfaces/checkpoint-provider.js';
import {
ASSEMBLED_REPORT_FILENAME,
ASSEMBLED_REPORT_PDF_FILENAME,
DEFAULT_DELIVERABLES_SUBDIR,
deliverablesDir,
REPORT_JSON_FILENAME,
resolveSessionJsonPath,
SARIF_FILENAME,
TYPST_TEMPLATE,
} from '../paths.js';
import { getAgentGitPaths } from '../services/agent-git-paths.js';
import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js';
@@ -477,6 +479,30 @@ async function writeSarifIfEnabled(
}
}
/**
* Compile the PDF report from the assembled report data.
*
* Failures are logged and swallowed the PDF is a secondary artifact and must not fail a run
* whose report is already written.
*/
async function writePdfReport(
reportData: ReportData,
deliverablesPath: string,
logger: ReturnType<typeof createActivityLogger>,
): Promise<void> {
try {
const { renderReportPdf } = await import('../services/pdf-renderer.js');
await renderReportPdf({
reportData,
templatePath: TYPST_TEMPLATE,
outputPath: path.join(deliverablesPath, ASSEMBLED_REPORT_PDF_FILENAME),
});
logger.info(`Wrote ${ASSEMBLED_REPORT_PDF_FILENAME}`);
} catch (error) {
logger.warn(`Failed to write ${ASSEMBLED_REPORT_PDF_FILENAME}: ${(error as Error).message}`);
}
}
export async function runReportAgent(input: ActivityInput, exploit: boolean): Promise<AgentMetrics> {
const { createFindingCollector } = await import('../collectors/finding-collector.js');
const { renderReport } = await import('../services/report-renderer.js');
@@ -532,6 +558,7 @@ export async function runReportAgent(input: ActivityInput, exploit: boolean): Pr
await atomicWrite(path.join(deliverablesPath, ASSEMBLED_REPORT_FILENAME), renderReport(reportData));
logger.info(`Wrote ${ASSEMBLED_REPORT_FILENAME} from structured data`);
await writePdfReport(reportData, deliverablesPath, logger);
await writeSarifIfEnabled(input, exploit, reportData, deliverablesPath, logger);
};
+9 -4
View File
@@ -35,7 +35,12 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker
import dotenv from 'dotenv';
import { sanitizeHostname } from '../audit/utils.js';
import { parseConfig } from '../config-parser.js';
import { ASSEMBLED_REPORT_FILENAME, deliverablesDir, FINAL_REPORT_FILENAME, resolveSessionJsonPath } from '../paths.js';
import {
ASSEMBLED_REPORT_PDF_FILENAME,
deliverablesDir,
FINAL_REPORT_PDF_FILENAME,
resolveSessionJsonPath,
} from '../paths.js';
import type { VulnClass } from '../types/config.js';
import { fileExists, readJson } from '../utils/file-io.js';
import * as activities from './activities.js';
@@ -389,9 +394,9 @@ function copyDeliverables(repoPath: string, outputPath: string): void {
}
// Surface the report under its human-facing name alongside the raw deliverables
const assembledReport = path.join(outputDir, ASSEMBLED_REPORT_FILENAME);
if (fs.existsSync(assembledReport)) {
fs.copyFileSync(assembledReport, path.join(outputPath, FINAL_REPORT_FILENAME));
const assembledPdf = path.join(outputDir, ASSEMBLED_REPORT_PDF_FILENAME);
if (fs.existsSync(assembledPdf)) {
fs.copyFileSync(assembledPdf, path.join(outputPath, FINAL_REPORT_PDF_FILENAME));
}
console.log(`Copied ${files.length} deliverable(s) to ${outputPath}`);