dnsweeper
Version:
Advanced CLI tool for DNS record risk analysis and cleanup. Features CSV import for Cloudflare/Route53, automated risk assessment, and parallel DNS validation.
442 lines (383 loc) • 11.4 kB
text/typescript
/**
* 出力フォーマッター - 分析結果の出力形式を管理
*/
import { writeFileSync } from 'fs';
import chalk from 'chalk';
import type { IDNSRecord, DNSRecordType } from '../types/index.js';
/**
* 出力形式
*/
export type OutputFormat = 'json' | 'csv' | 'table' | 'text';
/**
* リスクレベル
*/
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
/**
* 出力オプション
*/
export interface OutputOptions {
format: OutputFormat;
colors: boolean;
outputFile?: string;
verbose: boolean;
compact: boolean;
}
/**
* 分析結果データ
*/
export interface AnalysisResult {
summary: {
total: number;
byType: Record<DNSRecordType, number>;
byRisk: Record<RiskLevel, number>;
duration: number;
};
records: Array<
IDNSRecord & {
riskLevel: RiskLevel;
riskScore: number;
recommendations: string[];
}
>;
metadata: {
scannedAt: Date;
source: string;
version: string;
};
}
/**
* テーブル列設定
*/
interface TableColumn {
key: string;
header: string;
width: number;
align: 'left' | 'center' | 'right';
format?: (value: any) => string;
}
/**
* 出力フォーマッター
*/
export class OutputFormatter {
private options: OutputOptions;
private riskColors: Record<RiskLevel, (text: string) => string>;
constructor(options: Partial<OutputOptions> = {}) {
this.options = {
format: 'table',
colors: true,
verbose: false,
compact: false,
...options,
};
// リスクレベル別の色設定
this.riskColors = {
low: chalk.green,
medium: chalk.yellow,
high: chalk.red,
critical: chalk.red.bold,
};
}
/**
* 分析結果を指定された形式で出力
*/
format(result: AnalysisResult): string {
switch (this.options.format) {
case 'json':
return this.formatJSON(result);
case 'csv':
return this.formatCSV(result);
case 'table':
return this.formatTable(result);
case 'text':
return this.formatText(result);
default:
throw new Error(`サポートされていない出力形式: ${this.options.format}`);
}
}
/**
* ファイルに出力
*/
async writeToFile(result: AnalysisResult, filename?: string): Promise<void> {
const outputFile = filename || this.options.outputFile;
if (!outputFile) {
throw new Error('出力ファイル名が指定されていません');
}
const content = this.format(result);
if (this.options.format === 'csv' && outputFile.endsWith('.csv')) {
// CSVの場合はBOM付きUTF-8で保存
const bom = '\uFEFF';
writeFileSync(outputFile, bom + content, 'utf8');
} else {
writeFileSync(outputFile, content, 'utf8');
}
}
/**
* JSON形式で出力
*/
private formatJSON(result: AnalysisResult): string {
if (this.options.compact) {
return JSON.stringify(result);
}
return JSON.stringify(result, null, 2);
}
/**
* CSV形式で出力
*/
private formatCSV(result: AnalysisResult): string {
const headers = [
'ID',
'Name',
'Type',
'Value',
'TTL',
'Risk Level',
'Risk Score',
'Priority',
'Weight',
'Port',
'Created',
'Updated',
'Recommendations',
];
const rows = result.records.map((record) => [
record.id,
record.name,
record.type,
record.value,
record.ttl,
record.riskLevel,
record.riskScore,
record.priority || '',
record.weight || '',
record.port || '',
record.created.toISOString(),
record.updated.toISOString(),
record.recommendations.join('; '),
]);
return [headers, ...rows]
.map((row) => row.map((cell) => this.escapeCsvCell(String(cell))).join(','))
.join('\n');
}
/**
* テーブル形式で出力
*/
private formatTable(result: AnalysisResult): string {
const output: string[] = [];
// サマリー情報
output.push(this.formatSummary(result.summary));
output.push('');
// レコードテーブル
if (result.records.length > 0) {
output.push(this.formatRecordsTable(result.records));
}
// メタデータ
if (this.options.verbose) {
output.push('');
output.push(this.formatMetadata(result.metadata));
}
return output.join('\n');
}
/**
* テキスト形式で出力
*/
private formatText(result: AnalysisResult): string {
const output: string[] = [];
output.push('=== DNS分析結果 ===');
output.push('');
// サマリー
output.push(this.formatSummary(result.summary));
output.push('');
// レコード詳細
for (const record of result.records) {
output.push(this.formatRecordDetail(record));
output.push('');
}
return output.join('\n');
}
/**
* サマリー情報をフォーマット
*/
private formatSummary(summary: AnalysisResult['summary']): string {
const lines: string[] = [];
lines.push(this.colorize('📊 分析サマリー', 'title'));
lines.push(this.createSeparator());
lines.push(`総レコード数: ${this.colorize(summary.total.toString(), 'number')}`);
lines.push(`分析時間: ${this.colorize((summary.duration / 1000).toFixed(2) + 's', 'number')}`);
lines.push('');
// レコードタイプ別統計
lines.push('📝 レコードタイプ別:');
for (const [type, count] of Object.entries(summary.byType)) {
if (count > 0) {
lines.push(` ${type.padEnd(6)}: ${this.colorize(count.toString(), 'number')}`);
}
}
lines.push('');
// リスクレベル別統計
lines.push('⚠️ リスクレベル別:');
for (const [level, count] of Object.entries(summary.byRisk)) {
if (count > 0) {
const coloredLevel = this.colorizeRisk(level as RiskLevel, level.toUpperCase());
// パディング計算時は色なしの文字列長を使用
const paddingLength = 15 - level.toUpperCase().length;
lines.push(
` ${coloredLevel}${' '.repeat(paddingLength)}: ${this.colorize(count.toString(), 'number')}`,
);
}
}
return lines.join('\n');
}
/**
* レコードテーブルをフォーマット
*/
private formatRecordsTable(records: AnalysisResult['records']): string {
const columns: TableColumn[] = [
{ key: 'name', header: 'Name', width: 30, align: 'left' },
{ key: 'type', header: 'Type', width: 6, align: 'center' },
{ key: 'value', header: 'Value', width: 25, align: 'left' },
{ key: 'ttl', header: 'TTL', width: 8, align: 'right' },
{
key: 'riskLevel',
header: 'Risk',
width: 10,
align: 'center',
format: (level: RiskLevel) => this.colorizeRisk(level, level.toUpperCase()),
},
{ key: 'riskScore', header: 'Score', width: 6, align: 'right' },
];
const lines: string[] = [];
// ヘッダー
const headerLine = columns
.map((col) => this.padCell(this.colorize(col.header, 'header'), col.width, col.align))
.join(' | ');
lines.push(headerLine);
// セパレーター
const separatorLine = columns.map((col) => '-'.repeat(col.width)).join('-+-');
lines.push(separatorLine);
// データ行
for (const record of records) {
const dataLine = columns
.map((col) => {
const value = record[col.key as keyof typeof record];
const formattedValue = col.format ? col.format(value) : String(value);
return this.padCell(formattedValue, col.width, col.align);
})
.join(' | ');
lines.push(dataLine);
}
return lines.join('\n');
}
/**
* レコード詳細をフォーマット
*/
private formatRecordDetail(record: AnalysisResult['records'][0]): string {
const lines: string[] = [];
lines.push(`🔍 ${this.colorize(record.name, 'title')} (${record.type})`);
lines.push(` 値: ${record.value}`);
lines.push(` TTL: ${record.ttl}s`);
lines.push(
` リスク: ${this.colorizeRisk(record.riskLevel, record.riskLevel.toUpperCase())} (スコア: ${record.riskScore})`,
);
if (record.recommendations.length > 0) {
lines.push(' 推奨事項:');
for (const rec of record.recommendations) {
lines.push(` • ${rec}`);
}
}
return lines.join('\n');
}
/**
* メタデータをフォーマット
*/
private formatMetadata(metadata: AnalysisResult['metadata']): string {
const lines: string[] = [];
lines.push(this.colorize('📋 メタデータ', 'title'));
lines.push(this.createSeparator());
lines.push(`スキャン日時: ${metadata.scannedAt.toLocaleString('ja-JP')}`);
lines.push(`ソース: ${metadata.source}`);
lines.push(`バージョン: ${metadata.version}`);
return lines.join('\n');
}
/**
* リスクレベルに応じた色付け
*/
private colorizeRisk(level: RiskLevel, text: string): string {
if (!this.options.colors) {
return text;
}
return this.riskColors[level](text);
}
/**
* 汎用色付け
*/
private colorize(text: string, type: 'title' | 'header' | 'number' | 'normal'): string {
if (!this.options.colors) {
return text;
}
switch (type) {
case 'title':
return chalk.cyan.bold(text);
case 'header':
return chalk.blue.bold(text);
case 'number':
return chalk.magenta(text);
case 'normal':
default:
return text;
}
}
/**
* セルの文字列調整
*/
private padCell(text: string, width: number, align: 'left' | 'center' | 'right'): string {
// ANSIエスケープシーケンスを除いた実際の文字数を取得
const cleanText = text.replace(/\u001b\[[0-9;]*m/g, '');
const padding = width - cleanText.length;
if (padding <= 0) {
return text.slice(0, width);
}
switch (align) {
case 'right':
return ' '.repeat(padding) + text;
case 'center':
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
case 'left':
default:
return text + ' '.repeat(padding);
}
}
/**
* セパレーター作成
*/
private createSeparator(length: number = 50): string {
return '─'.repeat(length);
}
/**
* CSVセルのエスケープ
*/
private escapeCsvCell(value: string): string {
// カンマ、ダブルクォート、改行を含む場合はダブルクォートで囲む
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;
}
}
/**
* デフォルト設定でのフォーマッター作成
*/
export function createFormatter(options?: Partial<OutputOptions>): OutputFormatter {
return new OutputFormatter(options);
}
/**
* クイック出力関数
*/
export function formatAnalysisResult(
result: AnalysisResult,
format: OutputFormat = 'table',
colors: boolean = true,
): string {
const formatter = new OutputFormatter({ format, colors });
return formatter.format(result);
}