Building CLI Apps in Node¶
Half the tools you use in the terminal are Node.js under the hood ESLint , Prettier , TypeScript , npm , yarn , create-react-app - all CLI tools built with Node Building CLI apps is where Node shines: quick to write , easy to distribute , runs everywhere
Shebang and process.argv¶
#!/usr/bin/env node
// Every Node CLI starts with this shebang line
const args = process.argv.slice(2)
console.log('Arguments:', args)
// process.argv[0] = path to node binary
// process.argv[1] = path to script
// process.argv[2+] = user arguments
Make the file executable:
chmod +x mycli.js
./mycli.js --name Mahmoud --verbose
yargs - Argument Parsing¶
Don't parse process.argv yourself - use yargs or commander
#!/usr/bin/env node
const yargs = require('yargs')
const { hideBin } = require('yargs/helpers')
const argv = yargs(hideBin(process.argv))
.usage('$0 <command> [options]')
.command('scan <target>', 'Scan a target', (yargs) => {
yargs.positional('target', {
describe: 'Target host or IP',
type: 'string'
})
}, (argv) => {
console.log(`Scanning ${argv.target}...`)
})
.option('verbose', {
alias: 'v',
type: 'boolean',
description: 'Verbose output'
})
.option('timeout', {
alias: 't',
type: 'number',
default: 5000,
description: 'Timeout in ms'
})
.option('config', {
alias: 'c',
type: 'string',
description: 'Config file path'
})
.demandCommand(1, 'You need at least one command')
.strict()
.help()
.argv
if (argv.verbose) {
console.log('Verbose mode enabled')
}
./mycli.js scan 10.0.0.1 --verbose --timeout 10000
Reading stdin and Writing stdout/stderr¶
#!/usr/bin/env node
// Read stdin stream
async function readStdin() {
const chunks = []
for await (const chunk of process.stdin) {
chunks.push(chunk)
}
return Buffer.concat(chunks).toString()
}
// Write to stdout
process.stdout.write('Output to stdout\n')
// Write to stderr (errors, diagnostics)
process.stderr.write('Error: something broke\n')
// Exit with code
// process.exit(0) - success
// process.exit(1) - general error
// process.exit(2) - misuse of shell builtins
// Pipe data through your CLI
// cat largefile.txt | node mycli.js
const data = await readStdin()
const lines = data.split('\n').filter(l => l.includes('ERROR'))
lines.forEach(l => process.stdout.write(l + '\n'))
Interactive Prompts - inquirer¶
const inquirer = require('inquirer')
async function main() {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'target',
message: 'Enter target host:',
validate: (input) => input.length > 0 || 'Target is required'
},
{
type: 'list',
name: 'scanType',
message: 'Select scan type:',
choices: ['Quick scan', 'Full scan', 'Custom'],
default: 'Quick scan'
},
{
type: 'checkbox',
name: 'ports',
message: 'Select ports:',
choices: [
{ name: 'HTTP (80)', value: 80, checked: true },
{ name: 'HTTPS (443)', value: 443, checked: true },
{ name: 'SSH (22)', value: 22 },
{ name: 'FTP (21)', value: 21 }
]
},
{
type: 'password',
name: 'apiKey',
message: 'Enter API key:',
mask: '*'
},
{
type: 'confirm',
name: 'confirmed',
message: 'Start scan?',
default: false
}
])
if (answers.confirmed) {
console.log(`Scanning ${answers.target} on ports ${answers.ports.join(', ')}`)
}
}
main().catch(console.error)
CLI Visuals - chalk , ora , boxen¶
#!/usr/bin/env node
const chalk = require('chalk')
const ora = require('ora')
const boxen = require('boxen')
async function main() {
// Colored output
console.log(chalk.green('✓ Target reachable'))
console.log(chalk.red('✗ Port 22 closed'))
console.log(chalk.yellow('⚠ Rate limit approaching'))
console.log(chalk.blue.bold('Info:') + ' Running in verbose mode')
console.log(chalk.bgRed.white(' CRITICAL '))
// Spinner for async operations
const spinner = ora('Scanning target...').start()
await new Promise(r => setTimeout(r, 2000))
spinner.succeed('Scan complete')
// spinner.fail('Scan failed')
// spinner.info('Scan interrupted')
// Box for important info
console.log(boxen(
chalk.bold('Scan Results\n\n') +
`Host: ${chalk.green('10.0.0.1')}\n` +
`Open ports: ${chalk.yellow('5')}\n` +
`Duration: ${chalk.blue('12.4s')}`,
{
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'green'
}
))
}
main()
Publishing as an npm CLI Tool¶
// package.json
{
"name": "my-security-scanner",
"version": "1.0.0",
"description": "CLI security scanner",
"bin": {
"scan": "./bin/scan.js",
"scan-report": "./bin/report.js"
},
"preferGlobal": true,
"dependencies": {
"yargs": "^17.0.0",
"chalk": "^5.0.0",
"ora": "^8.0.0",
"inquirer": "^9.0.0"
}
}
# Install from npm
npm install -g my-security-scanner
# Or run directly
npx my-security-scanner scan 10.0.0.1
# Users now have:
scan --help
scan-report --output html
The bin field maps command names to script paths Node automatically links these when the package is installed globally Scripts should start with #!/usr/bin/env node and be executable
Security - Command Injection in CLI Tools¶
// DANGER - shell injection via CLI arguments
const { execSync } = require('child_process')
const target = process.argv[2]
// Attacker: "./mycli.js ; rm -rf / ;"
const result = execSync(`nmap ${target}`, { shell: true })
CLI tools that spawn subprocesses with unsanitized user input are RCE vectors waiting to happen
// SAFE - use spawn with argument arrays
const { spawnSync } = require('child_process')
const target = process.argv[2]
// Validate input
if (!/^[a-zA-Z0-9\.\-]+$/.test(target)) {
console.error('Invalid target')
process.exit(1)
}
const result = spawnSync('nmap', ['-sV', target], { stdio: 'inherit' })
Argument injection - even with argument arrays , some binaries interpret -- flags
// DANGER - nmap interprets --script as a flag
spawnSync('nmap', ['-sV', '--script=http-vuln-check', target])
--script=http-vuln-check , nmap loads the script anyway Sanitize inputs even when using spawn Other CLI security concerns: - Don't log sensitive data (API keys , tokens) to stdout - Don't write config files with secrets to world-readable locations - Validate file paths to prevent path traversal in output files - Be careful with -- argument terminator for positionals
Prerequisites¶
- adv_04_pwas.md - understand Node.js in non-server contexts
next -> ref_01_events_api.md