icoa-cli 2.19.355 → 2.19.357
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ai4ctf.js +1 -1
- package/dist/commands/arena.js +1 -1
- package/dist/commands/ctf.js +787 -1
- package/dist/commands/ctf4ai-demo.js +1 -1
- package/dist/commands/ctf4vla.js +1 -1
- package/dist/commands/demo2.js +1502 -1
- package/dist/commands/exam.js +1 -1
- package/dist/commands/files.js +59 -1
- package/dist/commands/ipynb.d.ts +10 -4
- package/dist/commands/ipynb.js +1 -1
- package/dist/commands/lang.js +202 -1
- package/dist/commands/log.js +171 -1
- package/dist/commands/shell.d.ts +15 -0
- package/dist/commands/shell.js +151 -1
- package/dist/commands/sim.js +389 -1
- package/dist/index.js +355 -1
- package/dist/lib/access.js +184 -1
- package/dist/lib/aienv.js +205 -1
- package/dist/lib/arena-submit.js +21 -1
- package/dist/lib/banner.js +31 -1
- package/dist/lib/budget.js +6 -1
- package/dist/lib/challenge-dir.js +16 -1
- package/dist/lib/colors.js +17 -1
- package/dist/lib/comms.js +212 -1
- package/dist/lib/config.js +93 -1
- package/dist/lib/countdown.js +43 -1
- package/dist/lib/country-lang.js +39 -1
- package/dist/lib/ctfd-client.js +417 -1
- package/dist/lib/demo-exam.js +478 -1
- package/dist/lib/demo-flags.js +27 -1
- package/dist/lib/demo-stats.js +62 -1
- package/dist/lib/demo2-progress.js +102 -1
- package/dist/lib/docker-probe.d.ts +45 -0
- package/dist/lib/docker-probe.js +118 -0
- package/dist/lib/editor-spawn.d.ts +23 -0
- package/dist/lib/editor-spawn.js +53 -0
- package/dist/lib/exam-client.js +54 -1
- package/dist/lib/exam-sandbox.js +201 -1
- package/dist/lib/exam-setup.js +36 -1
- package/dist/lib/exam-state.js +273 -1
- package/dist/lib/gemini.js +247 -1
- package/dist/lib/i18n.js +302 -1
- package/dist/lib/integrity-snapshot.js +88 -1
- package/dist/lib/interactive-spawn.js +55 -1
- package/dist/lib/ipynb-input.js +65 -1
- package/dist/lib/kernel-protocol.js +88 -1
- package/dist/lib/kernel.js +146 -2
- package/dist/lib/learn-curricula.js +309 -1
- package/dist/lib/learn-i18n.js +184 -1
- package/dist/lib/learn-input.js +101 -1
- package/dist/lib/learn-render.js +863 -1
- package/dist/lib/learn-state.js +103 -1
- package/dist/lib/log-sync.js +155 -1
- package/dist/lib/logger.js +49 -1
- package/dist/lib/main-rl.js +7 -1
- package/dist/lib/menu-nav.js +105 -1
- package/dist/lib/notebook-doc.d.ts +38 -0
- package/dist/lib/notebook-doc.js +137 -0
- package/dist/lib/open-file.js +55 -1
- package/dist/lib/paper-upgrade.js +119 -1
- package/dist/lib/platform.js +99 -1
- package/dist/lib/render-card.js +112 -1
- package/dist/lib/repl-asker.js +67 -1
- package/dist/lib/sample-runner.js +227 -1
- package/dist/lib/sandbox.d.ts +25 -1
- package/dist/lib/sandbox.js +144 -1
- package/dist/lib/shell-split.js +69 -1
- package/dist/lib/sim-cooldown.js +75 -1
- package/dist/lib/theme.js +119 -1
- package/dist/lib/token-format.js +74 -1
- package/dist/lib/tool-man.js +418 -1
- package/dist/lib/toolset-hash.js +48 -1
- package/dist/lib/translation.js +80 -1
- package/dist/lib/translations-fetcher.js +95 -1
- package/dist/lib/ui.js +99 -1
- package/dist/lib/update-check.js +114 -1
- package/dist/lib/version.js +24 -1
- package/dist/postinstall.js +48 -1
- package/dist/repl.js +2391 -1
- package/dist/types/index.js +63 -1
- package/package.json +1 -1
package/dist/commands/ctf.js
CHANGED
|
@@ -1 +1,787 @@
|
|
|
1
|
-
import chalk from"chalk";import{CTFdClient as e}from"../lib/ctfd-client.js";import{getConfig as o,saveConfig as t}from"../lib/config.js";import{postIntegritySnapshot as n}from"../lib/integrity-snapshot.js";import{logCommand as i,logSubmission as s}from"../lib/logger.js";import{getTranslation as a}from"../lib/translation.js";import{isLeaderAccount as l,isLeaderToken as c,leaderAuth as r}from"../lib/comms.js";import{printSuccess as d,printError as g,printWarning as m,printInfo as p,printTable as y,printMarkdown as h,printHeader as u,printKeyValue as w,createSpinner as f,formatCountdown as b}from"../lib/ui.js";import{eaiArenaTask as S,printEaiArenaGuide as v}from"./connect.js";export function detectFlagFormat(e){if(!e)return null;const o=e.match(/flag\s*format\s*[::]?\s*`?([A-Za-z][A-Za-z0-9_]{0,20}\{[^}\n]{1,60}\})`?/i);return o?o[1]:null}function C(){const t=o();t.ctfdUrl&&(t.token||t.sessionCookie)||(g("Not connected to CTFd. Run: join <url>"),process.exit(1));const n=t.sessionCookie||"",i=t.token&&!t.token.includes("session=")?t.token:"";return{config:t,client:new e(t.ctfdUrl,i,n||t.token)}}export function registerCtfCommands(k){const I=k.command("ctf").description("Competition commands");I.command("join <url>").description("Connect to a CTFd instance").action(async n=>{let s=n.trim().replace(/\/+$/,"");function a(e,o){return new Promise(t=>{process.stdout.write(chalk.white(e));const n=!!process.stdin.isTTY&&!!process.stdin.isRaw,i=process.stdin.listeners("data").slice(),s=process.stdin.listeners("keypress").slice();for(const e of i)process.stdin.removeListener("data",e);for(const e of s)process.stdin.removeListener("keypress",e);process.stdin.isTTY&&process.stdin.setRawMode?.(!0),process.stdin.resume();let a="";const l=e=>{const c=e.toString(),r=e=>{process.stdin.isTTY&&process.stdin.setRawMode?.(n),process.stdin.removeListener("data",l);for(const e of i)process.stdin.on("data",e);for(const e of s)process.stdin.on("keypress",e);process.stdout.write("\n"),t(e)};"\n"===c||"\r"===c?r(a):""===c||"\b"===c?a.length>0&&(a=a.slice(0,-1),process.stdout.write("\b \b")):""===c?r(""):(a+=c,process.stdout.write(o||c))};process.stdin.on("data",l)})}s.startsWith("http://")||s.startsWith("https://")||(s=`https://${s}`),i(`ctf join ${s}`),console.log(),p(`Connecting to ${chalk.bold(s)}`);const y=await a(" Username: ");if(c(y)){const e=f("Verifying leader token...");e.start();const o=await r(s,y);return o?(e.succeed("Leader verified"),t({ctfdUrl:s,token:"",sessionCookie:"",leaderToken:y.trim(),leaderTeam:o.team,userName:o.account,userId:null,teamId:null,joinedAt:(new Date).toISOString()}),console.log(),w("Leader",o.account),o.team&&w("Team",o.team),console.log(),console.log(chalk.yellow(" ─────────────────────────────────────────────")),console.log(chalk.bold.yellow(" Team Leader console")),console.log(),console.log(chalk.white(" scoreboard")+chalk.gray(" Live team rankings")),console.log(chalk.white(" jury")+chalk.gray(" File a clarification / appeal (recorded)")),console.log(chalk.white(" news")+chalk.gray(" Latest organizer broadcast")),console.log(chalk.white(" time")+chalk.gray(" Event clock")),console.log(chalk.white(" logout")+chalk.gray(" Sign out")),console.log(chalk.yellow(" ─────────────────────────────────────────────")),console.log(),void console.log(chalk.gray(" The challenge bracket is not available to leaders."))):(e.fail("Invalid leader token"),void g("That leader token was not accepted. Check the token on your card and retry."))}const h=await a(" Password: ","*");let u="",b="",S="";const v=f("Logging in...");v.start();try{const o=new e(s,""),t=await o.loginWithCredentials(y,h);u=t.token,b=t.session,S=t.csrf,u?v.succeed("Login successful — API token auto-generated"):v.succeed("Login successful (session mode)")}catch(e){return v.fail("Login failed"),void g(e.message)}const C=f("Testing connection...");C.start();try{const n=new e(s,u,b,S),i=await n.testConnection();C.succeed("Connected successfully"),console.log(),w("User",i.name),w("Score",String(i.score||0)),i.team_id&&w("Team ID",String(i.team_id));try{const e=await n.getCompetitionMeta(),o={ctfdUrl:s,token:u||b,userId:i.id,userName:i.name,teamId:i.team_id,sessionCookie:b,country:i.country||"",joinedAt:(new Date).toISOString()};e.start?(o.competitionStartsAt=new Date(1e3*e.start).toISOString(),w("Starts",new Date(1e3*e.start).toLocaleString())):o.competitionStartsAt="",e.end?(o.competitionEndsAt=new Date(1e3*e.end).toISOString(),w("Ends",new Date(1e3*e.end).toLocaleString())):o.competitionEndsAt="";const a=Date.now()/1e3;e.start&&a<e.start?o.competitionState="pre_competition":e.end&&a>e.end?o.competitionState="finished":o.competitionState="live",w("Status",o.competitionState),t(o)}catch{t({ctfdUrl:s,token:u,userId:i.id,userName:i.name,teamId:i.team_id,joinedAt:(new Date).toISOString()})}console.log(),d("Connection saved. You are ready!"),console.log();const a=o().mode||"";l(i.name||"")?(console.log(chalk.yellow(" ─────────────────────────────────────────────")),console.log(chalk.bold.yellow(" Team Leader console")),console.log(),console.log(chalk.white(" scoreboard")+chalk.gray(" Live team rankings")),console.log(chalk.white(" news")+chalk.gray(" Latest organizer broadcast")),console.log(chalk.white(" jury")+chalk.gray(" File a clarification / appeal (recorded)")),console.log(chalk.white(" time")+chalk.gray(" Event clock")),console.log(chalk.white(" status")+chalk.gray(" Event state")),console.log(chalk.white(" logout")+chalk.gray(" Sign out")),console.log(chalk.yellow(" ─────────────────────────────────────────────"))):"olympiad"===a?(console.log(chalk.cyan(" ─────────────────────────────────────────────")),console.log(chalk.bold.white(" How to compete:")),console.log(),console.log(chalk.white(" Step 1 ")+chalk.bold.cyan("challenges")+chalk.gray(" Browse all challenges")),console.log(chalk.white(" Step 2 ")+chalk.bold.cyan("open <id>")+chalk.gray(" Read challenge details")),console.log(chalk.white(" Step 3 ")+chalk.bold.cyan("submit <id> <flag>")+chalk.gray(" Submit your answer")),console.log(),console.log(chalk.bold.yellow(" ★ Need AI help? ")+chalk.bold.cyan("ai4ctf")+chalk.gray(" Chat freely with your AI teammate")),console.log(chalk.bold.yellow(" ★ Stuck on a tool? ")+chalk.bold.cyan("man <tool>")+chalk.gray(" how to use steghide / binwalk / nmap … (")+chalk.cyan("man tools")+chalk.gray(" = list)")),console.log(),console.log(chalk.gray(" More:")),console.log(chalk.white(" scoreboard")+chalk.gray(" Live rankings")),console.log(chalk.white(" status")+chalk.gray(" Your score & timer")),console.log(chalk.white(" memo")+chalk.gray(" Report an issue with a challenge")),console.log(chalk.white(" news")+chalk.gray(" Show the latest organizer broadcast")),console.log(chalk.cyan(" ─────────────────────────────────────────────"))):(console.log(chalk.gray(" Next:")),console.log(chalk.white(" exam list ")+chalk.gray("View available exams")),console.log(chalk.white(" challenges ")+chalk.gray("View CTF challenges")),console.log(chalk.white(" status ")+chalk.gray("Check score & timer")))}catch(e){/ACCOUNT_BLOCKED|banned|not enabled/i.test(e?.message||"")?(C.fail("This account cannot access the competition"),console.log(),g("Signed in OK, but the account is banned / disabled / not enabled. Try a different account, or contact the organizer."),t({ctfdUrl:s})):b?(C.succeed("Connected (session mode — limited API)"),t({ctfdUrl:s,token:b,userName:y,sessionCookie:b}),console.log(),w("User",y),m("API access limited. Some features may not work."),d("Connection saved.")):(C.fail("Connection test failed"),g(e.message),t({ctfdUrl:s}),console.log(),p(`Connection saved. Try: join ${s}`))}}),I.command("logout").description("Disconnect and clear credentials").action(()=>{i("ctf logout"),t({ctfdUrl:"",token:"",sessionCookie:"",userId:null,userName:"",teamId:null,country:"",leaderToken:"",leaderTeam:""}),d("Logged out. Credentials cleared."),console.log(),console.log(chalk.gray(" What now?")),console.log(chalk.white(" join <url>")+chalk.gray(" Re-connect to competition")),console.log(chalk.white(" setup")+chalk.gray(" Switch mode")),console.log(chalk.white(" exit")+chalk.gray(" Quit ICOA CLI"))}),I.command("activate <code>").description("Validate competition code").action(async e=>{i(`ctf activate ${e}`),C();const n=f("Validating competition code...");n.start();try{const i=await fetch(`${o().ctfdUrl}/api/v1/icoa/activate`,{method:"POST",headers:{Authorization:`Token ${o().token}`,"Content-Type":"application/json"},body:JSON.stringify({code:e})});if(i.ok){const s=await i.json();n.succeed("Competition code validated"),t({competitionCode:e,teamId:s.data?.team_id||o().teamId,competitionState:"live"}),d(`Activated: ${e}`)}else n.succeed("Competition code saved"),t({competitionCode:e}),p("Code stored locally. Server validation will be attempted during competition.")}catch{n.succeed("Competition code saved"),t({competitionCode:e}),p("Code stored locally.")}}),I.command("challenges").description("List all challenges").action(async()=>{i("ctf challenges");const{client:e}=C(),o=f("Loading challenges...");o.start();try{const t=await e.getChallenges();if(o.stop(),!t||0===t.length)return void p("No challenges available yet.");const n=new Map;for(const e of t){const o=e.category||"Uncategorized";n.has(o)||n.set(o,[]),n.get(o).push(e)}const i=t.filter(e=>e.solved_by_me).length;u(`Challenges (${i}/${t.length} solved)`);const s={Web:"Find vulnerabilities in websites",Crypto:"Break ciphers & encryption",Reversing:"Analyze compiled programs",Rev:"Analyze compiled programs",Pwn:"Exploit binary vulnerabilities",Forensics:"Investigate digital evidence",Misc:"Creative & mixed challenges",OSINT:"Open-source intelligence gathering",AI:"AI security & adversarial ML",ai4ctf:"Use AI to solve CTF challenges",ctf4ai:"Attack live AI targets (LLM / ML / agents)",ctf4eai:"Red-team embodied-AI robot policies"},a=e=>{if(s[e])return s[e];const o=e.toLowerCase().replace(/\s+practice$/,""),t=Object.keys(s).find(e=>e.toLowerCase()===o);return t?s[t]:""},l=e=>e<=100?chalk.green("Easy"):e<=250?chalk.yellow("Medium"):e<=500?chalk.red("Hard"):chalk.magenta("Expert"),c=new Set(["auditagent"]),r=e=>c.has((e||"").trim().toLowerCase()),d=[...n.entries()].sort(([e],[o])=>e.localeCompare(o)).flatMap(([e,o])=>{const t=a(e);return[[t?`${chalk.cyan.bold(`── ${e} ──`)} ${chalk.gray(t)}`:chalk.cyan.bold(`── ${e} ──`),"","","",""],...o.sort((e,o)=>{const t=r(e.name);return t!==r(o.name)?t?-1:1:e.value-o.value}).map(e=>[String(e.id),e.solved_by_me?chalk.gray.strikethrough(e.name):e.name,e.solved_by_me?chalk.gray(String(e.value)):chalk.yellow(String(e.value)),e.solved_by_me?chalk.gray("--"):l(e.value),e.solved_by_me?chalk.green("✓"):chalk.gray("○")])]});y(["#","Name","Points","Difficulty","Solved"],d),console.log(chalk.gray(` ${t.length} challenges, ${i} solved`)),0===i&&(console.log(),console.log(chalk.gray(" Tip: Start with ")+chalk.green("Easy")+chalk.gray(" challenges! Type ")+chalk.white("open <id>")+chalk.gray(" to read one.")))}catch(e){o.fail("Failed to load challenges"),g(e.message)}}),I.command("open <id>").description("View challenge details").action(async e=>{i(`ctf open ${e}`);const{config:o,client:n}=C(),s=f("Loading challenge...");s.start();try{const i=await n.getChallenge(parseInt(e,10));s.stop(),t({currentChallengeId:i.id,currentChallengeName:i.name,currentChallengeCategory:i.category});const l=S(i);u(`${i.name} [${i.category}]`),w("Points",String(i.value)),w("Solves",String(i.solves)),i.connection_info&&!l&&w("Connection",i.connection_info),console.log();let c=i.description;if("en"!==o.language)try{c=await a(c,i.id,o.language)}catch{}h(c);const r=detectFlagFormat(i.description);if(console.log(),r?console.log(chalk.bold.white(" Flag format: ")+chalk.green(r)):console.log(chalk.bold.white(" Flag format: ")+chalk.gray("submit the flag exactly as you receive it, including its ")+chalk.green("wrapper{...}")+chalk.gray(" — don't retype or add a prefix.")),i.files&&i.files.length>0&&(console.log(),p(`Files (${i.files.length}): use ${chalk.white(`files ${e}`)} to download`)),i.hints&&i.hints.length>0&&p(`CTFd Hints available: ${i.hints.length}`),l)return console.log(),void v(e,l);i.connection_info&&(console.log(),p(`Quick connect: ${chalk.white(`connect ${e}`)}`));const d=!!i.connection_info&&/^https?:\/\//i.test(i.connection_info.trim());console.log(),console.log(chalk.gray(" ─────────────────────────────────────────────")),console.log(chalk.bold.white(" What to do next:")),d&&console.log(chalk.white(` connect ${e}`)+chalk.gray(" How to attack & submit — full guide")),console.log(chalk.white(' hint "how to start?"')+chalk.gray(" Ask the AI teammate (if enabled this round)")),i.files&&i.files.length>0&&console.log(chalk.white(` files ${e}`)+chalk.gray(" Download challenge files")),i.connection_info&&!d&&console.log(chalk.white(` connect ${e}`)+chalk.gray(" Connect to target")),console.log(chalk.white(` submit ${e} <flag>`)+chalk.gray(" Submit your answer (paste the full flag with its wrapper)")),console.log(chalk.gray(" ─────────────────────────────────────────────"))}catch(e){s.fail("Failed to load challenge"),g(e.message)}}),I.command("submit <id> <flag>").description("Submit a flag").action(async(e,o)=>{i(`ctf submit ${e}`),s(parseInt(e,10),o);const{client:t}=C(),a=f("Submitting flag...");a.start();try{const i=await t.submitFlag(parseInt(e,10),o);switch(a.stop(),i.status){case"correct":console.log(),console.log(chalk.green.bold(" 🎉 Correct! ")+chalk.white(i.message)),console.log();break;case"incorrect":g(`Incorrect. ${i.message}`);break;case"already_solved":m("Already solved.");break;case"paused":m("Competition is paused.");break;case"ratelimited":m("Rate limited. Please wait before trying again.");break;default:p(i.message||"Unknown response")}await n()}catch(e){a.fail("Submission failed"),g(e.message)}}),I.command("scoreboard [top]").description("View scoreboard (optional: top N)").action(async e=>{i("ctf scoreboard");const{config:o,client:t}=C(),n=f("Loading scoreboard...");n.start();try{const e=await t.getScoreboard();if(n.stop(),!e||0===e.length)return void p("Scoreboard is empty.");u("Scoreboard");const i=e.map(e=>{const t=e.account_id===o.teamId,n=[String(e.pos),e.name,String(e.score)];return t?n.map(e=>chalk.yellow.bold(e)):n});y(["Rank","Team","Score"],i)}catch(e){n.fail("Failed to load scoreboard"),g(e.message)}}),I.command("status").description("Show competition status").action(async()=>{i("ctf status");const{config:e,client:o}=C();u("Competition Status");const t={pre_competition:chalk.yellow,demo:chalk.blue,live:chalk.green,finished:chalk.red,unknown:chalk.gray},n=e.competitionState||"unknown",s=t[n]||chalk.gray;console.log(` ${chalk.gray("State:")} ${chalk.bold(s({pre_competition:"PRE-COMPETITION",demo:"DEMO",live:"LIVE",finished:"FINISHED",unknown:"UNKNOWN"}[n]||n))}`);try{const e=await o.testConnection();w("User",chalk.white.bold(e.name)),w("Score",chalk.yellow.bold(String(e.score||0))),w("Rank",chalk.cyan(e.place||"N/A"))}catch{w("User",e.userName||"Unknown")}if(e.competitionEndsAt){console.log();const o=new Date(e.competitionEndsAt);new Date<o?w("Time Remaining",chalk.yellow.bold(b(o))):w("Time",chalk.red("Competition ended"))}console.log()}),I.command("time").description("Show competition countdown").action(async()=>{i("ctf time");let n=o();if(n.ctfdUrl)try{const i=new e(n.ctfdUrl,n.token,n.sessionCookie),s=await i.getCompetitionMeta(),a={};a.competitionStartsAt=s.start?new Date(1e3*s.start).toISOString():"",a.competitionEndsAt=s.end?new Date(1e3*s.end).toISOString():"",t(a),n=o()}catch{}if(!n.competitionStartsAt&&!n.competitionEndsAt)return p("Competition times not configured."),void p("They will be set when the competition server provides timing information.");const s=new Date,a=n.competitionStartsAt?new Date(n.competitionStartsAt):null,l=n.competitionEndsAt?new Date(n.competitionEndsAt):null;u("Competition Timer"),a&&s<a?(console.log(chalk.yellow(" Competition has not started yet.")),console.log(),console.log(` ${chalk.bold("Starts in:")} ${b(a)}`)):l&&s<l?(console.log(chalk.green(" Competition is LIVE")),console.log(),console.log(` ${chalk.bold("Time remaining:")} ${b(l)}`),console.log(),console.log(chalk.gray(" This is the shared event clock. If you are on a timed exam")),console.log(chalk.gray(" paper, your personal deadline is shown by ")+chalk.white("exam info")+chalk.gray("."))):console.log(chalk.red(" Competition has ended."))})}
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { CTFdClient } from '../lib/ctfd-client.js';
|
|
3
|
+
import { getConfig, saveConfig } from '../lib/config.js';
|
|
4
|
+
import { postIntegritySnapshot } from '../lib/integrity-snapshot.js';
|
|
5
|
+
import { logCommand, logSubmission } from '../lib/logger.js';
|
|
6
|
+
import { getTranslation } from '../lib/translation.js';
|
|
7
|
+
import { isLeaderAccount, isLeaderToken, leaderAuth } from '../lib/comms.js';
|
|
8
|
+
import { printSuccess, printError, printWarning, printInfo, printTable, printMarkdown, printHeader, printKeyValue, createSpinner, formatCountdown, } from '../lib/ui.js';
|
|
9
|
+
import { eaiArenaTask, printEaiArenaGuide } from './connect.js';
|
|
10
|
+
// Pull an explicit flag-format hint out of a challenge description so `open`
|
|
11
|
+
// can show contestants the exact wrapper to submit (the prefix is instance-
|
|
12
|
+
// specific — pecan{...} on practice, ICOA{...} at the real comp — so we never
|
|
13
|
+
// hardcode it). Only matches an EXPLICIT "flag format: name{...}" statement to
|
|
14
|
+
// avoid grabbing unrelated braces (code snippets, set/dict literals).
|
|
15
|
+
export function detectFlagFormat(desc) {
|
|
16
|
+
if (!desc)
|
|
17
|
+
return null;
|
|
18
|
+
const m = desc.match(/flag\s*format\s*[::]?\s*`?([A-Za-z][A-Za-z0-9_]{0,20}\{[^}\n]{1,60}\})`?/i);
|
|
19
|
+
return m ? m[1] : null;
|
|
20
|
+
}
|
|
21
|
+
function requireConnection() {
|
|
22
|
+
const config = getConfig();
|
|
23
|
+
if (!config.ctfdUrl || (!config.token && !config.sessionCookie)) {
|
|
24
|
+
printError('Not connected to CTFd. Run: join <url>');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const session = config.sessionCookie || '';
|
|
28
|
+
const token = config.token && !config.token.includes('session=') ? config.token : '';
|
|
29
|
+
return { config, client: new CTFdClient(config.ctfdUrl, token, session || config.token) };
|
|
30
|
+
}
|
|
31
|
+
export function registerCtfCommands(program) {
|
|
32
|
+
const ctf = program.command('ctf').description('Competition commands');
|
|
33
|
+
// ─── icoa ctf join <url> ───
|
|
34
|
+
ctf
|
|
35
|
+
.command('join <url>')
|
|
36
|
+
.description('Connect to a CTFd instance')
|
|
37
|
+
.action(async (rawUrl) => {
|
|
38
|
+
// Auto-add https:// if missing
|
|
39
|
+
let url = rawUrl.trim().replace(/\/+$/, '');
|
|
40
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
41
|
+
url = `https://${url}`;
|
|
42
|
+
}
|
|
43
|
+
logCommand(`ctf join ${url}`);
|
|
44
|
+
console.log();
|
|
45
|
+
printInfo(`Connecting to ${chalk.bold(url)}`);
|
|
46
|
+
// Read username and password using raw stdin. rl.pause() in the REPL is
|
|
47
|
+
// not enough on its own: the parent readline's keypress handler stays
|
|
48
|
+
// attached to stdin, and stdin.resume() below re-routes data to BOTH
|
|
49
|
+
// that handler and our onData — every keystroke would echo twice.
|
|
50
|
+
// Snapshot stdin's data/keypress listeners, detach while we read, restore
|
|
51
|
+
// on completion. Preserve the original isRaw so the REPL keeps working.
|
|
52
|
+
function rawInput(prompt, mask) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
process.stdout.write(chalk.white(prompt));
|
|
55
|
+
const wasRaw = process.stdin.isTTY ? !!process.stdin.isRaw : false;
|
|
56
|
+
const savedData = process.stdin.listeners('data').slice();
|
|
57
|
+
const savedKeypress = process.stdin.listeners('keypress').slice();
|
|
58
|
+
for (const l of savedData)
|
|
59
|
+
process.stdin.removeListener('data', l);
|
|
60
|
+
for (const l of savedKeypress)
|
|
61
|
+
process.stdin.removeListener('keypress', l);
|
|
62
|
+
if (process.stdin.isTTY)
|
|
63
|
+
process.stdin.setRawMode?.(true);
|
|
64
|
+
process.stdin.resume();
|
|
65
|
+
let buf = '';
|
|
66
|
+
const onData = (ch) => {
|
|
67
|
+
const c = ch.toString();
|
|
68
|
+
const finish = (value) => {
|
|
69
|
+
if (process.stdin.isTTY)
|
|
70
|
+
process.stdin.setRawMode?.(wasRaw);
|
|
71
|
+
process.stdin.removeListener('data', onData);
|
|
72
|
+
for (const l of savedData)
|
|
73
|
+
process.stdin.on('data', l);
|
|
74
|
+
for (const l of savedKeypress)
|
|
75
|
+
process.stdin.on('keypress', l);
|
|
76
|
+
process.stdout.write('\n');
|
|
77
|
+
resolve(value);
|
|
78
|
+
};
|
|
79
|
+
if (c === '\n' || c === '\r') {
|
|
80
|
+
finish(buf);
|
|
81
|
+
}
|
|
82
|
+
else if (c === '\x7f' || c === '\b') {
|
|
83
|
+
if (buf.length > 0) {
|
|
84
|
+
buf = buf.slice(0, -1);
|
|
85
|
+
process.stdout.write('\b \b');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (c === '\x03') {
|
|
89
|
+
finish('');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
buf += c;
|
|
93
|
+
process.stdout.write(mask || c);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
process.stdin.on('data', onData);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const username = await rawInput(' Username: ');
|
|
100
|
+
// ─── Team-leader token path ───
|
|
101
|
+
// A leader pastes their leader token at the Username prompt. We validate it
|
|
102
|
+
// against token-api (NO CTFd login → no web session → challenge bracket
|
|
103
|
+
// unreachable), store a leader session, and stop. Account ↔ team are
|
|
104
|
+
// server-resolved from the token (un-forgeable).
|
|
105
|
+
if (isLeaderToken(username)) {
|
|
106
|
+
const spinnerL = createSpinner('Verifying leader token...');
|
|
107
|
+
spinnerL.start();
|
|
108
|
+
const lead = await leaderAuth(url, username);
|
|
109
|
+
if (!lead) {
|
|
110
|
+
spinnerL.fail('Invalid leader token');
|
|
111
|
+
printError('That leader token was not accepted. Check the token on your card and retry.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
spinnerL.succeed('Leader verified');
|
|
115
|
+
saveConfig({
|
|
116
|
+
ctfdUrl: url,
|
|
117
|
+
token: '', // leaders hold NO CTFd credential
|
|
118
|
+
sessionCookie: '',
|
|
119
|
+
leaderToken: username.trim(),
|
|
120
|
+
leaderTeam: lead.team,
|
|
121
|
+
userName: lead.account,
|
|
122
|
+
userId: null,
|
|
123
|
+
teamId: null,
|
|
124
|
+
joinedAt: new Date().toISOString(),
|
|
125
|
+
});
|
|
126
|
+
console.log();
|
|
127
|
+
printKeyValue('Leader', lead.account);
|
|
128
|
+
if (lead.team)
|
|
129
|
+
printKeyValue('Team', lead.team);
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(chalk.yellow(' ─────────────────────────────────────────────'));
|
|
132
|
+
console.log(chalk.bold.yellow(' Team Leader console'));
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(chalk.white(' scoreboard') + chalk.gray(' Live team rankings'));
|
|
135
|
+
console.log(chalk.white(' jury') + chalk.gray(' File a clarification / appeal (recorded)'));
|
|
136
|
+
console.log(chalk.white(' news') + chalk.gray(' Latest organizer broadcast'));
|
|
137
|
+
console.log(chalk.white(' time') + chalk.gray(' Event clock'));
|
|
138
|
+
console.log(chalk.white(' logout') + chalk.gray(' Sign out'));
|
|
139
|
+
console.log(chalk.yellow(' ─────────────────────────────────────────────'));
|
|
140
|
+
console.log();
|
|
141
|
+
console.log(chalk.gray(' The challenge bracket is not available to leaders.'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const password = await rawInput(' Password: ', '*');
|
|
145
|
+
let token = '';
|
|
146
|
+
let sessionCookie = '';
|
|
147
|
+
let csrfNonce = '';
|
|
148
|
+
const spinner = createSpinner('Logging in...');
|
|
149
|
+
spinner.start();
|
|
150
|
+
try {
|
|
151
|
+
const client = new CTFdClient(url, '');
|
|
152
|
+
const result = await client.loginWithCredentials(username, password);
|
|
153
|
+
token = result.token;
|
|
154
|
+
sessionCookie = result.session;
|
|
155
|
+
csrfNonce = result.csrf;
|
|
156
|
+
if (token) {
|
|
157
|
+
spinner.succeed('Login successful — API token auto-generated');
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
spinner.succeed('Login successful (session mode)');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
spinner.fail('Login failed');
|
|
165
|
+
printError(err.message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Test connection
|
|
169
|
+
const spinner2 = createSpinner('Testing connection...');
|
|
170
|
+
spinner2.start();
|
|
171
|
+
try {
|
|
172
|
+
const client = new CTFdClient(url, token, sessionCookie, csrfNonce);
|
|
173
|
+
const user = await client.testConnection();
|
|
174
|
+
spinner2.succeed('Connected successfully');
|
|
175
|
+
console.log();
|
|
176
|
+
printKeyValue('User', user.name);
|
|
177
|
+
printKeyValue('Score', String(user.score || 0));
|
|
178
|
+
if (user.team_id) {
|
|
179
|
+
printKeyValue('Team ID', String(user.team_id));
|
|
180
|
+
}
|
|
181
|
+
// Fetch competition metadata (times, mode)
|
|
182
|
+
try {
|
|
183
|
+
const meta = await client.getCompetitionMeta();
|
|
184
|
+
const configUpdate = {
|
|
185
|
+
ctfdUrl: url,
|
|
186
|
+
token: token || sessionCookie,
|
|
187
|
+
userId: user.id,
|
|
188
|
+
userName: user.name,
|
|
189
|
+
teamId: user.team_id,
|
|
190
|
+
sessionCookie: sessionCookie,
|
|
191
|
+
country: user.country || '',
|
|
192
|
+
joinedAt: new Date().toISOString(),
|
|
193
|
+
};
|
|
194
|
+
// Mirror the server exactly: when the server reports a time, save it;
|
|
195
|
+
// when it reports none (null), CLEAR any stale cached value. Otherwise
|
|
196
|
+
// a once-set end time lingers forever and `time` shows a bogus
|
|
197
|
+
// countdown against a deadline the server no longer has.
|
|
198
|
+
if (meta.start) {
|
|
199
|
+
configUpdate.competitionStartsAt = new Date(meta.start * 1000).toISOString();
|
|
200
|
+
printKeyValue('Starts', new Date(meta.start * 1000).toLocaleString());
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
configUpdate.competitionStartsAt = '';
|
|
204
|
+
}
|
|
205
|
+
if (meta.end) {
|
|
206
|
+
configUpdate.competitionEndsAt = new Date(meta.end * 1000).toISOString();
|
|
207
|
+
printKeyValue('Ends', new Date(meta.end * 1000).toLocaleString());
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
configUpdate.competitionEndsAt = '';
|
|
211
|
+
}
|
|
212
|
+
// Determine competition state
|
|
213
|
+
const now = Date.now() / 1000;
|
|
214
|
+
if (meta.start && now < meta.start) {
|
|
215
|
+
configUpdate.competitionState = 'pre_competition';
|
|
216
|
+
}
|
|
217
|
+
else if (meta.end && now > meta.end) {
|
|
218
|
+
configUpdate.competitionState = 'finished';
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
configUpdate.competitionState = 'live';
|
|
222
|
+
}
|
|
223
|
+
printKeyValue('Status', configUpdate.competitionState);
|
|
224
|
+
saveConfig(configUpdate);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Fallback: save without competition times
|
|
228
|
+
saveConfig({
|
|
229
|
+
ctfdUrl: url,
|
|
230
|
+
token: token,
|
|
231
|
+
userId: user.id,
|
|
232
|
+
userName: user.name,
|
|
233
|
+
teamId: user.team_id,
|
|
234
|
+
joinedAt: new Date().toISOString(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
console.log();
|
|
238
|
+
printSuccess('Connection saved. You are ready!');
|
|
239
|
+
console.log();
|
|
240
|
+
const currentMode = getConfig().mode || '';
|
|
241
|
+
const isLeader = isLeaderAccount(user.name || '');
|
|
242
|
+
if (isLeader) {
|
|
243
|
+
console.log(chalk.yellow(' ─────────────────────────────────────────────'));
|
|
244
|
+
console.log(chalk.bold.yellow(' Team Leader console'));
|
|
245
|
+
console.log();
|
|
246
|
+
console.log(chalk.white(' scoreboard') + chalk.gray(' Live team rankings'));
|
|
247
|
+
console.log(chalk.white(' news') + chalk.gray(' Latest organizer broadcast'));
|
|
248
|
+
console.log(chalk.white(' jury') + chalk.gray(' File a clarification / appeal (recorded)'));
|
|
249
|
+
console.log(chalk.white(' time') + chalk.gray(' Event clock'));
|
|
250
|
+
console.log(chalk.white(' status') + chalk.gray(' Event state'));
|
|
251
|
+
console.log(chalk.white(' logout') + chalk.gray(' Sign out'));
|
|
252
|
+
console.log(chalk.yellow(' ─────────────────────────────────────────────'));
|
|
253
|
+
}
|
|
254
|
+
else if (currentMode === 'olympiad') {
|
|
255
|
+
// Olympiad: guided step-by-step walkthrough
|
|
256
|
+
console.log(chalk.cyan(' ─────────────────────────────────────────────'));
|
|
257
|
+
console.log(chalk.bold.white(' How to compete:'));
|
|
258
|
+
console.log();
|
|
259
|
+
console.log(chalk.white(' Step 1 ') + chalk.bold.cyan('challenges') + chalk.gray(' Browse all challenges'));
|
|
260
|
+
console.log(chalk.white(' Step 2 ') + chalk.bold.cyan('open <id>') + chalk.gray(' Read challenge details'));
|
|
261
|
+
console.log(chalk.white(' Step 3 ') + chalk.bold.cyan('submit <id> <flag>') + chalk.gray(' Submit your answer'));
|
|
262
|
+
console.log();
|
|
263
|
+
console.log(chalk.bold.yellow(' ★ Need AI help? ') +
|
|
264
|
+
chalk.bold.cyan('ai4ctf') +
|
|
265
|
+
chalk.gray(' Chat freely with your AI teammate'));
|
|
266
|
+
console.log(chalk.bold.yellow(' ★ Stuck on a tool? ') +
|
|
267
|
+
chalk.bold.cyan('man <tool>') +
|
|
268
|
+
chalk.gray(' how to use steghide / binwalk / nmap … (') +
|
|
269
|
+
chalk.cyan('man tools') +
|
|
270
|
+
chalk.gray(' = list)'));
|
|
271
|
+
console.log();
|
|
272
|
+
console.log(chalk.gray(' More:'));
|
|
273
|
+
console.log(chalk.white(' scoreboard') + chalk.gray(' Live rankings'));
|
|
274
|
+
console.log(chalk.white(' status') + chalk.gray(' Your score & timer'));
|
|
275
|
+
console.log(chalk.white(' memo') + chalk.gray(' Report an issue with a challenge'));
|
|
276
|
+
console.log(chalk.white(' news') + chalk.gray(' Show the latest organizer broadcast'));
|
|
277
|
+
console.log(chalk.cyan(' ─────────────────────────────────────────────'));
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
console.log(chalk.gray(' Next:'));
|
|
281
|
+
console.log(chalk.white(' exam list ') + chalk.gray('View available exams'));
|
|
282
|
+
console.log(chalk.white(' challenges ') + chalk.gray('View CTF challenges'));
|
|
283
|
+
console.log(chalk.white(' status ') + chalk.gray('Check score & timer'));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
// A banned / not-enabled account logs in fine (302) but 403s on every API
|
|
288
|
+
// call. Do NOT mislabel that as a usable "limited API" session — it isn't,
|
|
289
|
+
// and re-running join with the same account won't help. Tell the user to
|
|
290
|
+
// switch accounts. (Confirmed root cause of the mel banned-ZZCP login: the
|
|
291
|
+
// session is valid, the account is blocked.)
|
|
292
|
+
const blocked = /ACCOUNT_BLOCKED|banned|not enabled/i.test(err?.message || '');
|
|
293
|
+
if (blocked) {
|
|
294
|
+
spinner2.fail('This account cannot access the competition');
|
|
295
|
+
console.log();
|
|
296
|
+
printError('Signed in OK, but the account is banned / disabled / not enabled. ' +
|
|
297
|
+
'Try a different account, or contact the organizer.');
|
|
298
|
+
saveConfig({ ctfdUrl: url });
|
|
299
|
+
}
|
|
300
|
+
else if (sessionCookie) {
|
|
301
|
+
// Session login succeeded but API test failed — save anyway
|
|
302
|
+
spinner2.succeed('Connected (session mode — limited API)');
|
|
303
|
+
saveConfig({
|
|
304
|
+
ctfdUrl: url,
|
|
305
|
+
token: sessionCookie,
|
|
306
|
+
userName: username,
|
|
307
|
+
sessionCookie: sessionCookie,
|
|
308
|
+
});
|
|
309
|
+
console.log();
|
|
310
|
+
printKeyValue('User', username);
|
|
311
|
+
printWarning('API access limited. Some features may not work.');
|
|
312
|
+
printSuccess('Connection saved.');
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
spinner2.fail('Connection test failed');
|
|
316
|
+
printError(err.message);
|
|
317
|
+
saveConfig({ ctfdUrl: url });
|
|
318
|
+
console.log();
|
|
319
|
+
printInfo(`Connection saved. Try: join ${url}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
// ─── icoa ctf logout ───
|
|
324
|
+
ctf
|
|
325
|
+
.command('logout')
|
|
326
|
+
.description('Disconnect and clear credentials')
|
|
327
|
+
.action(() => {
|
|
328
|
+
logCommand('ctf logout');
|
|
329
|
+
saveConfig({
|
|
330
|
+
ctfdUrl: '',
|
|
331
|
+
token: '',
|
|
332
|
+
sessionCookie: '',
|
|
333
|
+
userId: null,
|
|
334
|
+
userName: '',
|
|
335
|
+
teamId: null,
|
|
336
|
+
country: '',
|
|
337
|
+
leaderToken: '',
|
|
338
|
+
leaderTeam: '',
|
|
339
|
+
});
|
|
340
|
+
printSuccess('Logged out. Credentials cleared.');
|
|
341
|
+
console.log();
|
|
342
|
+
console.log(chalk.gray(' What now?'));
|
|
343
|
+
console.log(chalk.white(' join <url>') + chalk.gray(' Re-connect to competition'));
|
|
344
|
+
console.log(chalk.white(' setup') + chalk.gray(' Switch mode'));
|
|
345
|
+
console.log(chalk.white(' exit') + chalk.gray(' Quit ICOA CLI'));
|
|
346
|
+
});
|
|
347
|
+
// ─── icoa ctf activate <code> ───
|
|
348
|
+
ctf
|
|
349
|
+
.command('activate <code>')
|
|
350
|
+
.description('Validate competition code')
|
|
351
|
+
.action(async (code) => {
|
|
352
|
+
logCommand(`ctf activate ${code}`);
|
|
353
|
+
requireConnection();
|
|
354
|
+
const spinner = createSpinner('Validating competition code...');
|
|
355
|
+
spinner.start();
|
|
356
|
+
try {
|
|
357
|
+
// Try custom ICOA endpoint first
|
|
358
|
+
const res = await fetch(`${getConfig().ctfdUrl}/api/v1/icoa/activate`, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: {
|
|
361
|
+
Authorization: `Token ${getConfig().token}`,
|
|
362
|
+
'Content-Type': 'application/json',
|
|
363
|
+
},
|
|
364
|
+
body: JSON.stringify({ code }),
|
|
365
|
+
});
|
|
366
|
+
if (res.ok) {
|
|
367
|
+
const data = (await res.json());
|
|
368
|
+
spinner.succeed('Competition code validated');
|
|
369
|
+
saveConfig({
|
|
370
|
+
competitionCode: code,
|
|
371
|
+
teamId: data.data?.team_id || getConfig().teamId,
|
|
372
|
+
competitionState: 'live',
|
|
373
|
+
});
|
|
374
|
+
printSuccess(`Activated: ${code}`);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// Fallback: just store the code locally
|
|
378
|
+
spinner.succeed('Competition code saved');
|
|
379
|
+
saveConfig({ competitionCode: code });
|
|
380
|
+
printInfo('Code stored locally. Server validation will be attempted during competition.');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// Server doesn't have custom endpoint, store locally
|
|
385
|
+
spinner.succeed('Competition code saved');
|
|
386
|
+
saveConfig({ competitionCode: code });
|
|
387
|
+
printInfo('Code stored locally.');
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
// ─── icoa ctf challenges ───
|
|
391
|
+
ctf
|
|
392
|
+
.command('challenges')
|
|
393
|
+
.description('List all challenges')
|
|
394
|
+
.action(async () => {
|
|
395
|
+
logCommand('ctf challenges');
|
|
396
|
+
const { client } = requireConnection();
|
|
397
|
+
const spinner = createSpinner('Loading challenges...');
|
|
398
|
+
spinner.start();
|
|
399
|
+
try {
|
|
400
|
+
const challenges = await client.getChallenges();
|
|
401
|
+
spinner.stop();
|
|
402
|
+
if (!challenges || challenges.length === 0) {
|
|
403
|
+
printInfo('No challenges available yet.');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Group by category
|
|
407
|
+
const byCategory = new Map();
|
|
408
|
+
for (const c of challenges) {
|
|
409
|
+
const cat = c.category || 'Uncategorized';
|
|
410
|
+
if (!byCategory.has(cat))
|
|
411
|
+
byCategory.set(cat, []);
|
|
412
|
+
byCategory.get(cat).push(c);
|
|
413
|
+
}
|
|
414
|
+
const solved = challenges.filter((c) => c.solved_by_me).length;
|
|
415
|
+
printHeader(`Challenges (${solved}/${challenges.length} solved)`);
|
|
416
|
+
// Category descriptions for beginners
|
|
417
|
+
const catDesc = {
|
|
418
|
+
Web: 'Find vulnerabilities in websites',
|
|
419
|
+
Crypto: 'Break ciphers & encryption',
|
|
420
|
+
Reversing: 'Analyze compiled programs',
|
|
421
|
+
Rev: 'Analyze compiled programs',
|
|
422
|
+
Pwn: 'Exploit binary vulnerabilities',
|
|
423
|
+
Forensics: 'Investigate digital evidence',
|
|
424
|
+
Misc: 'Creative & mixed challenges',
|
|
425
|
+
OSINT: 'Open-source intelligence gathering',
|
|
426
|
+
AI: 'AI security & adversarial ML',
|
|
427
|
+
ai4ctf: 'Use AI to solve CTF challenges',
|
|
428
|
+
ctf4ai: 'Attack live AI targets (LLM / ML / agents)',
|
|
429
|
+
ctf4eai: 'Red-team embodied-AI robot policies',
|
|
430
|
+
};
|
|
431
|
+
// Resolve a category blurb, tolerating the live track variants
|
|
432
|
+
// ("ctf4ai", "ctf4eai practice", "ai4ctf practice", …) that CTFd serves.
|
|
433
|
+
const describeCat = (cat) => {
|
|
434
|
+
if (catDesc[cat])
|
|
435
|
+
return catDesc[cat];
|
|
436
|
+
const base = cat.toLowerCase().replace(/\s+practice$/, '');
|
|
437
|
+
const key = Object.keys(catDesc).find((k) => k.toLowerCase() === base);
|
|
438
|
+
return key ? catDesc[key] : '';
|
|
439
|
+
};
|
|
440
|
+
// Difficulty based on points
|
|
441
|
+
const difficulty = (pts) => {
|
|
442
|
+
if (pts <= 100)
|
|
443
|
+
return chalk.green('Easy');
|
|
444
|
+
if (pts <= 250)
|
|
445
|
+
return chalk.yellow('Medium');
|
|
446
|
+
if (pts <= 500)
|
|
447
|
+
return chalk.red('Hard');
|
|
448
|
+
return chalk.magenta('Expert');
|
|
449
|
+
};
|
|
450
|
+
// Flagship challenges that must lead their category regardless of point
|
|
451
|
+
// value (e.g. `auditagent` is the ctf4ai showcase target at 3000 pts, so
|
|
452
|
+
// value-ascending sort would otherwise bury it last). Matched on the
|
|
453
|
+
// CTFd challenge name, case-insensitively.
|
|
454
|
+
const pinnedFirst = new Set(['auditagent']);
|
|
455
|
+
const isPinned = (name) => pinnedFirst.has((name || '').trim().toLowerCase());
|
|
456
|
+
// Sort categories and display
|
|
457
|
+
const rows = [...byCategory.entries()]
|
|
458
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
459
|
+
.flatMap(([category, challs]) => {
|
|
460
|
+
const desc = describeCat(category);
|
|
461
|
+
const catLabel = desc
|
|
462
|
+
? `${chalk.cyan.bold(`── ${category} ──`)} ${chalk.gray(desc)}`
|
|
463
|
+
: chalk.cyan.bold(`── ${category} ──`);
|
|
464
|
+
const catRow = [catLabel, '', '', '', ''];
|
|
465
|
+
const challRows = challs
|
|
466
|
+
.sort((a, b) => {
|
|
467
|
+
// Pinned flagship challenges lead, then the rest by point value.
|
|
468
|
+
const pa = isPinned(a.name);
|
|
469
|
+
const pb = isPinned(b.name);
|
|
470
|
+
if (pa !== pb)
|
|
471
|
+
return pa ? -1 : 1;
|
|
472
|
+
return a.value - b.value;
|
|
473
|
+
})
|
|
474
|
+
.map((c) => [
|
|
475
|
+
String(c.id),
|
|
476
|
+
c.solved_by_me ? chalk.gray.strikethrough(c.name) : c.name,
|
|
477
|
+
c.solved_by_me ? chalk.gray(String(c.value)) : chalk.yellow(String(c.value)),
|
|
478
|
+
c.solved_by_me ? chalk.gray('--') : difficulty(c.value),
|
|
479
|
+
c.solved_by_me ? chalk.green('✓') : chalk.gray('○'),
|
|
480
|
+
]);
|
|
481
|
+
return [catRow, ...challRows];
|
|
482
|
+
});
|
|
483
|
+
printTable(['#', 'Name', 'Points', 'Difficulty', 'Solved'], rows);
|
|
484
|
+
console.log(chalk.gray(` ${challenges.length} challenges, ${solved} solved`));
|
|
485
|
+
if (solved === 0) {
|
|
486
|
+
console.log();
|
|
487
|
+
console.log(chalk.gray(' Tip: Start with ') +
|
|
488
|
+
chalk.green('Easy') +
|
|
489
|
+
chalk.gray(' challenges! Type ') +
|
|
490
|
+
chalk.white('open <id>') +
|
|
491
|
+
chalk.gray(' to read one.'));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
spinner.fail('Failed to load challenges');
|
|
496
|
+
printError(err.message);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
// ─── icoa ctf open <id> ───
|
|
500
|
+
ctf
|
|
501
|
+
.command('open <id>')
|
|
502
|
+
.description('View challenge details')
|
|
503
|
+
.action(async (id) => {
|
|
504
|
+
logCommand(`ctf open ${id}`);
|
|
505
|
+
const { config, client } = requireConnection();
|
|
506
|
+
const spinner = createSpinner('Loading challenge...');
|
|
507
|
+
spinner.start();
|
|
508
|
+
try {
|
|
509
|
+
const challenge = await client.getChallenge(parseInt(id, 10));
|
|
510
|
+
spinner.stop();
|
|
511
|
+
// Save current challenge context for hints
|
|
512
|
+
saveConfig({
|
|
513
|
+
currentChallengeId: challenge.id,
|
|
514
|
+
currentChallengeName: challenge.name,
|
|
515
|
+
currentChallengeCategory: challenge.category,
|
|
516
|
+
});
|
|
517
|
+
// ctf4eai arena tasks are CLI-solved (`eai <task>`); their connection_info
|
|
518
|
+
// is just the base URL (manifest path="") and is NOT something to connect
|
|
519
|
+
// to, so don't surface it as a "Connection" target.
|
|
520
|
+
const eaiTask = eaiArenaTask(challenge);
|
|
521
|
+
printHeader(`${challenge.name} [${challenge.category}]`);
|
|
522
|
+
printKeyValue('Points', String(challenge.value));
|
|
523
|
+
printKeyValue('Solves', String(challenge.solves));
|
|
524
|
+
if (challenge.connection_info && !eaiTask) {
|
|
525
|
+
printKeyValue('Connection', challenge.connection_info);
|
|
526
|
+
}
|
|
527
|
+
console.log();
|
|
528
|
+
// Display description (with optional translation)
|
|
529
|
+
let description = challenge.description;
|
|
530
|
+
if (config.language !== 'en') {
|
|
531
|
+
try {
|
|
532
|
+
description = await getTranslation(description, challenge.id, config.language);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// Translation failed, use English
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
printMarkdown(description);
|
|
539
|
+
// Flag format — contestants need to know the exact wrapper to submit.
|
|
540
|
+
// Show the format stated in the challenge text if present; otherwise an
|
|
541
|
+
// honest, instance-agnostic instruction (don't claim it's "shown above"
|
|
542
|
+
// when it isn't).
|
|
543
|
+
const flagFmt = detectFlagFormat(challenge.description);
|
|
544
|
+
console.log();
|
|
545
|
+
if (flagFmt) {
|
|
546
|
+
console.log(chalk.bold.white(' Flag format: ') + chalk.green(flagFmt));
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
console.log(chalk.bold.white(' Flag format: ') +
|
|
550
|
+
chalk.gray('submit the flag exactly as you receive it, including its ') +
|
|
551
|
+
chalk.green('wrapper{...}') +
|
|
552
|
+
chalk.gray(" — don't retype or add a prefix."));
|
|
553
|
+
}
|
|
554
|
+
// Show files
|
|
555
|
+
if (challenge.files && challenge.files.length > 0) {
|
|
556
|
+
console.log();
|
|
557
|
+
printInfo(`Files (${challenge.files.length}): use ${chalk.white(`files ${id}`)} to download`);
|
|
558
|
+
}
|
|
559
|
+
// Show hints
|
|
560
|
+
if (challenge.hints && challenge.hints.length > 0) {
|
|
561
|
+
printInfo(`CTFd Hints available: ${challenge.hints.length}`);
|
|
562
|
+
}
|
|
563
|
+
// ctf4eai arena tasks are CLI-solved (`eai <task>`), NOT a network
|
|
564
|
+
// target. Render the arena guide and skip every `connect`-flavoured hint
|
|
565
|
+
// below, which would falsely tell the student to connect/curl the target.
|
|
566
|
+
if (eaiTask) {
|
|
567
|
+
console.log();
|
|
568
|
+
printEaiArenaGuide(id, eaiTask);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
// Show connection info
|
|
572
|
+
if (challenge.connection_info) {
|
|
573
|
+
console.log();
|
|
574
|
+
printInfo(`Quick connect: ${chalk.white(`connect ${id}`)}`);
|
|
575
|
+
}
|
|
576
|
+
// Show guided next actions. For HTTP AI targets (ctf4ai), `connect` now
|
|
577
|
+
// prints a full attack→submit guide, so make it the PRIMARY step. The AI
|
|
578
|
+
// `hint` is teammate-backed and may be disabled in a given round (e.g. no
|
|
579
|
+
// server AI key), so phrase it honestly rather than as the go-to.
|
|
580
|
+
const httpTarget = !!challenge.connection_info && /^https?:\/\//i.test(challenge.connection_info.trim());
|
|
581
|
+
console.log();
|
|
582
|
+
console.log(chalk.gray(' ─────────────────────────────────────────────'));
|
|
583
|
+
console.log(chalk.bold.white(' What to do next:'));
|
|
584
|
+
if (httpTarget) {
|
|
585
|
+
console.log(chalk.white(` connect ${id}`) + chalk.gray(' How to attack & submit — full guide'));
|
|
586
|
+
}
|
|
587
|
+
console.log(chalk.white(` hint "how to start?"`) + chalk.gray(' Ask the AI teammate (if enabled this round)'));
|
|
588
|
+
if (challenge.files && challenge.files.length > 0) {
|
|
589
|
+
console.log(chalk.white(` files ${id}`) + chalk.gray(' Download challenge files'));
|
|
590
|
+
}
|
|
591
|
+
if (challenge.connection_info && !httpTarget) {
|
|
592
|
+
console.log(chalk.white(` connect ${id}`) + chalk.gray(' Connect to target'));
|
|
593
|
+
}
|
|
594
|
+
console.log(chalk.white(` submit ${id} <flag>`) +
|
|
595
|
+
chalk.gray(' Submit your answer (paste the full flag with its wrapper)'));
|
|
596
|
+
console.log(chalk.gray(' ─────────────────────────────────────────────'));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
spinner.fail('Failed to load challenge');
|
|
600
|
+
printError(err.message);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
// ─── icoa ctf submit <id> <flag> ───
|
|
604
|
+
ctf
|
|
605
|
+
.command('submit <id> <flag>')
|
|
606
|
+
.description('Submit a flag')
|
|
607
|
+
.action(async (id, flag) => {
|
|
608
|
+
logCommand(`ctf submit ${id}`);
|
|
609
|
+
logSubmission(parseInt(id, 10), flag);
|
|
610
|
+
const { client } = requireConnection();
|
|
611
|
+
const spinner = createSpinner('Submitting flag...');
|
|
612
|
+
spinner.start();
|
|
613
|
+
try {
|
|
614
|
+
const result = await client.submitFlag(parseInt(id, 10), flag);
|
|
615
|
+
spinner.stop();
|
|
616
|
+
switch (result.status) {
|
|
617
|
+
case 'correct':
|
|
618
|
+
console.log();
|
|
619
|
+
console.log(chalk.green.bold(' 🎉 Correct! ') + chalk.white(result.message));
|
|
620
|
+
console.log();
|
|
621
|
+
break;
|
|
622
|
+
case 'incorrect':
|
|
623
|
+
printError(`Incorrect. ${result.message}`);
|
|
624
|
+
break;
|
|
625
|
+
case 'already_solved':
|
|
626
|
+
printWarning('Already solved.');
|
|
627
|
+
break;
|
|
628
|
+
case 'paused':
|
|
629
|
+
printWarning('Competition is paused.');
|
|
630
|
+
break;
|
|
631
|
+
case 'ratelimited':
|
|
632
|
+
printWarning('Rate limited. Please wait before trying again.');
|
|
633
|
+
break;
|
|
634
|
+
default:
|
|
635
|
+
printInfo(result.message || 'Unknown response');
|
|
636
|
+
}
|
|
637
|
+
// P1.5 integrity sampling — fire a scrubbed, server-debounced snapshot
|
|
638
|
+
// anchored to this server-recorded submission. Detective-only and
|
|
639
|
+
// self-swallowing; gated to competitionState:'live' inside the helper.
|
|
640
|
+
// Both correct & incorrect submits are real scoring events, so we
|
|
641
|
+
// sample on either — the 5-min debounce lives server-side.
|
|
642
|
+
await postIntegritySnapshot();
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
spinner.fail('Submission failed');
|
|
646
|
+
printError(err.message);
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
// ─── icoa ctf scoreboard ───
|
|
650
|
+
ctf
|
|
651
|
+
.command('scoreboard [top]')
|
|
652
|
+
.description('View scoreboard (optional: top N)')
|
|
653
|
+
.action(async (_top) => {
|
|
654
|
+
logCommand('ctf scoreboard');
|
|
655
|
+
const { config, client } = requireConnection();
|
|
656
|
+
const spinner = createSpinner('Loading scoreboard...');
|
|
657
|
+
spinner.start();
|
|
658
|
+
try {
|
|
659
|
+
const scoreboard = await client.getScoreboard();
|
|
660
|
+
spinner.stop();
|
|
661
|
+
if (!scoreboard || scoreboard.length === 0) {
|
|
662
|
+
printInfo('Scoreboard is empty.');
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
printHeader('Scoreboard');
|
|
666
|
+
const rows = scoreboard.map((entry) => {
|
|
667
|
+
const isMyTeam = entry.account_id === config.teamId;
|
|
668
|
+
const row = [String(entry.pos), entry.name, String(entry.score)];
|
|
669
|
+
if (isMyTeam) {
|
|
670
|
+
return row.map((cell) => chalk.yellow.bold(cell));
|
|
671
|
+
}
|
|
672
|
+
return row;
|
|
673
|
+
});
|
|
674
|
+
printTable(['Rank', 'Team', 'Score'], rows);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
spinner.fail('Failed to load scoreboard');
|
|
678
|
+
printError(err.message);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
// ─── icoa ctf status ───
|
|
682
|
+
ctf
|
|
683
|
+
.command('status')
|
|
684
|
+
.description('Show competition status')
|
|
685
|
+
.action(async () => {
|
|
686
|
+
logCommand('ctf status');
|
|
687
|
+
const { config, client } = requireConnection();
|
|
688
|
+
printHeader('Competition Status');
|
|
689
|
+
// Competition state
|
|
690
|
+
const stateColors = {
|
|
691
|
+
pre_competition: chalk.yellow,
|
|
692
|
+
demo: chalk.blue,
|
|
693
|
+
live: chalk.green,
|
|
694
|
+
finished: chalk.red,
|
|
695
|
+
unknown: chalk.gray,
|
|
696
|
+
};
|
|
697
|
+
const stateLabels = {
|
|
698
|
+
pre_competition: 'PRE-COMPETITION',
|
|
699
|
+
demo: 'DEMO',
|
|
700
|
+
live: 'LIVE',
|
|
701
|
+
finished: 'FINISHED',
|
|
702
|
+
unknown: 'UNKNOWN',
|
|
703
|
+
};
|
|
704
|
+
const state = config.competitionState || 'unknown';
|
|
705
|
+
const colorFn = stateColors[state] || chalk.gray;
|
|
706
|
+
console.log(` ${chalk.gray('State:')} ${chalk.bold(colorFn(stateLabels[state] || state))}`);
|
|
707
|
+
// User info
|
|
708
|
+
try {
|
|
709
|
+
const user = await client.testConnection();
|
|
710
|
+
printKeyValue('User', chalk.white.bold(user.name));
|
|
711
|
+
printKeyValue('Score', chalk.yellow.bold(String(user.score || 0)));
|
|
712
|
+
printKeyValue('Rank', chalk.cyan(user.place || 'N/A'));
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
printKeyValue('User', config.userName || 'Unknown');
|
|
716
|
+
}
|
|
717
|
+
// Time
|
|
718
|
+
if (config.competitionEndsAt) {
|
|
719
|
+
console.log();
|
|
720
|
+
const endsAt = new Date(config.competitionEndsAt);
|
|
721
|
+
const now = new Date();
|
|
722
|
+
if (now < endsAt) {
|
|
723
|
+
printKeyValue('Time Remaining', chalk.yellow.bold(formatCountdown(endsAt)));
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
printKeyValue('Time', chalk.red('Competition ended'));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
console.log();
|
|
730
|
+
});
|
|
731
|
+
// ─── icoa ctf time ───
|
|
732
|
+
ctf
|
|
733
|
+
.command('time')
|
|
734
|
+
.description('Show competition countdown')
|
|
735
|
+
.action(async () => {
|
|
736
|
+
logCommand('ctf time');
|
|
737
|
+
let config = getConfig();
|
|
738
|
+
// The cached competition window is only written at `join` time, so a once-
|
|
739
|
+
// set deadline goes stale the moment an admin changes the event window (and
|
|
740
|
+
// never self-heals). Re-fetch the live server window here so the countdown
|
|
741
|
+
// reflects the CURRENT deadline, not a snapshot from when the user joined.
|
|
742
|
+
// Best-effort: if the refresh fails (offline), fall back to the cached value.
|
|
743
|
+
if (config.ctfdUrl) {
|
|
744
|
+
try {
|
|
745
|
+
const client = new CTFdClient(config.ctfdUrl, config.token, config.sessionCookie);
|
|
746
|
+
const meta = await client.getCompetitionMeta();
|
|
747
|
+
const refresh = {};
|
|
748
|
+
refresh.competitionStartsAt = meta.start ? new Date(meta.start * 1000).toISOString() : '';
|
|
749
|
+
refresh.competitionEndsAt = meta.end ? new Date(meta.end * 1000).toISOString() : '';
|
|
750
|
+
saveConfig(refresh);
|
|
751
|
+
config = getConfig();
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
/* offline or server unreachable — fall back to cached window */
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (!config.competitionStartsAt && !config.competitionEndsAt) {
|
|
758
|
+
printInfo('Competition times not configured.');
|
|
759
|
+
printInfo('They will be set when the competition server provides timing information.');
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const now = new Date();
|
|
763
|
+
const startsAt = config.competitionStartsAt ? new Date(config.competitionStartsAt) : null;
|
|
764
|
+
const endsAt = config.competitionEndsAt ? new Date(config.competitionEndsAt) : null;
|
|
765
|
+
printHeader('Competition Timer');
|
|
766
|
+
// Static snapshot only — print the current countdown once and return.
|
|
767
|
+
// A live setInterval here corrupts the REPL prompt (it keeps writing
|
|
768
|
+
// over the prompt line every second) and never stops, so `time` shows
|
|
769
|
+
// the time remaining at the moment it's run; re-run it to refresh.
|
|
770
|
+
if (startsAt && now < startsAt) {
|
|
771
|
+
console.log(chalk.yellow(' Competition has not started yet.'));
|
|
772
|
+
console.log();
|
|
773
|
+
console.log(` ${chalk.bold('Starts in:')} ${formatCountdown(startsAt)}`);
|
|
774
|
+
}
|
|
775
|
+
else if (endsAt && now < endsAt) {
|
|
776
|
+
console.log(chalk.green(' Competition is LIVE'));
|
|
777
|
+
console.log();
|
|
778
|
+
console.log(` ${chalk.bold('Time remaining:')} ${formatCountdown(endsAt)}`);
|
|
779
|
+
console.log();
|
|
780
|
+
console.log(chalk.gray(' This is the shared event clock. If you are on a timed exam'));
|
|
781
|
+
console.log(chalk.gray(' paper, your personal deadline is shown by ') + chalk.white('exam info') + chalk.gray('.'));
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
console.log(chalk.red(' Competition has ended.'));
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
}
|