Examples and apps

Small programs to learn from, and real applications to use and take apart: desktop apps, security tools and network utilities, each with its full source.

Example programsAppsOpen the playground

Example programs

Short programs that each show one idea. Run them here, or find them in the examples folder that comes with TB: tb examples\care_bot.tb.

For Tanisha

The very first TB program, a little letter written in code. Lists, loops, functions and a note at the end.

# for_tanisha.tb
# A little program, written in a little language, for Tanisha.
# Run it with:  tb for_tanisha.tb

let name = "Tanisha"
say("Hello, " + name + "!")
say("This language is called TB. Can you guess why?")
say("")

# Things I love about you (a list)
let reasons = [
  "your laugh",
  "the way you care about everyone",
  "how brave you are",
  "your terrible puns",
]

say("A few reasons I love you:")
let count = 1
for reason in reasons {
  say(str(count) + ". " + reason)
  count = count + 1
}
say("...and " + str(len(reasons)) + " is nowhere near all of them.")
say("")

# A function that remembers things (a closure)
fn make_counter() {
  let days = 0
  return fn() {
    days = days + 1
    return days
  }
}

let together = make_counter()
together()
together()
say("Days I've been happy today: " + str(together()))

# A function that calls itself (recursion)
fn hearts(n) {
  if n <= 0 {
    return ""
  }
  return "<3 " + hearts(n - 1)
}
say(hearts(5))
say("")

# Counting with while
let i = 3
while i > 0 {
  say(str(i) + "...")
  i = i - 1
}

if len(reasons) > 3 {
  say("I love you more than " + str(len(reasons)) + " reasons can say.")
} else if len(reasons) > 0 {
  say("I love you.")
} else {
  say("Still loving you.")
}

say("")
say("A note from TB, just for you:")
care(name)
say("")
say("With love, always.")

Hello, world

Your very first TB program. It says hello and does a little maths.

# Title: Hello, world
# About: Your very first TB program. It says hello and does a little maths.

# Lines that start with # are notes for people. TB skips them.

say("Hello, world!")
say("Hello, Tanisha!")

# say() can show more than one thing at once, with spaces in between.
say("Two plus two is", 2 + 2)

# Text in quotes can be joined together with +
let name = "Tanisha"
say("Welcome to TB, " + name + ".")

Compliment machine

Mixes words from two lists to write lots of little compliments.

# Title: Compliment machine
# About: Mixes words from two lists to write lots of little compliments.

let things = ["smile", "laugh", "kindness"]
let words = ["lights up the room", "makes my day"]

fn compliment(thing, word) {
  return "Your " + thing + " " + word + "."
}

# A loop inside a loop: every thing gets paired with every word.
for thing in things {
  for word in words {
    say(compliment(thing, word))
  }
}
say("")
say("That's " + str(len(things) * len(words)) + " compliments, and every one is true.")

Countdown to a hug

Counts down with a while loop, then counts by twos with range().

# Title: Countdown to a hug
# About: Counts down with a while loop, then counts by twos with range().

let seconds = 5
say("Hug arriving in...")
while seconds > 0 {
  say(str(seconds) + "...")
  seconds = seconds - 1
}
say("Hug delivered! <3")
say("")

# range(10, 0, -2) means: start at 10, stop before 0, go down 2 each time.
say("Counting kisses by twos, backwards:")
for n in range(10, 0, -2) {
  say(n)
}

Love buzz

A playful pattern: every 3rd number says "love", every 5th says "hugs".

# Title: Love buzz
# About: A playful pattern: every 3rd number says "love", every 5th says "hugs".

# % gives the remainder after dividing. If n % 3 is 0, n is a multiple of 3.
for n in range(1, 16) {
  if n % 15 == 0 {
    say("love and hugs!")
  } else if n % 3 == 0 {
    say("love")
  } else if n % 5 == 0 {
    say("hugs")
  } else {
    say(n)
  }
}
say("")

# Text times a number repeats the text. Here we draw a little heart triangle.
for size in range(1, 6) {
  say("<3 " * size)
}

Grocery list

Builds a shopping list with push(), changes an item, and prints it with for.

# Title: Grocery list
# About: Builds a shopping list with push(), changes an item, and prints it with for.

let groceries = []
push(groceries, "milk")
push(groceries, "bread")
push(groceries, "strawberries")
push(groceries, "chocolate")

say("My list has " + str(len(groceries)) + " things:")
for item in groceries {
  say("- " + item)
}
say("")

# Lists start counting at 0, so groceries[0] is the first thing.
say("First thing to buy: " + groceries[0])

# Change an item in the list.
groceries[1] = "fresh bread"
say("Oops, make that: " + groceries[1])
say("")

# Two lists can be joined with +
let treats = ["ice cream", "flowers"]
let everything = groceries + treats
say("With treats added:", everything)

# Number each item, starting from 1 for humans.
let number = 1
for item in everything {
  say(str(number) + ". " + item)
  number = number + 1
}

Functions that remember

Closures: little functions that keep their own memory, like a hug counter.

# Title: Functions that remember
# About: Closures: little functions that keep their own memory, like a hug counter.

fn make_counter(label) {
  let count = 0
  return fn() {
    count = count + 1
    say(label + ": " + str(count))
  }
}

let hugs = make_counter("Hugs today")
let kisses = make_counter("Kisses today")

hugs()
hugs()
kisses()
hugs()

# A function can also be kept in a 'let' and passed around like any value.
let shout = fn(text) {
  return text + "!!!"
}

fn twice(f, value) {
  return f(f(value))
}

say(twice(shout, "I love you"))

Functions that call themselves

Recursion: Fibonacci numbers, factorials, and a staircase of stars.

# Title: Functions that call themselves
# About: Recursion: Fibonacci numbers, factorials, and a staircase of stars.

# Each Fibonacci number is the two before it added together.
fn fib(n) {
  if n < 2 {
    return n
  }
  return fib(n - 1) + fib(n - 2)
}

let numbers = []
for i in range(10) {
  push(numbers, fib(i))
}
say("The first 10 Fibonacci numbers:", numbers)

# 5 factorial means 5 * 4 * 3 * 2 * 1.
fn factorial(n) {
  if n <= 1 {
    return 1
  }
  return n * factorial(n - 1)
}
say("5 factorial is", factorial(5))

# Draw a staircase by calling stairs() with a smaller number each time.
fn stairs(n) {
  if n == 0 {
    return
  }
  stairs(n - 1)
  say("*" * n)
}
stairs(4)

Guess the number

A small guessing game. TB is thinking of a number from 1 to 20.

# Title: Guess the number
# About: A small guessing game. TB is thinking of a number from 1 to 20.

let secret = 7
let tries = 0
let found = false

say("I'm thinking of a number from 1 to 20. Can you guess it?")

while not found and tries < 5 {
  let answer = ask("Your guess: ")
  # Check the answer is a whole number from 1 to 20 before using num().
  let ok = false
  for n in range(1, 21) {
    if answer == str(n) {
      ok = true
    }
  }
  if not ok {
    say("Hmm, \"" + answer + "\" isn't a number from 1 to 20. Try again!")
  } else {
    tries = tries + 1
    let guess = num(answer)
    if guess == secret {
      found = true
    } else if guess < secret {
      say("A bit higher!")
    } else {
      say("A bit lower!")
    }
  }
}

if found {
  say("Yes! It was " + str(secret) + ". You got it in " + str(tries) + " tries.")
} else {
  say("Out of tries! The number was " + str(secret) + ". Want to play again?")
}

Care bot

A gentle little bot that asks how you are feeling and answers kindly.

# Title: Care bot
# About: A gentle little bot that asks how you are feeling and answers kindly.

# Checks whether a word is in a list of words.
fn is_one_of(word, options) {
  for option in options {
    if word == option {
      return true
    }
  }
  return false
}

say("Hi! I'm Care Bot. I'm here to check in on you.")
let name = ask("What's your name? ")
if name == "" {
  name = "friend"
}
say("It's lovely to see you, " + name + ".")
say("")

say("How are you feeling right now?")
say("  1. Happy")
say("  2. Tired")
say("  3. Sad")
say("  4. Stressed")
say("  5. Something else")
let feeling = ask("Type a number (or the word): ")
say("")

if is_one_of(feeling, ["1", "happy", "Happy", "HAPPY", "good", "Good", "great", "Great"]) {
  say("Yay! That makes me so happy to hear.")
  say("Hold on to this feeling. You deserve every bit of it.")
} else if is_one_of(feeling, ["2", "tired", "Tired", "TIRED", "sleepy", "Sleepy"]) {
  say("Sounds like you've been working hard.")
  say("Maybe a glass of water and a little rest? Even ten minutes helps.")
} else if is_one_of(feeling, ["3", "sad", "Sad", "SAD", "down", "Down"]) {
  say("I'm sorry today feels heavy. It's okay to feel sad.")
  say("Wrap up in something cosy, and maybe talk to someone who loves you.")
} else if is_one_of(feeling, ["4", "stressed", "Stressed", "STRESSED", "worried", "Worried"]) {
  say("That sounds like a lot. Let's take one slow breath together.")
  say("In... and out. You don't have to do everything at once.")
} else {
  say("Thank you for telling me. Whatever you're feeling is okay.")
  say("Be gentle with yourself today.")
}
say("")

# Ask for a score, but only accept whole numbers from 1 to 10.
let answer = ask("On a scale of 1 to 10, how is your day going? ")
let score = 0
for n in range(1, 11) {
  if answer == str(n) {
    score = n
  }
}

if score == 0 {
  say("That's alright, numbers can be hard to pick. I'm still glad you're here.")
} else if score <= 3 {
  say(str(score) + " out of 10. I'm sending you the biggest hug.")
  say("Please rest, drink some water, and reach out to someone you trust.")
} else if score <= 6 {
  say(str(score) + " out of 10. A middling day. Something small and nice might help.")
} else if score <= 9 {
  say(str(score) + " out of 10! That's a good day. I'm proud of you.")
} else {
  say("10 out of 10! Amazing! Your happiness is contagious.")
}

say("")
say("One last thing before you go:")
care(name)

Apps

Complete programs written in TB 2.0. Desktop apps open windows; the security and network tools run in a terminal. Every one is a single readable main.tb.

They already come inside your TB download, in its apps folder (with sample logs in apps/data). On Windows, the installer also puts the desktop apps in the Start menu. To run one yourself:

Your TBRun the To-do app
Windows installerStart menu, TB, To-do, or in TB Prompt: tb "%LOCALAPPDATA%\Programs\TB\apps\todo\main.tb"
Windows ziptb\tbw.exe tb\apps\todo\main.tb
Mac~/tb/tb ~/tb/apps/todo/main.tb
Linux./tb/tb tb/apps/todo/main.tb

Command-line tools take a file to read, for example tb apps/log-triage/main.tb apps/data/auth.log. The source links below are for reading or copying a single app.

Desktop apps

Real windows with buttons, tables and menus, built with the gui library.

To-do list apps/todo

A to-do list in a window. Type a task and press Enter (or click Add), double-click a task to tick it off, and delete tasks or clear the finished ones with the buttons underneath. The list is saved in todo.json next to the app, so it is still there next time.

From your TB folder: tb apps/todo/main.tb

Download source: README.md main.tb

Screenshot of the To-do list app
main.tb, 119 lines
# To-do: add tasks, tick them off, delete them.
# Your list is saved in todo.json, next to this file.
# Run: tb apps/todo/main.tb

import gui
import json
import fs

let FILE = fs.here("todo.json")

# Each task is a map: {"text": "Buy flowers", "done": false}
let tasks = []
if fs.exists(FILE) {
  try {
    tasks = json.read(FILE)
  } catch err {
    say(f"I couldn't read {FILE}, so I'm starting a fresh list. ({err.message})")
    tasks = []
  }
}

let win = gui.window("To-do", {"width": 480, "height": 560})
win.label("Things to do", {"style": "title"})
win.label("Type a task and press Enter. Double-click a task to tick it.", {"style": "muted"})

let entry = win.row()
let box = entry.input({"placeholder": "What needs doing?", "on_enter": fn(text) { add_task(text) }})
entry.button("Add", fn() { add_task(box.value()) }, {"primary": true})

let list = win.table(["Done", "Task"], {"widths": [60, 360], "on_open": fn(row, i) { toggle(i) }})

let actions = win.row()
actions.button("Tick", fn() { toggle_selected() })
actions.button("Delete", fn() { delete_selected() })
actions.button("Clear done", fn() { clear_finished() })

let status = win.status("")

fn save() {
  json.write(FILE, tasks)
}

fn mark(task) {
  if task["done"] {
    return "✓"
  }
  return ""
}

fn show() {
  let rows = []
  for task in tasks {
    rows.push([mark(task), task["text"]])
  }
  list.set_rows(rows)
  let left = len(tasks.filter(fn(t) { return not t["done"] }))
  if len(tasks) == 0 {
    status.set("Nothing to do. Enjoy your day!")
  } else {
    status.set(f"{left} to do, {len(tasks) - left} done")
  }
}

fn add_task(text) {
  let clean = text.trim()
  if clean == "" {
    return
  }
  tasks.push({"text": clean, "done": false})
  box.clear()
  save()
  show()
}

fn toggle(i) {
  let task = tasks[i]
  task["done"] = not task["done"]
  save()
  show()
  list.select(i)
}

fn picked() {
  let i = list.selected_index()
  if i == nil {
    gui.alert("Pick a task in the list first.")
  }
  return i
}

fn toggle_selected() {
  let i = picked()
  if i != nil {
    toggle(i)
  }
}

fn delete_selected() {
  let i = picked()
  if i == nil {
    return
  }
  let text = tasks[i]["text"]
  if gui.confirm(f"Delete \"{text}\"?") {
    tasks.remove_at(i)
    save()
    show()
  }
}

fn clear_finished() {
  tasks = tasks.filter(fn(t) { return not t["done"] })
  save()
  show()
}

show()
box.focus()
win.run()

Care check-in apps/care

"How are you today?" A gentle mood check-in, made for Tanisha. Pick how you feel, write a few words if you like, and save it: you get a kind message back, and the check-in goes into a journal (journal.json, next to the app) shown newest first. Double-click a journal entry to read its note.

From your TB folder: tb apps/care/main.tb

Download source: README.md main.tb

Screenshot of the Care check-in app
main.tb, 123 lines
# How are you today? A little mood check-in, made for Tanisha.
# Pick a mood, write a note if you like, and save it to your journal
# (journal.json, next to this file). You get a kind message back.
# Run: tb apps/care/main.tb

import gui
import json
import fs
import time
import random

let FILE = fs.here("journal.json")

let MOODS = ["Happy", "Calm", "Okay", "Tired", "Sad", "Worried"]
let ICONS = {"Happy": "☀", "Calm": "✿", "Okay": "☁", "Tired": "☾", "Sad": "☂", "Worried": "☘"}

let FOR_MOOD = {
  "Happy": "I love seeing you happy. Hold on to this feeling.",
  "Calm": "Calm looks good on you. Breathe it in.",
  "Okay": "Okay is enough. You don't have to be more than that today.",
  "Tired": "Rest isn't a reward, it's a need. Be soft with yourself tonight.",
  "Sad": "It's okay to feel sad. You are not alone, and this will pass.",
  "Worried": "One small step at a time. You've handled hard things before.",
}

let ALWAYS = [
  "You are doing better than you think.",
  "Drink some water and take a deep breath.",
  "Whatever happens today, you are loved.",
  "Small steps still move you forward.",
  "Proud of you, always.",
  "You make the world softer just by being in it.",
]

# Each entry: {"date": "2026-10-26 21:30", "mood": "Calm", "note": "..."}
let journal = []
if fs.exists(FILE) {
  try {
    journal = json.read(FILE)
  } catch err {
    say(f"I couldn't read {FILE}, so I'm starting a new journal. ({err.message})")
  }
}

let mood = nil
let now = time.now()

let win = gui.window("How are you today?", {"width": 580, "height": 720})
win.label("How are you today, Tanisha?", {"style": "title"})
win.label(f"{now.weekday}, {now.day} {now.month_name}", {"style": "muted"})

# A button needs a function; this makes one that remembers its mood.
fn pick_mood(name) {
  return fn() {
    mood = name
    chosen.set(f"{ICONS[name]}  Feeling {name.lower()} today")
  }
}

let moods = win.column()
let first_row = moods.row()
let second_row = moods.row()
let index = 0
for name in MOODS {
  let row = first_row
  if index >= 3 {
    row = second_row
  }
  row.button(f"{ICONS[name]}  {name}", pick_mood(name), {"expand": true})
  index += 1
}

let chosen = win.label("Pick the one that fits best.", {"style": "heading"})
let note = win.textarea({"height": 4})
win.button("Save today's check-in", fn() { save_entry() }, {"primary": true})
let message = win.label("", {"style": "accent", "wrap": 520})

win.separator()
win.label("Your journal", {"style": "heading"})
let table = win.table(["When", "Mood", "Note"], {"widths": [160, 90, 270], "height": 4, "on_open": fn(row) { read_entry(row) }})
let status = win.status("")

fn kind_message(name) {
  return FOR_MOOD[name] + " " + random.choice(ALWAYS)
}

fn show_journal() {
  let rows = []
  for entry in journal.reverse() {
    rows.push([entry["date"], entry["mood"], entry["note"]])
  }
  table.set_rows(rows)
  let n = len(journal)
  if n == 1 {
    status.set("1 check-in in your journal")
  } else {
    status.set(f"{n} check-ins in your journal")
  }
}

fn save_entry() {
  if mood == nil {
    gui.alert("Pick how you feel first ♥")
    return
  }
  let entry = {"date": time.format(time.timestamp(), "%Y-%m-%d %H:%M"), "mood": mood, "note": note.value().trim()}
  journal.push(entry)
  json.write(FILE, journal)
  message.set(kind_message(mood))
  note.clear()
  show_journal()
}

fn read_entry(row) {
  let text = row[2]
  if text == "" {
    text = "(no note)"
  }
  gui.alert(f"{row[1]} on {row[0]}\n\n{text}", "Journal")
}

show_journal()
win.run()

Notes apps/notes

A small plain-text editor. Open and save files with the File menu, the buttons at the top, or the keyboard (Ctrl+O, Ctrl+S, Ctrl+N; Cmd on a Mac). The status bar counts words and characters as you type, and the title shows a dot when there are unsaved changes. It asks before throwing changes away.

From your TB folder: tb apps/notes/main.tb

Download source: README.md main.tb

Screenshot of the Notes app
main.tb, 154 lines
# Notes: a small plain-text editor.
# Open and save files with the File menu (or Ctrl+O / Ctrl+S; Cmd on a Mac).
# The status bar counts your words as you type.
# Run: tb apps/notes/main.tb            (or: tb apps/notes/main.tb some-file.txt)

import gui
import fs
import os

let FILTERS = {"Text files": "*.txt *.md *.tb *.log", "All files": "*"}

let current = nil      # the file being edited (nil: not saved yet)
let dirty = false      # true when there are unsaved changes

let win = gui.window("Notes", {"width": 680, "height": 520})

let bar = win.row()
bar.button("New", fn() { new_note() })
bar.button("Open…", fn() { open_note() })
bar.button("Save", fn() { save_note() }, {"primary": true})
let file_label = bar.label("", {"style": "muted"})

let editor = win.textarea({"on_change": fn(text) { changed(text) }, "height": 16})
let status = win.status("")

win.menu({
  "File": {
    "New": fn() { new_note() },
    "Open…": fn() { open_note() },
    "Save": fn() { save_note() },
    "Save As…": fn() { save_as() },
    "-": nil,
    "Quit": fn() { quit() },
  },
  "Help": {
    "About Notes": fn() { gui.alert("Notes, a tiny editor written in TB.\nMade with love, for Tanisha.", "About") },
  },
})
win.key("Mod+N", fn() { new_note() })
win.key("Mod+O", fn() { open_note() })
win.key("Mod+S", fn() { save_note() })

fn file_name() {
  if current == nil {
    return "Untitled"
  }
  return fs.name(current)
}

fn count_words(text) {
  return len(text.split())
}

fn update() {
  let text = editor.value()
  let words = count_words(text)
  let plural = "s"
  if words == 1 {
    plural = ""
  }
  let mark = ""
  if dirty {
    mark = " •"
  }
  status.set(f"{words} word{plural}, {len(text)} characters")
  file_label.set(file_name() + mark)
  win.title(f"{file_name()}{mark} - Notes")
}

fn changed(text) {
  dirty = true
  update()
}

fn okay_to_lose_changes() {
  if not dirty {
    return true
  }
  return gui.confirm("You have unsaved changes. Throw them away?")
}

fn new_note() {
  if okay_to_lose_changes() {
    editor.set("")
    current = nil
    dirty = false
    update()
    editor.focus()
  }
}

fn load(path) {
  try {
    editor.set(fs.read(path))
    current = path
    dirty = false
  } catch err {
    gui.alert(err.message, "Couldn't open")
  }
  update()
}

fn open_note() {
  if not okay_to_lose_changes() {
    return
  }
  let path = gui.open_file(FILTERS)
  if path != nil {
    load(path)
  }
}

fn write_to(path) {
  try {
    fs.write(path, editor.value())
    current = path
    dirty = false
  } catch err {
    gui.alert(err.message, "Couldn't save")
  }
  update()
}

fn save_as() {
  let suggested = file_name()
  if current == nil {
    suggested = "Untitled.txt"
  }
  let path = gui.save_file(suggested, FILTERS)
  if path != nil {
    write_to(path)
  }
}

fn save_note() {
  if current == nil {
    save_as()
  } else {
    write_to(current)
  }
}

fn quit() {
  if okay_to_lose_changes() {
    win.close()
  }
}

if len(os.args) > 0 {
  load(os.args[0])
}
update()
editor.focus()
win.run()

Calculator apps/calculator

A calculator with a grid of buttons, in the dark theme. It works one step at a time like a pocket calculator (2 + 3 × 4 = 20), and you can type on the keyboard too: digits, + - * / . %, Enter for =, Escape to clear and Backspace to rub out a digit. Dividing by zero shows a message instead of crashing.

From your TB folder: tb apps/calculator/main.tb

Download source: README.md main.tb

Screenshot of the Calculator app
main.tb, 176 lines
# Calculator: a grid of buttons, like the one on your phone.
# You can type too: digits, + - * / . and Enter (=), Escape (clear), Backspace.
# Run: tb apps/calculator/main.tb

import gui

let shown = "0"     # the number on the display, as text
let stored = nil    # the number before the operator
let op = nil        # "+", "−", "×" or "÷"
let fresh = true    # true when the next digit starts a new number
let problem = nil   # a message to show above the number (like dividing by zero)

let win = gui.window("Calculator", {"width": 340, "height": 480, "theme": "dark"})
let history = win.label(" ", {"style": "muted", "align": "right"})
let display = win.label("0", {"style": "display", "align": "right"})

let KEYS = [
  ["C", "±", "%", "÷"],
  ["7", "8", "9", "×"],
  ["4", "5", "6", "−"],
  ["1", "2", "3", "+"],
  ["0", ".", "⌫", "="],
]

fn press_for(key) {
  return fn() { press(key) }
}

let grid = win.column({"expand": true})
for keys in KEYS {
  let row = grid.row({"expand": true})
  for key in keys {
    row.button(key, press_for(key), {"expand": true, "primary": key == "=", "width": 3})
  }
}

fn to_display(x) {
  if x.abs() >= 1000000000000000 {
    return "Too big"
  }
  return str(round(x, 10))
}

fn show() {
  display.set(shown)
  if problem != nil {
    history.set(problem)
  } else if op == nil {
    history.set(" ")
  } else {
    history.set(f"{to_display(stored)} {op}")
  }
}

fn digit(d) {
  if fresh or shown == "0" {
    shown = d
    fresh = false
  } else if len(shown) < 15 {
    shown += d
  }
}

fn dot() {
  if fresh {
    shown = "0."
    fresh = false
  } else if not shown.contains(".") {
    shown += "."
  }
}

fn calculate(a, how, b) {
  if how == "+" {
    return a + b
  }
  if how == "−" {
    return a - b
  }
  if how == "×" {
    return a * b
  }
  if b == 0 {
    throw "You can't divide by zero."
  }
  return a / b
}

fn finish() {
  if op == nil or shown == "Too big" or shown == "Oops" {
    return
  }
  try {
    shown = to_display(calculate(stored, op, num(shown)))
  } catch err {
    shown = "Oops"
    problem = err.message
    op = nil
    fresh = true
    return
  }
  op = nil
  fresh = true
}

fn operator(how) {
  if op != nil and not fresh {
    finish()
  }
  if shown == "Too big" or shown == "Oops" {
    return
  }
  stored = num(shown)
  op = how
  fresh = true
}

fn press(key) {
  let broken = shown == "Oops" or shown == "Too big"
  if broken and not "0123456789.C".contains(key) {
    return
  }
  problem = nil
  if "0123456789".contains(key) {
    digit(key)
  } else if key == "." {
    dot()
  } else if key == "C" {
    shown = "0"
    stored = nil
    op = nil
    fresh = true
  } else if key == "⌫" {
    if not fresh and len(shown) > 1 and shown != "-0" {
      shown = shown.slice(0, len(shown) - 1)
      if shown == "-" {
        shown = "0"
      }
    } else {
      shown = "0"
    }
  } else if key == "±" {
    if shown.starts_with("-") {
      shown = shown.slice(1)
    } else if shown != "0" {
      shown = "-" + shown
    }
  } else if key == "%" {
    shown = to_display(num(shown) / 100)
    fresh = true
  } else if key == "=" {
    finish()
    show()
    return
  } else {
    operator(key)
  }
  show()
}

# The keyboard works too.
for d in "0123456789".chars() {
  win.key(d, press_for(d))
}
win.key("+", press_for("+"))
win.key("-", press_for("−"))
win.key("*", press_for("×"))
win.key("/", press_for("÷"))
win.key(".", press_for("."))
win.key("%", press_for("%"))
win.key("=", press_for("="))
win.key("Enter", press_for("="))
win.key("Escape", press_for("C"))
win.key("Backspace", press_for("⌫"))

win.run()

Hash checker apps/hash-checker

Is this download really the file you think it is? Pick a file (or paste its path and press Enter) and Hash Checker shows its size and its MD5, SHA-1 and SHA-256, each with a Copy button. Paste the hash the download page or the threat report gave you: a green tick means it matches, a red cross means it doesn't (and you shouldn't open it). The comparison uses hash.compare, so case and stray spaces don't matter.

From your TB folder: tb apps/hash-checker/main.tb

Download source: README.md main.tb

Screenshot of the Hash checker app
main.tb, 166 lines
# Hash Checker: fingerprint a file (MD5, SHA-1, SHA-256), compare it with
# the hash a download page or a threat report gives you, and optionally look
# it up on VirusTotal (only the hash is sent, never the file).
# Run: tb apps/hash-checker/main.tb [file]

import gui
import hash
import intel
import fs
import os

let result = {}     # {"md5", "sha1", "sha256", "size"} of the current file
let NAMES = {"md5": "MD5", "sha1": "SHA-1", "sha256": "SHA-256"}

let win = gui.window("Hash Checker", {"width": 900, "height": 600})
win.label("Hash Checker", {"style": "title"})
win.label("Is this file the one you think it is? Pick it, then paste the hash you expect.", {"style": "muted"})

let pick = win.row()
let path_box = pick.input({"placeholder": "Path to a file (paste or type it, then press Enter)",
                          "on_enter": fn(text) { check(text) }})
pick.button("Choose file…", fn() { choose() })
pick.button("Hash it", fn() { check(path_box.value()) }, {"primary": true})

win.separator()
let file_label = win.label("No file yet.", {"style": "heading"})
let size_label = win.label(" ", {"style": "muted"})

# A Copy button needs a function; this makes one for each hash.
fn copier(key) {
  return fn() {
    if result.has(key) {
      gui.copy(result[key])
      status.set(f"{NAMES[key]} copied to the clipboard.")
    }
  }
}

# One row per hash: name, value, copy button.
let values = {}
for key in ["md5", "sha1", "sha256"] {
  let line = win.row()
  line.label(NAMES[key], {"style": "bold", "width": 8})
  line.button("Copy", copier(key), {"width": 5})
  values[key] = line.label("-", {"style": "mono"})
}

win.separator()
win.label("Expected hash", {"style": "heading"})
let expect_row = win.row()
let expected = expect_row.input({"placeholder": "Paste the MD5, SHA-1 or SHA-256 you were given",
                                "on_change": fn(text) { compare() }})
expect_row.button("Paste", fn() {
  expected.set(gui.paste().trim())
  compare()
})
let verdict = win.label(" ", {"style": "bold"})

win.separator()
let vt_row = win.row()
vt_row.button("Look up on VirusTotal", fn() { virustotal() })
let vt_label = vt_row.label("Only the SHA-256 is sent, never the file.", {"style": "muted"})

let status = win.status("Ready")

fn nice_size(n) {
  if n < 1024 {
    return f"{n} bytes"
  }
  if n < 1024 * 1024 {
    return f"{round(n / 1024, 1)} KB ({n} bytes)"
  }
  return f"{round(n / 1024 / 1024, 1)} MB ({n} bytes)"
}

fn choose() {
  let path = gui.open_file()
  if path != nil {
    path_box.set(path)
    check(path)
  }
}

fn check(path) {
  let clean = path.trim().replace("\"", "").replace("'", "")
  if clean == "" {
    gui.alert("Choose a file first, or paste its path into the box.")
    return
  }
  if not fs.is_file(clean) {
    gui.alert(f"I couldn't find a file at:\n{clean}", "No such file")
    return
  }
  status.set("Hashing…")
  try {
    result = hash.file(clean)
  } catch err {
    gui.alert(err.message, "Couldn't read the file")
    status.set("Ready")
    return
  }
  file_label.set(fs.name(clean))
  size_label.set(nice_size(result["size"]) + "   ·   " + clean)
  for key in ["md5", "sha1", "sha256"] {
    values[key].set(result[key])
  }
  vt_label.set("Only the SHA-256 is sent, never the file.")
  vt_label.style("muted")
  status.set(f"Hashed {fs.name(clean)}")
  compare()
}

fn compare() {
  let want = expected.value().trim().lower()
  if want == "" {
    verdict.set(" ")
    return
  }
  if result.is_empty() {
    verdict.set("Pick a file to compare with.")
    verdict.style("muted")
    return
  }
  for key in ["md5", "sha1", "sha256"] {
    if hash.compare(want, result[key]) {
      verdict.set(f"✓  Match: the {NAMES[key]} is exactly what you expected.")
      verdict.style("success")
      return
    }
  }
  verdict.set("✗  No match. This is not the file you were promised. Don't open it.")
  verdict.style("danger")
}

fn virustotal() {
  if result.is_empty() {
    gui.alert("Hash a file first.")
    return
  }
  if not intel.has_key("virustotal") {
    gui.alert(intel.key_help(), "VirusTotal needs an API key")
    return
  }
  status.set("Asking VirusTotal…")
  try {
    let answer = intel.virustotal_file(result["sha256"])
    vt_label.set(f"VirusTotal: {answer.verdict} (score {answer.score})")
    if answer.verdict == "malicious" or answer.verdict == "suspicious" {
      vt_label.style("danger")
    } else if answer.verdict == "harmless" {
      vt_label.style("success")
    } else {
      vt_label.style("normal")
    }
    status.set("VirusTotal answered.")
  } catch err {
    gui.alert(err.message, "VirusTotal lookup failed")
    status.set("Ready")
  }
}

if len(os.args) > 0 {
  path_box.set(os.args[0])
  check(os.args[0])
}
win.run()

Log triage (desktop) apps/log-triage-gui

TB Log Triage, the desktop version of apps/log-triage. Open a log (it starts with the sample apps/data/auth.log) and TB works out the format (syslog, auth.log, Apache/nginx, JSON lines, CSV, Windows Security exports), runs the detection rules from apps/log-triage/rules.tb (brute force with "then logged in" escalation, port scans, web attacks and scanners, new accounts and group changes, cleared logs, encoded PowerShell...) and lists the alerts, most serious first.

From your TB folder: tb apps/log-triage-gui/main.tb

Download source: README.md main.tb

Screenshot of the Log triage (desktop) app
main.tb, 212 lines
# TB Log Triage: open a log, let TB work out its format, run the detection
# rules from apps/log-triage/rules.tb (brute force, port scans, web attacks,
# new accounts, cleared logs...) and look through the alerts. Export an HTML
# report to share with your team.
# Run: tb apps/log-triage-gui/main.tb [log file] [--light]

import gui
import logs
import report
import fs
import os
import re
import encode
import "../log-triage/rules.tb" as rules

let DATA = fs.join(fs.here(), "..", "data")
let SAMPLES = ["auth.log", "access.log", "firewall.log", "windows_security.json", "windows_security.csv"]

let current = nil     # path of the open log
let format = ""
let records = []
let alerts = []

let theme = "dark"
let start = fs.join(DATA, "auth.log")
for arg in os.args {
  if arg == "--light" {
    theme = "light"
  } else if arg == "--dark" {
    theme = "dark"
  } else {
    start = arg
  }
}

let win = gui.window("TB Log Triage", {"width": 1080, "height": 780, "theme": theme})

let top = win.row()
let heading = top.column({"expand": true})
heading.label("TB Log Triage", {"style": "title"})
let subtitle = heading.label("Open a log to look for trouble.", {"style": "muted"})

let bar = win.row()
bar.button("Open log…", fn() { open_log() }, {"primary": true})
bar.label("or try a sample:", {"style": "muted"})
let sample = bar.select(SAMPLES, {"on_change": fn(name) { load(fs.join(DATA, name)) }, "width": 22})
bar.button("Export HTML report…", fn() { export() })

let counts = win.label(" ", {"style": "heading"})

let table = win.table(["Level", "Rule", "By", "Count", "First seen", "Last seen"], {
  "widths": [100, 330, 140, 70, 180, 180], "height": 6,
  "on_select": fn(row, i) { show_details(i) },
})

win.label("Details", {"style": "heading"})
let details = win.textarea({"readonly": true, "mono": true, "height": 10})
let status = win.status("")

win.menu({
  "File": {
    "Open log…": fn() { open_log() },
    "Export HTML report…": fn() { export() },
    "-": nil,
    "Quit": win.close,
  },
})
win.key("Mod+O", fn() { open_log() })
win.key("Mod+E", fn() { export() })

let ICON = {"critical": "●●●●", "high": "●●●", "medium": "●●", "low": "●", "informational": "·"}

fn by_text(alert) {
  if alert.by == nil {
    return "-"
  }
  return str(alert.by)
}

fn level_counts() {
  let found = {"critical": 0, "high": 0, "medium": 0, "low": 0, "informational": 0}
  for a in alerts {
    found[a.level] += 1
  }
  return found
}

fn load(path) {
  try {
    let fmt = logs.sniff(path)
    let got = logs.read(path)
    records = got
    format = fmt
    current = path
  } catch err {
    gui.alert(err.message, "Couldn't read the log")
    return
  }
  alerts = rules.triage(records)
  let rows = []
  for a in alerts {
    rows.push([a.level.upper(), a.rule, by_text(a), a.count, a.first_when, a.last_when])
  }
  table.set_rows(rows)
  subtitle.set(f"{fs.name(current)}  ·  {format} format  ·  {len(records)} records")
  let c = level_counts()
  if len(alerts) == 0 {
    counts.set("No alerts. This log looks quiet.")
    counts.style("success")
    details.set("Nothing matched the rules. That's good news!")
  } else {
    counts.set(f"{len(alerts)} alerts:  {c.critical} critical,  {c.high} high,  {c.medium} medium,  {c.low + c.informational} low")
    if c.critical + c.high > 0 {
      counts.style("danger")
    } else {
      counts.style("heading")
    }
    table.select(0)
    show_details(0)
  }
  status.set(f"{len(records)} records  ·  {time_span()}  ·  {len(alerts)} alerts")
  win.title(f"{fs.name(current)} - TB Log Triage")
}

fn time_span() {
  let timed = records.filter(fn(r) { return r.time != nil }).sort_by(fn(r) { return r.time })
  if timed.is_empty() {
    return "no timestamps"
  }
  return f"{timed.first().when} to {timed.last().when}"
}

# Extra notes an analyst wants for some alerts.
fn notes_for(alert) {
  let notes = []
  if alert.get("success_after", false) {
    notes.push(f"!! {alert.by} then logged in as {alert.success_user} at {alert.success_when}. Treat that account as compromised.")
  }
  if alert.has("users") {
    notes.push("Accounts tried: " + alert.users.join(", "))
  }
  if alert.has("ports") {
    notes.push(f"Ports probed: {alert.ports.slice(0, 20).join(", ")}")
  }
  if alert.rule == "Encoded PowerShell command" {
    let m = re.match("(?i)-(?:e|ec|enc|encodedcommand)\s+([A-Za-z0-9+/=]+)", alert.records[0].command_line)
    if m != nil {
      notes.push("Decoded PowerShell: " + encode.unbase64(m[1]))
    }
  }
  return notes
}

fn show_details(i) {
  if i == nil {
    return
  }
  let a = alerts[i]
  let text = f"{a.level.upper()}  {a.rule}\n"
  text += f"By: {by_text(a)}    Count: {a.count}    From {a.first_when} to {a.last_when}\n"
  if a.get("description", "") != "" {
    text += a.description + "\n"
  }
  for note in notes_for(a) {
    text += note + "\n"
  }
  text += "\nSample log lines:\n"
  for r in a.records.slice(0, 8) {
    text += "  " + r.raw + "\n"
  }
  details.set(text)
}

fn open_log() {
  let path = gui.open_file({"Logs": "*.log *.txt *.json *.jsonl *.csv", "All files": "*"}, "Open a log")
  if path != nil {
    load(path)
  }
}

fn export() {
  if current == nil {
    gui.alert("Open a log first.")
    return
  }
  let path = gui.save_file(f"triage-{fs.name(current)}.html", [".html"])
  if path == nil {
    return
  }
  let rows = []
  for a in alerts {
    rows.push({"level": a.level, "rule": a.rule, "by": by_text(a), "count": a.count,
               "first": a.first_when, "last": a.last_when})
  }
  let c = level_counts()
  let page = report.html(f"TB Log Triage: {fs.name(current)}", rows, {
    "intro": f"{len(records)} records in {format} format, {time_span()}.",
    "stats": {"Records": len(records), "Alerts": len(alerts), "Critical": c.critical, "High": c.high},
  })
  try {
    let saved = report.save(path, page)
    status.set(f"Report saved to {saved}")
  } catch err {
    gui.alert(err.message, "Couldn't save the report")
  }
}

load(start)
if SAMPLES.contains(fs.name(start)) {
  sample.set(fs.name(start))
}
win.run()

IOC workbench apps/ioc-workbench

Paste an email, an alert or a threat report (or open a file; it starts with the sample phishing email in apps/data) and the IOC Workbench lists every indicator of compromise in it: IP addresses, domains, URLs, email addresses, MD5/SHA-1/SHA-256 hashes and CVE IDs, with a count per type. Defanged indicators (hxxp://, 1.2.3[.]4, [@]) are found too.

From your TB folder: tb apps/ioc-workbench/main.tb

Download source: README.md main.tb

Screenshot of the IOC workbench app
main.tb, 156 lines
# IOC Workbench: paste an email, an alert or a report (or open a file) and
# get every indicator of compromise in it: IPs, domains, URLs, emails,
# hashes and CVEs. Defang or refang the text, and copy or save the IOCs as
# Markdown, JSON or CSV for a ticket.
# Run: tb apps/ioc-workbench/main.tb [file]

import gui
import ioc
import json
import report
import fs
import os

let SAMPLE = fs.join(fs.here(), "..", "data", "phishing_email.txt")
let TYPES = ["ips", "domains", "urls", "emails", "md5", "sha1", "sha256", "cves"]
let NAMES = {"ips": "IP", "domains": "Domain", "urls": "URL", "emails": "Email",
             "md5": "MD5", "sha1": "SHA-1", "sha256": "SHA-256", "cves": "CVE"}

let found = []   # list of {"type", "value", "defanged"}

let win = gui.window("IOC Workbench", {"width": 1100, "height": 720})
win.label("IOC Workbench", {"style": "title"})
win.label("Paste text (an email, an alert, a report), then Extract. Defanged IOCs like hxxp:// and 1.2.3[.]4 are found too.", {"style": "muted"})

let tools = win.row()
tools.button("Open file…", fn() { open_text() })
tools.button("Paste", fn() { paste_text() })
tools.button("Extract IOCs", fn() { extract() }, {"primary": true})
tools.button("Defang text", fn() { text.set(ioc.defang(text.value())) })
tools.button("Refang text", fn() { text.set(ioc.refang(text.value())) })

let panes = win.row({"expand": true})
let left = panes.column({"expand": true})
left.label("Text", {"style": "heading"})
let text = left.textarea({"height": 8})

let right = panes.column({"expand": true})
right.label("Indicators", {"style": "heading"})
let table = right.table(["Type", "Value", "Defanged"], {"widths": [80, 260, 260], "height": 8,
                                                        "on_open": fn(row) { copy_one(row) }})
let counts = right.label(" ", {"style": "muted", "wrap": 560})

let out = win.row()
out.button("Copy as Markdown", fn() { copy_as("markdown") })
out.button("Copy as JSON", fn() { copy_as("json") })
out.button("Copy defanged list", fn() { copy_as("defanged") })
out.button("Save…", fn() { save() })

let status = win.status("Double-click an indicator to copy it (defanged).")

fn extract() {
  let got = ioc.extract(text.value())
  found = []
  let parts = []
  for t in TYPES {
    for value in got[t] {
      found.push({"type": NAMES[t], "value": value, "defanged": ioc.defang(value)})
    }
    if len(got[t]) > 0 {
      parts.push(f"{len(got[t])} {NAMES[t]}")
    }
  }
  let rows = []
  for f in found {
    rows.push([f.type, f.value, f.defanged])
  }
  table.set_rows(rows)
  if found.is_empty() {
    counts.set("No indicators found.")
  } else {
    counts.set(parts.join("  ·  "))
  }
  status.set(f"{len(found)} indicators found.")
}

fn as_text(how) {
  if how == "markdown" {
    return report.markdown(found, ["type", "value", "defanged"])
  }
  if how == "json" {
    return json.stringify(found, true)
  }
  if how == "csv" {
    return report.csv(found, ["type", "value", "defanged"])
  }
  return found.map(fn(f) { return f.defanged }).join("\n")
}

fn copy_as(how) {
  if found.is_empty() {
    gui.alert("Extract some IOCs first.")
    return
  }
  gui.copy(as_text(how))
  status.set(f"Copied {len(found)} indicators ({how}).")
}

fn copy_one(row) {
  gui.copy(row[2])
  status.set(f"Copied {row[2]}")
}

fn save() {
  if found.is_empty() {
    gui.alert("Extract some IOCs first.")
    return
  }
  let path = gui.save_file("iocs.md", {"Markdown": "*.md", "JSON": "*.json", "CSV": "*.csv", "Text": "*.txt"})
  if path == nil {
    return
  }
  let how = "defanged"
  if fs.ext(path).lower() == ".md" {
    how = "markdown"
  } else if fs.ext(path).lower() == ".json" {
    how = "json"
  } else if fs.ext(path).lower() == ".csv" {
    how = "csv"
  }
  try {
    report.save(path, as_text(how))
    status.set(f"Saved {len(found)} indicators to {path}")
  } catch err {
    gui.alert(err.message, "Couldn't save")
  }
}

fn load(path) {
  try {
    text.set(fs.read(path))
  } catch err {
    gui.alert(err.message, "Couldn't open")
    return
  }
  win.title(f"{fs.name(path)} - IOC Workbench")
  extract()
}

fn open_text() {
  let path = gui.open_file({"Text": "*.txt *.eml *.log *.md *.json *.csv", "All files": "*"})
  if path != nil {
    load(path)
  }
}

fn paste_text() {
  text.set(gui.paste())
  extract()
}

if len(os.args) > 0 {
  load(os.args[0])
} else {
  load(SAMPLE)
}
win.run()

Security (SOC) tools

Command-line tools for the jobs a security analyst does every day.

Log triage apps/log-triage

Reads log files, runs detection rules over them and prints a table of alerts, most serious first, with notes an analyst would want (who got in, what an encoded PowerShell command really says, which ports a scanner touched). This is the first thing a SOC analyst does with a log: triage.

From your TB folder: tb apps/log-triage/main.tb

Download source: README.md main.tb rules.tb rules_test.tb samples_test.tb

Example run
tb apps/log-triage/main.tb                                 # all the sample logs in apps/data
tb apps/log-triage/main.tb /var/log/auth.log               # your own log (any format)
tb apps/log-triage/main.tb apps/data/access.log --html triage.html
tb test apps/log-triage                                    # the app's own tests
main.tb, 143 lines
# log-triage: read log files, run detections, and show what needs attention.
#
#   tb apps/log-triage/main.tb                      triage every sample log
#   tb apps/log-triage/main.tb /var/log/auth.log    triage your own log
#   tb apps/log-triage/main.tb access.log --html report.html

import os
import fs
import logs
import report
import encode
import re
import "rules.tb"

let data = fs.join(fs.here(), "..", "data")
let files = []
let html_path = nil

let i = 0
while i < len(os.args) {
  let arg = os.args[i]
  if arg == "--html" {
    if i + 1 >= len(os.args) {
      say("--html needs a file name, like: --html report.html")
      exit(2)
    }
    html_path = os.args[i + 1]
    i += 2
    continue
  }
  if arg == "--help" or arg == "-h" {
    say("Usage: tb main.tb [log ...] [--html report.html]")
    exit(0)
  }
  if arg.starts_with("--") {
    say(f"Unknown option {arg}. Usage: tb main.tb [log ...] [--html report.html]")
    exit(2)
  }
  files.push(arg)
  i += 1
}
if files.is_empty() {
  for name in ["auth.log", "access.log", "firewall.log", "windows_security.json", "windows_security.csv"] {
    files.push(fs.join(data, name))
  }
  say("No log given, so here is a triage of the sample logs in apps/data.")
  say("")
}

# The time span a list of records covers, as text.
fn span(records) {
  let timed = records.filter(fn(r) { return r.time != nil }).sort_by(fn(r) { return r.time })
  if timed.is_empty() {
    return "no timestamps"
  }
  return f"{timed.first().when} to {timed.last().when}"
}

# Notes an analyst would want next to some alerts.
fn notes_for(alert) {
  let notes = []
  if alert.get("success_after", false) {
    notes.push(f"{alert.by} guessed a password: {alert.success_user} logged in at {alert.success_when}. Treat the account as compromised.")
  }
  if alert.rule == "Encoded PowerShell command" {
    let cmd = alert.records[0].command_line
    let m = re.match("(?i)-(?:e|ec|enc|encodedcommand)\s+([A-Za-z0-9+/=]+)", cmd)
    if m != nil {
      notes.push("Decoded PowerShell: " + encode.unbase64(m[1]))
    }
  }
  if alert.rule == "Port scan" {
    notes.push(f"{alert.by} probed {alert.count} ports: {alert.ports.slice(0, 12).join(", ")}...")
  }
  return notes
}

let all_alerts = []
let total_records = 0
let files_read = 0
for path in files {
  let records = []
  try {
    records = logs.read(path)
  } catch err {
    say(f"Skipping {path}: {err.message}")
    continue
  }
  files_read += 1
  total_records += len(records)
  let format = "empty"
  if not records.is_empty() {
    format = records[0].format
  }
  say(f"== {fs.name(path)}  ({format}, {len(records)} records, {span(records)})")

  let busiest = logs.top(records, "ip", 3).map(fn(t) { return f"{t.value} ({t.count})" })
  if not busiest.is_empty() {
    say("   busiest IPs: " + busiest.join(", "))
  }

  let alerts = rules.triage(records)
  if alerts.is_empty() {
    say("   No alerts. Nothing matched the rules.")
    say("")
    continue
  }
  for a in alerts {
    a.log = fs.name(path)
    all_alerts.push(a)
  }
  say(report.table(alerts, ["level", "rule", "by", "count", "first_when", "last_when"], {"max_width": 36}))
  for a in alerts {
    for note in notes_for(a) {
      say("   ! " + note)
    }
  }
  say("")
}

if files_read == 0 {
  say("None of the logs could be read, so there is nothing to triage.")
  exit(1)
}

let serious = all_alerts.filter(fn(a) { return a.level == "critical" or a.level == "high" })
say(f"Done: {total_records} records in {files_read} file(s), {len(all_alerts)} alert(s), {len(serious)} high or critical.")

if not serious.is_empty() {
  say("")
  say("Timeline of the high and critical alerts:")
  say(report.timeline(serious))
}

if html_path != nil {
  let page = report.html("Log triage", all_alerts, {
    "columns": ["level", "rule", "by", "count", "first_when", "last_when", "log"],
    "intro": f"{len(files)} log file(s), {total_records} records. Alerts are sorted by level, then time.",
    "stats": {"records": total_records, "alerts": len(all_alerts), "high or critical": len(serious)},
  })
  say("")
  say("HTML report saved to " + report.save(html_path, page))
}

IOC extractor apps/ioc-extractor

Pulls indicators of compromise (IOCs) out of any text file - a phishing email, a threat report, a ticket - and prints them defanged, as a Markdown table or JSON, ready to paste into a ticket or feed into a block list.

From your TB folder: tb apps/ioc-extractor/main.tb

Download source: README.md main.tb

Example run
tb apps/ioc-extractor/main.tb                          # the sample phishing email
tb apps/ioc-extractor/main.tb report.txt               # your own file
tb apps/ioc-extractor/main.tb report.txt --json        # JSON instead of Markdown
tb apps/ioc-extractor/main.tb report.txt --raw         # don't defang
tb apps/ioc-extractor/main.tb report.txt --out iocs.md # also save the output
main.tb, 124 lines
# ioc-extractor: pull indicators of compromise out of a text file
# (an email, a threat report, a ticket) and print them defanged.
#
#   tb apps/ioc-extractor/main.tb                    the sample phishing email
#   tb apps/ioc-extractor/main.tb report.txt         your own file
#   tb apps/ioc-extractor/main.tb report.txt --json  JSON instead of Markdown
#   options: --json, --md (default), --raw (don't defang), --out FILE

import os
import fs
import ioc
import json
import report

let path = fs.join(fs.here(), "..", "data", "phishing_email.txt")
let style = "md"
let defang = true
let out_path = nil

let i = 0
while i < len(os.args) {
  let arg = os.args[i]
  if arg == "--json" {
    style = "json"
  } else if arg == "--md" {
    style = "md"
  } else if arg == "--raw" {
    defang = false
  } else if arg == "--out" {
    if i + 1 >= len(os.args) {
      say("--out needs a file name, like: --out iocs.md")
      exit(2)
    }
    out_path = os.args[i + 1]
    i += 1
  } else if arg == "--help" or arg == "-h" {
    say("Usage: tb main.tb [file] [--json | --md] [--raw] [--out FILE]")
    exit(0)
  } else if arg.starts_with("--") {
    say(f"Unknown option {arg}. Usage: tb main.tb [file] [--json | --md] [--raw] [--out FILE]")
    exit(2)
  } else {
    path = arg
  }
  i += 1
}

let text = ""
try {
  text = fs.read(path)
} catch err {
  say(err.message)
  exit(1)
}

let found = ioc.extract(text)

# Plain-English names for each kind of IOC (one, many), in the order to show them.
let LABELS = {
  "ips": ["IP address", "IP addresses"], "domains": ["domain", "domains"], "urls": ["URL", "URLs"],
  "emails": ["email address", "email addresses"], "md5": ["MD5 hash", "MD5 hashes"],
  "sha1": ["SHA-1 hash", "SHA-1 hashes"], "sha256": ["SHA-256 hash", "SHA-256 hashes"], "cves": ["CVE ID", "CVE IDs"],
}

# 1 -> "1 URL", 3 -> "3 URLs"
fn count_of(n, kind) {
  if n == 1 {
    return f"1 {LABELS[kind][0]}"
  }
  return f"{n} {LABELS[kind][1]}"
}

fn shown(value) {
  if defang {
    return ioc.defang(value)
  }
  return value
}

let total = 0
for kind in found {
  total += len(found[kind])
}

let output = ""
if style == "json" {
  let safe = {}
  for kind in found {
    safe[kind] = found[kind].map(shown)
  }
  output = json.stringify({"source": fs.name(path), "total": total, "defanged": defang, "iocs": safe}, true)
} else {
  let rows = []
  for kind in LABELS.keys() {
    for value in found[kind] {
      rows.push({"type": LABELS[kind][0], "value": shown(value)})
    }
  }
  let lines = [f"# IOCs in {fs.name(path)}", ""]
  let counts = []
  for kind in LABELS.keys() {
    if not found[kind].is_empty() {
      counts.push(count_of(len(found[kind]), kind))
    }
  }
  if total == 0 {
    lines.push("No IOCs found.")
  } else {
    lines.push(f"Found {total} IOC" + (total == 1 and "" or "s") + ": " + counts.join(", ") + ".")
    lines.push("")
    lines.push(report.markdown(rows, ["type", "value"]))
  }
  if defang {
    lines.push("")
    lines.push("Values are defanged (hxxp, [.]) so they can't be clicked. Use --raw to see them as they are.")
  }
  output = lines.join("\n")
}

say(output)
if out_path != nil {
  say("")
  say("Saved to " + report.save(out_path, output))
}

Brute-force hunter apps/brute-force-hunter

Finds password guessing in Linux auth.log and Windows Security logs (JSON from Get-WinEvent | ConvertTo-Json, or Event Viewer CSV), ranks the sources, and says whether any of them then logged in - the moment a brute force becomes an incident.

From your TB folder: tb apps/brute-force-hunter/main.tb

Download source: README.md main.tb

Example run
tb apps/brute-force-hunter/main.tb                              # the sample logs
tb apps/brute-force-hunter/main.tb /var/log/auth.log            # your own log(s)
tb apps/brute-force-hunter/main.tb auth.log --attempts 10 --within 300
main.tb, 144 lines
# brute-force-hunter: find password guessing in Linux auth.log and Windows
# Security logs, and say whether any attacker got in.
#
#   tb apps/brute-force-hunter/main.tb                        the sample logs
#   tb apps/brute-force-hunter/main.tb /var/log/auth.log      your own log(s)
#   tb apps/brute-force-hunter/main.tb auth.log --attempts 10 --within 300

import os
import fs
import logs
import detect
import report

let attempts = 5
let within = 600
let files = []

let i = 0
while i < len(os.args) {
  let arg = os.args[i]
  if (arg == "--attempts" or arg == "--within") and i + 1 >= len(os.args) {
    say(f"{arg} needs a number after it.")
    exit(2)
  }
  if arg == "--attempts" or arg == "--within" {
    let value = os.args[i + 1]
    if not value.is_number() {
      say(f"{arg} needs a number, not \"{value}\".")
      exit(2)
    }
    if arg == "--attempts" {
      attempts = int(value.to_number())
    } else {
      within = value.to_number()
    }
    i += 2
    continue
  }
  if arg == "--help" or arg == "-h" {
    say("Usage: tb main.tb [log ...] [--attempts N] [--within SECONDS]")
    exit(0)
  }
  if arg.starts_with("--") {
    say(f"Unknown option {arg}. Usage: tb main.tb [log ...] [--attempts N] [--within SECONDS]")
    exit(2)
  }
  files.push(arg)
  i += 1
}
if files.is_empty() {
  let data = fs.join(fs.here(), "..", "data")
  files = [fs.join(data, "auth.log"), fs.join(data, "windows_security.json"), fs.join(data, "windows_security.csv")]
}

# 135 -> "2m15s"
fn duration(seconds) {
  let s = int(seconds)
  if s < 60 {
    return f"{s}s"
  }
  if s < 3600 {
    if s % 60 == 0 {
      return f"{int(s / 60)}m"
    }
    return f"{int(s / 60)}m{s % 60}s"
  }
  return f"{int(s / 3600)}h{int((s % 3600) / 60)}m"
}

# ["a", "b", "c", "d", "e"] -> "a, b, c +2"
fn short_list(items, n) {
  if len(items) <= n {
    return items.join(", ")
  }
  return items.slice(0, n).join(", ") + f" +{len(items) - n}"
}

say(f"Hunting for {attempts}+ failed logins from one source within {duration(within)}.")
say("")

let offenders = []
let quiet = []
let files_read = 0
for path in files {
  let records = []
  try {
    records = logs.read(path)
  } catch err {
    say(f"Skipping {path}: {err.message}")
    continue
  }
  files_read += 1
  let failures = detect.failed_logins(records)
  say(f"{fs.name(path)}: {len(records)} records, {len(failures)} failed logins")

  let alerts = detect.brute_force(records, attempts, within)
  let loud = []
  for a in alerts {
    loud.push(a.by)
    offenders.push({
      "source": a.by,
      "log": fs.name(path),
      "failures": a.count,
      "accounts tried": short_list(a.users, 4),
      "from": a.first_when,
      "for": duration(a.last - a.first),
      "got in?": a.success_after and f"YES as {a.success_user}" or "no",
      "_alert": a,
    })
  }
  # Sources that failed, but too few times to count as brute force.
  for t in logs.top(failures, "ip", 50) {
    if t.value not in loud {
      quiet.push({"source": t.value, "log": fs.name(path), "failures": t.count})
    }
  }
}

say("")
if files_read == 0 {
  say("None of the logs could be read, so there is nothing to hunt in.")
  exit(1)
}
if offenders.is_empty() {
  say("No brute force found. Try a lower --attempts or a longer --within.")
} else {
  let ranked = offenders.sort_by(fn(o) { return 0 - o.failures })
  say(f"{len(ranked)} brute-force source(s):")
  say(report.table(ranked, ["source", "log", "failures", "accounts tried", "from", "for", "got in?"]))
  for o in ranked {
    let a = o._alert
    if a.success_after {
      say("")
      say(f"!! {a.by} guessed a password: {a.success_user} logged in at {a.success_when} after {a.count} failures.")
      say("   Next steps: disable or reset that account, block the IP, and check what it did after logging in.")
    }
  }
}

if not quiet.is_empty() {
  say("")
  say("Below the threshold (worth a look, slow attackers stay quiet on purpose):")
  say(report.table(quiet))
}

Sigma runner apps/sigma-runner

Runs Sigma detection rules - the shared YAML format detection engineers use to write rules once for any SIEM - against log files.

From your TB folder: tb apps/sigma-runner/main.tb

Download source: README.md main.tb

Example run
tb apps/sigma-runner/main.tb                            # sample rules on the sample logs
tb apps/sigma-runner/main.tb my-rules/ /var/log/auth.log
tb apps/sigma-runner/main.tb rule.yml access.log        # one rule file
main.tb, 127 lines
# sigma-runner: run Sigma detection rules (YAML files) against log files.
#
#   tb apps/sigma-runner/main.tb                         sample rules on the sample logs
#   tb apps/sigma-runner/main.tb my-rules/ auth.log      your rules on your log(s)
#   tb apps/sigma-runner/main.tb rule.yml access.log     one rule

import os
import fs
import logs
import detect
import report

let data = fs.join(fs.here(), "..", "data")
let rules_path = fs.join(data, "sigma")
let files = []

for arg in os.args {
  if arg == "--help" or arg == "-h" {
    say("Usage: tb main.tb [rules-folder-or-file] [log ...]")
    exit(0)
  }
}
for arg in os.args {
  if arg.starts_with("--") {
    say(f"Unknown option {arg}. Usage: tb main.tb [rules-folder-or-file] [log ...]")
    exit(2)
  }
}
if len(os.args) >= 1 {
  rules_path = os.args[0]
  files = os.args.slice(1)
}
if files.is_empty() {
  for name in ["auth.log", "access.log", "windows_security.json"] {
    files.push(fs.join(data, name))
  }
}

# Load every rule file on its own, so one broken rule doesn't stop the rest.
let rule_files = [rules_path]
if fs.is_dir(rules_path) {
  rule_files = fs.walk(rules_path).filter(fn(p) { return p.ends_with(".yml") or p.ends_with(".yaml") })
}
let rules = []
let broken = 0
for path in rule_files {
  try {
    rules.push(detect.sigma(path))
  } catch err {
    broken += 1
    say(f"Skipping {fs.name(path)}: {err.message}")
  }
}
if rules.is_empty() {
  say("No Sigma rules loaded, so there is nothing to run.")
  exit(1)
}

# What kind of logs a rule is for, from its logsource.
fn source_of(rule) {
  let parts = []
  for key in ["product", "service", "category"] {
    let value = rule.logsource.get(key, nil)
    if value != nil {
      parts.push(value)
    }
  }
  if parts.is_empty() {
    return "any"
  }
  return parts.join("/")
}

fn threshold_of(rule) {
  if rule.count <= 1 {
    return "every match"
  }
  let text = f"{rule.count}+"
  if rule.by != nil {
    text += f" per {rule.by}"
  }
  if rule.within != nil {
    text += f" in {rule.within}s"
  }
  return text
}

say(f"Loaded {len(rules)} Sigma rule(s)" + (broken > 0 and f" ({broken} skipped)" or "") + ":")
let rule_rows = rules.map(fn(r) {
  return {"level": r.level, "title": r.name, "logs": source_of(r), "alerts on": threshold_of(r)}
})
say(report.table(rule_rows))

let total = 0
let files_read = 0
for path in files {
  say("")
  let records = []
  try {
    records = logs.read(path)
  } catch err {
    say(f"Skipping {path}: {err.message}")
    continue
  }
  files_read += 1
  let alerts = detect.run(rules, records)
  total += len(alerts)
  say(f"== {fs.name(path)}: {len(records)} records, {len(alerts)} alert(s)")
  if not alerts.is_empty() {
    say(report.table(alerts, ["level", "rule", "by", "count", "first_when", "last_when"], {"max_width": 44}))
    for a in alerts {
      let example = a.records[0]
      let detail = example.get("command_line", nil) or example.get("path", nil) or example.message
      let who = example.get("user", nil)
      if who != nil {
        detail = f"{detail} (user {who})"
      }
      say(f"   e.g. {detail.slice(0, 110)}")
    }
  }
}
say("")
if files_read == 0 {
  say("None of the logs could be read, so no rules were run.")
  exit(1)
}
say(f"{total} alert(s) from {len(rules)} rule(s) over {files_read} log(s).")

Network and intel

Check hosts you own, watch websites, and ask threat-intelligence services about indicators.

IP reputation apps/ip-reputation

Looks up IP addresses, domains, URLs and file hashes in threat-intelligence services and tells you whether they are known to be bad: the first thing a SOC analyst does with an indicator from an alert.

From your TB folder: tb apps/ip-reputation/main.tb

Download source: README.md main.tb

Example run
$ tb main.tb 185.220.101.1 44d88612fea8a8f36de82e1278abb02f
185.220.101.1  (ip)
  abuseipdb   malicious   score 100   1234 reports, DE, Example Hosting
  virustotal  malicious   score  12   11 of 94 engines flagged it
44d88612fea8a8f36de82e1278abb02f  (hash)
  virustotal  malicious   score  88   62 of 70 engines flagged it

Summary (worst verdict per indicator):
  malicious   185.220.101.1
  malicious   44d88612fea8a8f36de82e1278abb02f
main.tb, 191 lines
# ip-reputation: is this IP, domain, URL or file hash known to be bad?
#
#   tb main.tb 185.220.101.1 example.com 44d88612fea8a8f36de82e1278abb02f
#   tb main.tb --file indicators.txt
#
# Uses VirusTotal (VT_API_KEY), AbuseIPDB (ABUSEIPDB_API_KEY) and
# URLhaus (URLHAUS_API_KEY, optional). Keys are read from the environment
# and are never printed.

import os
import fs
import net
import intel
import re
import text

# The worse a verdict, the higher its rank.
let RANK = {"unknown": 0, "harmless": 1, "suspicious": 2, "malicious": 3}

fn usage() {
  say("Usage: tb main.tb INDICATOR [INDICATOR ...]")
  say("       tb main.tb --file indicators.txt   (one per line, # for comments)")
  say("An indicator is an IP address, a domain, a URL or an MD5/SHA-1/SHA-256 hash.")
}

# Analysts often share "defanged" indicators so nobody clicks them by
# accident: hxxp://evil[.]example. Turn them back into normal text.
fn refang(x) {
  return x.trim().replace("[.]", ".").replace("(.)", ".").replace("hxxp", "http").replace("[:]", ":")
}

fn kind_of(x) {
  if net.is_ip(x) {
    return "ip"
  }
  if re.test("^([0-9a-fA-F]{32}|[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$", x) {
    return "hash"
  }
  if x.lower().starts_with("http://") or x.lower().starts_with("https://") {
    return "url"
  }
  if re.test("^([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$", x) {
    return "domain"
  }
  return "unknown"
}

# One short line about a result, depending on where it came from.
fn summary(r) {
  let d = r.details
  if r.source == "abuseipdb" {
    return f"{d.reports} reports, {d.country or "?"}, {d.isp or "?"}"
  }
  if r.source == "urlhaus" {
    if d.listed {
      return f"listed as {d.threat or "malware"} ({d.status})"
    }
    return "not listed"
  }
  if not d.found {
    return "no report yet"
  }
  let total = d.malicious + d.suspicious + d.harmless + d.undetected
  return f"{d.malicious + d.suspicious} of {total} engines flagged it"
}

# Which lookups make sense for this kind of indicator (and have a key)?
fn lookups(kind, have_vt, have_abuse) {
  let todo = []
  if kind == "ip" and have_abuse {
    todo.push("abuseipdb")
  }
  if kind == "url" {
    todo.push("urlhaus")
  }
  if have_vt and kind != "unknown" {
    todo.push("virustotal")
  }
  return todo
}

fn look_up(source, kind, x) {
  if source == "abuseipdb" {
    return intel.abuseipdb(x)
  }
  if source == "urlhaus" {
    return intel.urlhaus_url(x)
  }
  if kind == "ip" {
    return intel.virustotal_ip(x)
  }
  if kind == "domain" {
    return intel.virustotal_domain(x)
  }
  if kind == "url" {
    return intel.virustotal_url(x)
  }
  return intel.virustotal_file(x)
}

# -- read the indicators ------------------------------------------------------

let items = []
let args = os.args
let i = 0
while i < len(args) {
  let a = args[i]
  if a == "--help" or a == "-h" {
    usage()
    exit(0)
  }
  if a == "--file" {
    if i + 1 >= len(args) {
      say("--file needs a file name after it.")
      exit(2)
    }
    try {
      for row in fs.lines(args[i + 1]) {
        let clean = row.trim()
        if clean != "" and not clean.starts_with("#") {
          items.push(clean)
        }
      }
    } catch err {
      say(err.message)
      exit(2)
    }
    i += 2
    continue
  }
  items.push(a)
  i += 1
}

if items.is_empty() {
  usage()
  exit(2)
}

let have_vt = intel.has_key("virustotal")
let have_abuse = intel.has_key("abuseipdb")
if not have_vt and not have_abuse {
  say("No threat-intel API keys are set, so I can't look anything up yet.")
  say("")
  say(intel.key_help())
  say("")
  say("Then run this again. (Free keys are enough for this tool.)")
  exit(1)
}

# -- look them up ---------------------------------------------------------------

let worst_of_all = []
for raw in items.unique() {
  let x = refang(raw)
  let kind = kind_of(x)
  say(f"{x}  ({kind})")
  if kind == "unknown" {
    say("  I don't know what this is. Use an IP, domain, URL or hash.")
    continue
  }
  if kind == "ip" and net.is_private(x) {
    say("  private/internal address: not looked up online (no one outside your network knows it).")
    continue
  }
  let worst = "unknown"
  let todo = lookups(kind, have_vt, have_abuse)
  if todo.is_empty() {
    say("  no lookup available for this kind without a VirusTotal key (set VT_API_KEY).")
  }
  for source in todo {
    try {
      let r = look_up(source, kind, x)
      say(f"  {text.pad_right(source, 11)} {text.pad_right(r.verdict, 11)} score {text.pad_left(r.score, 3)}   {summary(r)}")
      if RANK[r.verdict] > RANK[worst] {
        worst = r.verdict
      }
    } catch err {
      say(f"  {text.pad_right(source, 11)} skipped: {err.message}")
    }
  }
  worst_of_all.push([x, worst])
}

if not worst_of_all.is_empty() {
  say("")
  say("Summary (worst verdict per indicator):")
  for pair in worst_of_all {
    say(f"  {text.pad_right(pair[1], 11)} {pair[0]}")
  }
}

Port check apps/port-check

Checks which common TCP ports are open on a computer you own, and prints a table. By default it checks this computer (127.0.0.1).

From your TB folder: tb apps/port-check/main.tb

Download source: README.md main.tb

Example run
$ tb main.tb
Port check for 127.0.0.1 (127.0.0.1), 29 ports

PORT   SERVICE        STATE
21     ftp            closed
22     ssh            closed
...
5432   postgres       OPEN
...
1 open, 28 closed or filtered.
main.tb, 106 lines
# port-check: which common ports are open on a computer you own?
#
#   tb main.tb                         check this computer (127.0.0.1)
#   tb main.tb 192.168.1.20            check another machine on your network
#   tb main.tb 127.0.0.1 22,80,8000-8010
#   tb main.tb 127.0.0.1 --banners     also show what open services say first
#
# Only check computers you own or have written permission to test.
# Checking other people's systems can be against the law.

import os
import net
import text

fn usage() {
  say("Usage: tb main.tb [HOST] [PORTS] [--banners] [--authorised]")
  say("  HOST     default 127.0.0.1 (this computer)")
  say("  PORTS    like 22,80,443 or 8000-8010 (default: common ports)")
  say("  --banners     read the greeting of each open port (SSH, FTP, mail...)")
  say("  --authorised  needed for addresses outside your own network:")
  say("                you confirm you own the machine or have written permission.")
}

let host = "127.0.0.1"
let ports = nil
let banners = false
let authorised = false
let positional = []
for a in os.args {
  if a == "--help" or a == "-h" {
    usage()
    exit(0)
  } else if a == "--banners" {
    banners = true
  } else if a == "--authorised" or a == "--authorized" {
    authorised = true
  } else {
    positional.push(a)
  }
}
if len(positional) > 0 {
  host = positional[0]
}
if len(positional) > 1 {
  ports = positional[1]
}
if ports == nil {
  ports = sorted(keys(net.common_ports))
}

# Resolve the name first, so we know where the checks really go.
let ip = host
if not net.is_ip(host) {
  try {
    ip = net.resolve(host)[0]
  } catch err {
    say(err.message)
    exit(2)
  }
}

# Public addresses need an explicit promise that this is allowed.
if not net.is_private(ip) and not authorised {
  say(f"{host} ({ip}) is outside your own network.")
  say("Only check machines you own or have written permission to test.")
  say("If that's the case, run again with --authorised.")
  exit(2)
}

let results = {}
try {
  results = net.check_ports(ip, ports, 1)
} catch err {
  say(err.message)
  exit(2)
}

say(f"Port check for {host} ({ip}), {len(results)} ports")
say("")
say(f"{text.pad_right("PORT", 7)}{text.pad_right("SERVICE", 15)}STATE")
let open_count = 0
for port in keys(results) {
  let service = net.common_ports.get(port, "")
  let state = "closed"
  if results[port] {
    state = "OPEN"
    open_count += 1
  }
  let row = f"{text.pad_right(port, 7)}{text.pad_right(service, 15)}{state}"
  if banners and results[port] {
    try {
      let b = net.banner(ip, port, 1)
      if b != "" {
        row = row + "   " + b.slice(0, 60)
      }
    } catch err {
      # the service went away between the check and the banner; skip it
    }
  }
  say(row)
}
say("")
say(f"{open_count} open, {len(results) - open_count} closed or filtered.")
if open_count > 0 {
  say("Tip: anything open that you don't recognise is worth a look (what program is listening?).")
}

Uptime monitor apps/uptime-monitor

Checks a list of websites and prints whether each one is up, its HTTP status and how long it took to answer.

From your TB folder: tb apps/uptime-monitor/main.tb

Download source: README.md main.tb sites.json

Example run
$ tb main.tb
Uptime check, 2026-10-26 10:29:11
SITE                  STATE  STATUS      TIME  NOTE
TB website            UP     200       689 ms
Portfolio             UP     200      1913 ms
All 2 sites are up.
main.tb, 133 lines
# uptime-monitor: are my websites up, and how fast do they answer?
#
#   tb main.tb                    check the sites in sites.json (next to this file)
#   tb main.tb my-sites.json      use another list
#   tb main.tb --every 60         keep checking every 60 seconds (Ctrl+C to stop)
#
# The list is JSON: {"timeout": 10, "slow_ms": 2000, "sites": [{"name": "...", "url": "https://..."}]}
# or simply a list of URLs: ["https://example.com", "https://example.org"].
# Exit code: 0 if every site is up, 1 if any is down (handy for scheduled jobs).

import os
import fs
import json
import http
import time
import text

fn usage() {
  say("Usage: tb main.tb [SITES.json] [--every SECONDS]")
}

# Read the config and give back {"timeout", "slow_ms", "sites": [{"name", "url"}]}.
fn load_config(path) {
  let data = json.read(path)
  let config = {"timeout": 10, "slow_ms": 2000, "sites": []}
  let sites = data
  if type(data) == "map" {
    config.timeout = data.get("timeout", 10)
    config.slow_ms = data.get("slow_ms", 2000)
    sites = data.get("sites", [])
  }
  for site in sites {
    if type(site) == "text" {
      config.sites.push({"name": site, "url": site})
    } else {
      config.sites.push({"name": site.get("name", site.url), "url": site.url})
    }
  }
  return config
}

# Check one site: {"name", "url", "up", "status", "ms", "note"}.
fn check(site, config) {
  let result = {"name": site.name, "url": site.url, "up": false, "status": "-", "ms": nil, "note": ""}
  try {
    let r = http.get(site.url, {"timeout": config.timeout})
    result.status = r.status
    result.ms = round(r.elapsed)
    result.up = r.status >= 200 and r.status < 400
    if result.up and r.elapsed > config.slow_ms {
      result.note = "slow"
    }
  } catch err {
    result.note = err.message
  }
  return result
}

fn report(config) {
  say(f"Uptime check, {time.format(time.timestamp(), "%Y-%m-%d %H:%M:%S")}")
  say(f"{text.pad_right("SITE", 22)}{text.pad_right("STATE", 7)}{text.pad_right("STATUS", 8)}{text.pad_left("TIME", 8)}  NOTE")
  let down = 0
  for site in config.sites {
    let r = check(site, config)
    let state = "UP"
    if not r.up {
      state = "DOWN"
      down += 1
    }
    let ms = "-"
    if r.ms != nil {
      ms = f"{r.ms} ms"
    }
    let row = f"{text.pad_right(r.name.slice(0, 21), 22)}{text.pad_right(state, 7)}{text.pad_right(r.status, 8)}{text.pad_left(ms, 8)}  {r.note}"
    say(row.trim())
  }
  let total = len(config.sites)
  if down == 0 {
    say(f"All {total} sites are up.")
  } else {
    say(f"{down} of {total} sites are DOWN.")
  }
  return down
}

# -- arguments ------------------------------------------------------------------

let path = fs.here("sites.json")
let every = nil
let args = os.args
let i = 0
while i < len(args) {
  let a = args[i]
  if a == "--help" or a == "-h" {
    usage()
    exit(0)
  } else if a == "--every" {
    if i + 1 >= len(args) or not args[i + 1].is_number() {
      say("--every needs a number of seconds after it.")
      exit(2)
    }
    every = max(5, args[i + 1].to_number())
    i += 1
  } else {
    path = a
  }
  i += 1
}

let config = nil
try {
  config = load_config(path)
} catch err {
  say(f"I couldn't read the site list: {err.message}")
  exit(2)
}
if len(config.sites) == 0 {
  say(f"{path} has no sites to check.")
  exit(2)
}

if every == nil {
  let down = report(config)
  if down > 0 {
    exit(1)
  }
} else {
  while true {
    report(config)
    say("")
    time.sleep(every)
  }
}