flagtrack
Version:
CTF Progress Tracker and Task Management Tool
354 lines (284 loc) • 11.9 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const simpleGit = require('simple-git');
const { loadConfig } = require('../utils/configManager');
const { findRepoRoot } = require('../utils/gitHelpers');
async function updateReadme() {
console.log(chalk.blue('📊 Generating CTF progress README'));
try {
const repoRoot = await findRepoRoot();
if (!repoRoot) {
console.log(chalk.yellow('⚠️ Could not find repository root.'));
process.exit(1);
}
// Generate README content
const readmeContent = await generateReadme(repoRoot);
// Write README to root directory
const readmePath = path.join(repoRoot, 'README.md');
await fs.writeFile(readmePath, readmeContent, 'utf-8');
console.log(chalk.green(`📝 Generated README.md at ${readmePath}`));
} catch (error) {
console.error(chalk.red('❌ README generation failed:'), error.message);
process.exit(1);
}
}
async function generateReadme(ctfRoot) {
// Find all competitions and their challenge data
const competitions = await findCompetitionData(ctfRoot);
// Get current time in Norwegian time (UTC+1)
const now = new Date();
const utcOffset = now.getTimezoneOffset();
const norwegianOffset = -60; // UTC+1 in minutes
const timezoneDiff = norwegianOffset - (-utcOffset);
const norwegianTime = new Date(now.getTime() + timezoneDiff * 60 * 1000);
const formattedTime = norwegianTime.toISOString().replace('T', ' ').slice(0, 19);
// Generate README content
let readme = `# CTF Competitions Progress Tracker
> Last updated: ${formattedTime}
`;
// Add section for each competition
const sortedCompetitions = Object.keys(competitions).sort();
for (const compName of sortedCompetitions) {
const compData = competitions[compName];
const categories = compData.categories;
// Calculate competition stats
let compChallenges = 0;
let compCompleted = 0;
let compPoints = 0;
for (const catNum in categories) {
const category = categories[catNum];
compChallenges += category.challenges.length;
compCompleted += category.challenges.filter(c => c.isCompleted).length;
compPoints += category.challenges.reduce((sum, c) => sum + (c.isCompleted ? c.points : 0), 0);
}
// Add competition header and stats
const progressPercentage = compChallenges ? Math.round((compCompleted / compChallenges) * 100) : 0;
readme += `## ${compName}
**Progress:** ${compCompleted}/${compChallenges} challenges (${progressPercentage}%)
**Points:** ${compPoints}
`;
// Add category sections, sorted by category number
const sortedCategories = Object.keys(categories).sort();
for (const catNum of sortedCategories) {
const category = categories[catNum];
const categoryName = category.name;
const challenges = category.challenges.sort((a, b) => a.num.localeCompare(b.num));
if (challenges.length === 0) {
continue; // Skip empty categories
}
const completedInCategory = challenges.filter(c => c.isCompleted).length;
const categoryPoints = challenges.reduce((sum, c) => sum + (c.isCompleted ? c.points : 0), 0);
readme += `### ${categoryName} (${completedInCategory}/${challenges.length} - ${categoryPoints} pts)
| # | Challenge | Points | Status | Solver | Flag |
|:---:|:----------|:------:|:------:|:-------|:------|
`;
for (const challenge of challenges) {
const status = challenge.isCompleted ? '✅' : '❌';
const pointsDisplay = challenge.points ? `${challenge.points}` : 'TBD';
// Handle flag display with tooltips for long flags
let flagDisplay;
if (!challenge.flag || challenge.flag === 'TBD') {
flagDisplay = challenge.flag === 'TBD' ? '`TBD`' : 'No flag';
} else if (challenge.flag.length > 30) {
// Use HTML with title attribute for tooltip on hover
const escapedFlag = challenge.flag.replace(/"/g, '"');
flagDisplay = `<code title="${escapedFlag}">${challenge.flag.slice(0, 27)}...</code>`;
} else {
flagDisplay = `\`${challenge.flag}\``;
}
// Format challenge name with shorter display if needed
const challengeName = challenge.name;
const challengeDisplay = challengeName.length > 25
? `${challengeName.slice(0, 22)}...`
: challengeName;
// Add solver to the output
const solver = challenge.solver || 'Unknown';
readme += `| ${challenge.num} | [${challengeDisplay}](${challenge.path}/writeup.md) | ${pointsDisplay} | ${status} | ${solver} | ${flagDisplay} |\n`;
}
readme += '\n';
}
// Add separator between competitions except for the last one
if (compName !== sortedCompetitions[sortedCompetitions.length - 1]) {
readme += '---\n\n';
}
}
return readme;
}
async function findCompetitionData(ctfRoot) {
const competitions = {};
const categoryPattern = /^\d{2}_/;
// Load configuration to check for parent directory
const config = await loadConfig();
const parentDir = config && config.parentDir ? config.parentDir : null;
// First, check if we have a parent directory
let competitionsRoot = ctfRoot;
if (parentDir) {
const parentDirPath = path.join(ctfRoot, parentDir);
if (await fs.pathExists(parentDirPath)) {
competitionsRoot = parentDirPath;
}
}
// Find all directories that could be competitions
let items;
try {
items = await fs.readdir(competitionsRoot, { withFileTypes: true });
} catch (error) {
console.log(chalk.yellow(`⚠️ Could not read directory ${competitionsRoot}: ${error.message}`));
return competitions;
}
for (const item of items) {
if (!item.isDirectory() || item.name.startsWith('.')) {
continue;
}
const itemPath = path.join(competitionsRoot, item.name);
// Check if this directory contains category folders
let hasCategories = false;
try {
const subitems = await fs.readdir(itemPath, { withFileTypes: true });
for (const subitem of subitems) {
if (subitem.isDirectory() && categoryPattern.test(subitem.name)) {
hasCategories = true;
break;
}
}
} catch (error) {
console.log(chalk.yellow(`⚠️ Could not read directory ${itemPath}: ${error.message}`));
continue;
}
if (hasCategories) {
competitions[item.name] = {
path: itemPath,
categories: {}
};
}
}
// For each competition, find all categories and challenges
for (const compName in competitions) {
const compData = competitions[compName];
const compPath = compData.path;
const categories = compData.categories;
// Find all category folders
try {
const items = await fs.readdir(compPath, { withFileTypes: true });
for (const item of items) {
if (item.isDirectory() && categoryPattern.test(item.name)) {
const categoryNum = item.name.slice(0, 2);
const categoryName = item.name.slice(3);
categories[categoryNum] = {
name: categoryName,
path: path.join(compPath, item.name),
challenges: []
};
}
}
// Find all challenge folders within categories
for (const catNum in categories) {
const category = categories[catNum];
const catItems = await fs.readdir(category.path, { withFileTypes: true });
for (const item of catItems) {
if (item.isDirectory()) {
const writeupPath = path.join(category.path, item.name, 'writeup.md');
if (await fs.pathExists(writeupPath)) {
// Extract challenge number if it follows the pattern, otherwise use "00"
const challengeNum = categoryPattern.test(item.name) ? item.name.slice(0, 2) : '00';
try {
const metadata = await extractMetadata(writeupPath, ctfRoot);
category.challenges.push({
num: challengeNum,
path: path.relative(ctfRoot, path.join(category.path, item.name)),
...metadata
});
} catch (error) {
console.log(chalk.yellow(`⚠️ Error processing ${writeupPath}: ${error.message}`));
}
}
}
}
}
} catch (error) {
console.log(chalk.yellow(`⚠️ Could not process competition ${compName}: ${error.message}`));
}
}
if (Object.keys(competitions).length === 0) {
console.log(chalk.yellow('⚠️ No competitions found. Make sure your directory structure is correct.'));
}
return competitions;
}
async function extractMetadata(writeupPath, ctfRoot) {
const content = await fs.readFile(writeupPath, 'utf-8');
// Extract challenge name from first heading
const nameMatch = content.match(/# 🧩 (.+)/);
const name = nameMatch ? nameMatch[1] : 'Unknown Challenge';
// Extract points
const pointsMatch = content.match(/\*\*Points:\*\* (\d+)/);
const points = pointsMatch && pointsMatch[1] !== 'TBD' ? parseInt(pointsMatch[1]) : 0;
// Extract category
const categoryMatch = content.match(/\*\*Category:\*\* (.+)/);
const category = categoryMatch ? categoryMatch[1].trim() : 'Uncategorized';
// Check if flag is present and is a real flag (not a placeholder)
const flagMatch = content.match(/\*\*Flag:\*\* `(.+)`/);
const flag = flagMatch ? flagMatch[1] : '';
// Extract solver from the file
const solverMatch = content.match(/\*\*Solver:\*\* (.+)/);
const fileSolver = solverMatch ? solverMatch[1].trim() : 'Unknown';
// A task is considered completed if:
// 1. A flag exists
// 2. The flag is not "TBD" (which indicates an unsolved challenge)
const isCompleted = flag && flag !== 'TBD';
// If the challenge is completed, try to get the solver from git history
let gitSolver = null;
if (isCompleted) {
gitSolver = await getFlagSolver(ctfRoot, writeupPath);
}
// Prefer the git solver if available, otherwise use the file solver
const solver = gitSolver || fileSolver;
return {
name,
points,
category,
isCompleted,
hasFlag: Boolean(flag),
solver,
flag
};
}
async function getFlagSolver(repoRoot, writeupPath) {
try {
const relativePath = path.relative(repoRoot, writeupPath);
const git = simpleGit(repoRoot);
// Get the git log with changes to the file showing who added/changed the flag
const logOptions = [
'-p', // Show patches
'--follow', // Follow file renames
'-S**Flag:** `', // Look for string changes containing this pattern
'--',
relativePath
];
const logs = await git.log(logOptions);
if (!logs || !logs.all || logs.all.length === 0) {
return null;
}
// Parse the log to find commits that changed the flag from TBD to something else
for (const commit of logs.all) {
// Get the diff for this commit
const diff = await git.show([
commit.hash,
'--format=', // No header, just diff
'-p',
relativePath
]);
// Look for the flag pattern change
const flagPattern = /-\s*\*\*Flag:\*\* `TBD`.*\n\+\s*\*\*Flag:\*\* `([^`]+)`/;
const flagMatch = diff.match(flagPattern);
if (flagMatch) {
return commit.author_name.trim();
}
}
return null;
} catch (error) {
console.log(chalk.yellow(`⚠️ Could not determine solver from git history: ${error.message}`));
return null;
}
}
module.exports = updateReadme;