For Tanisha
The very first TB program, a little letter written in code. Lists, loops, functions and a note at the end.
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.
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.
The very first TB program, a little letter written in code. Lists, loops, functions and a note at the end.
Your very first TB program. It says hello and does a little maths.
Mixes words from two lists to write lots of little compliments.
Counts down with a while loop, then counts by twos with range().
A playful pattern: every 3rd number says "love", every 5th says "hugs".
Builds a shopping list with push(), changes an item, and prints it with for.
Closures: little functions that keep their own memory, like a hug counter.
Recursion: Fibonacci numbers, factorials, and a staircase of stars.
A small guessing game. TB is thinking of a number from 1 to 20.
A gentle little bot that asks how you are feeling and answers kindly.
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.")
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 + ".")
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.")
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)
}
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)
}
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
}
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"))
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)
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?")
}
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)
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 TB | Run the To-do app |
|---|---|
| Windows installer | Start menu, TB, To-do, or in TB Prompt: tb "%LOCALAPPDATA%\Programs\TB\apps\todo\main.tb" |
| Windows zip | tb\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.
Real windows with buttons, tables and menus, built with the gui library.
apps/todoA 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

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()
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

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()
apps/notesA 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

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()
apps/calculatorA 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

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()
apps/hash-checkerIs 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

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()
apps/log-triage-guiTB 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

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()
apps/ioc-workbenchPaste 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

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()
Command-line tools for the jobs a security analyst does every day.
apps/log-triageReads 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
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))
}
apps/ioc-extractorPulls 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
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))
}
apps/brute-force-hunterFinds 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
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))
}
apps/sigma-runnerRuns 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
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).")
Check hosts you own, watch websites, and ask threat-intelligence services about indicators.
apps/ip-reputationLooks 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
$ 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]}")
}
}
apps/port-checkChecks 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
$ 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?).")
}
apps/uptime-monitorChecks 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
$ 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)
}
}