30 KiB
03-IMPLEMENTATION.md
Implementation
This document walks through the actual code: how each component works, why it's structured the way it is, and what patterns to look for when reading or extending the codebase.
File Structure
src/
├── harvester.nim # Entry point — CLI parsing, main orchestration
├── runner.nim # Collector dispatch — maps categories to collectors
├── types.nim # Type definitions — Finding, Report, Severity, etc
├── config.nim # Constants — every path, pattern, threshold, color
├── collectors/
│ ├── base.nim # Shared utils — file ops, permissions, factories
│ ├── browser.nim # Firefox + Chromium credential stores
│ ├── ssh.nim # SSH keys, config, authorized_keys
│ ├── git.nim # .git-credentials, tokens, config helpers
│ ├── cloud.nim # AWS, GCP, Azure, Kubernetes
│ ├── history.nim # Shell history, command patterns, .env files
│ ├── keyring.nim # GNOME, KDE, KeePass, pass, Bitwarden
│ └── apptoken.nim # DB creds, dev tokens, infra tokens, Docker
└── output/
├── terminal.nim # Color terminal with box drawing
└── json.nim # Structured JSON output
The Zero-Exception Foundation
Every source file opens with:
{.push raises: [].}
This Nim pragma tells the compiler: no procedure in this file may propagate an exception. Any call to a function that might raise (file I/O, string parsing, etc.) must be wrapped in try/except within this file. The compiler enforces this at compile time and will reject code that could propagate an uncaught exception.
This matters for a security tool because crashing mid-scan means partial results, missed findings, and unreliable output. The {.push raises: [].} guarantee means that if the tool compiles, it will not crash from unhandled exceptions during a scan.
Where an operation genuinely can't avoid exceptions (like calling readFile), the pattern is:
proc readFileContent*(path: string): string =
try:
result = readFile(path)
except CatchableError:
result = ""
The exception is caught immediately and converted to a safe default. This pattern repeats throughout base.nim for every filesystem operation.
Entry Point: harvester.nim
CLI Parsing
The parseCli function builds a HarvestConfig from command-line arguments using Nim's stdlib parseopt:
proc parseCli(): HarvestConfig =
result = defaultConfig()
var parser = initOptParser(
commandLineParams(),
shortNoVal = {'d', 'q', 'v', 'h'},
longNoVal = @["dry-run", "quiet", "verbose", "help", "version"]
)
The shortNoVal and longNoVal parameters tell the parser which flags don't take values. Without this, --quiet --format json would try to parse --format as the value of --quiet.
The parser loop uses case parser.key.toLowerAscii() for case-insensitive matching, so --Target, --target, and --TARGET all work. Each recognized flag updates the result config. Unrecognized flags are silently discarded (else: discard).
Module Parsing
The parseModules function converts comma-separated module names to a sequence of Category values:
proc parseModules*(input: string): seq[Category] =
result = @[]
let parts = input.split(",")
for part in parts:
let name = part.strip().toLowerAscii()
for cat in Category:
if ModuleNames[cat] == name:
result.add(cat)
break
This iterates through every Category enum value and checks if the module name matches. Unknown module names are silently skipped. The ModuleNames array in config.nim maps each Category to its string name, so the mapping is defined in one place.
Main Orchestration
The main function is the control flow hub:
proc main() =
let conf = parseCli()
if conf.dryRun:
renderDryRun(conf)
quit(0)
var report = runCollectors(conf)
report.metadata.timestamp = now().utc.format("yyyy-MM-dd'T'HH:mm:ss'Z'")
case conf.outputFormat
of fmtTerminal: renderTerminal(report, conf.quiet, conf.verbose)
of fmtJson: renderJson(report, conf.outputPath)
of fmtBoth:
renderTerminal(report, conf.quiet, conf.verbose)
renderJson(report, conf.outputPath)
var hasHighSeverity = false
for sev in [svCritical, svHigh]:
if report.summary[sev] > 0:
hasHighSeverity = true
break
if hasHighSeverity: quit(1) else: quit(0)
The timestamp is set after the scan completes (not before) so it reflects when results were produced. The {.cast(raises: []).} pragma is used around the time formatting because now() and format() could technically raise, but in practice never do with valid format strings. The cast tells the compiler "I know what I'm doing here."
The exit code check only looks at CRITICAL and HIGH. MEDIUM, LOW, and INFO findings don't trigger a non-zero exit. This keeps the tool useful in CI pipelines where you want to fail on genuine exposures but not on informational notes.
Runner: runner.nim
Collector Dispatch
The getCollector function is the routing table:
proc getCollector(cat: Category): CollectorProc =
case cat
of catBrowser: browser.collect
of catSsh: ssh.collect
of catCloud: cloud.collect
of catHistory: history.collect
of catKeyring: keyring.collect
of catGit: git.collect
of catApptoken: apptoken.collect
Nim's case statement on an enum is exhaustive: if you add a new Category value without adding a case branch, the compiler will reject the code. This compile-time guarantee prevents the "forgot to wire up the new module" class of bug.
The return type CollectorProc is a function pointer type defined in types.nim:
CollectorProc = proc(config: HarvestConfig): CollectorResult {.nimcall, raises: [].}
The {.nimcall, raises: [].} calling convention means the function uses Nim's native calling convention and cannot raise exceptions. This contract is enforced at the type level.
Result Aggregation
The runCollectors function iterates enabled modules, calls each collector, and builds the report:
proc runCollectors*(config: HarvestConfig): Report =
let start = getMonoTime()
var results: seq[CollectorResult] = @[]
var moduleNames: seq[string] = @[]
for cat in config.enabledModules:
moduleNames.add(ModuleNames[cat])
let collector = getCollector(cat)
let res = collector(config)
results.add(res)
let elapsed = getMonoTime() - start
var summary: array[Severity, int]
for res in results:
for finding in res.findings:
inc summary[finding.severity]
The use of getMonoTime() (monotonic clock) instead of now() for timing is important. Monotonic time is immune to clock adjustments and NTP corrections. If the system clock changes during a scan, getMonoTime() still gives an accurate duration.
The summary array uses Severity as the index type, so summary[svCritical] directly gives the count of critical findings. The inc proc increments in-place without allocation.
Base Utilities: collectors/base.nim
Permission Inspection
The permission checking functions use POSIX stat directly:
proc getPermsString*(path: string): string =
var statBuf: Stat
try:
if stat(path.cstring, statBuf) == 0:
let mode = statBuf.st_mode and 0o7777
result = "0" & toOct(mode.int, 3)
else:
result = "unknown"
except CatchableError:
result = "unknown"
The stat syscall retrieves file metadata from the kernel. The mode field contains the permission bits in the lower 12 bits (mask 0o7777). The toOct function converts to octal representation. The string "0" prefix produces the familiar format: "0600", "0644", etc.
The world-readable and group-readable checks extract specific bits:
proc isWorldReadable*(path: string): bool =
var statBuf: Stat
try:
if stat(path.cstring, statBuf) == 0:
result = (statBuf.st_mode.int and WorldReadBit) != 0
except CatchableError:
discard
WorldReadBit is the constant 0o004 from config.nim. The bitwise AND isolates just the "others read" bit. If it's non-zero, the file is world-readable.
Severity from Permissions
The permissionSeverity function encapsulates the permission-to-severity logic:
proc permissionSeverity*(path: string, isDir: bool = false): Severity =
let perms = getNumericPerms(path)
if perms < 0:
return svInfo
if (perms and WorldReadBit) != 0:
return svCritical
if (perms and GroupReadBit) != 0:
return svMedium
let expected = if isDir: OwnerOnlyDirPerms else: OwnerOnlyFilePerms
if perms > expected:
return svLow
result = svInfo
Negative perms means the stat call failed (file doesn't exist or we can't read metadata). World-readable is always CRITICAL. Group-readable is MEDIUM. Anything looser than the expected permissions (0600 for files, 0700 for directories) is LOW. Correct permissions are INFO.
Finding Factories
The two factory functions construct Finding objects with consistent metadata:
proc makeFinding*(
path: string,
description: string,
category: Category,
severity: Severity
): Finding =
Finding(
path: path,
category: category,
severity: severity,
description: description,
credential: none(Credential),
permissions: getPermsString(path),
modified: getModifiedTime(path),
size: getFileSizeBytes(path)
)
Every finding automatically gets the current permissions, modification time, and file size of the target path. Collectors don't need to remember to look these up. The makeFindingWithCred variant takes an additional Credential parameter wrapped in some().
Value Redaction
The redactValue function shows the first N characters and replaces the rest:
proc redactValue*(value: string, showChars: int = 4): string =
if value.len <= showChars:
result = "*".repeat(value.len)
else:
result = value[0 ..< showChars] & "*".repeat(value.len - showChars)
For values shorter than or equal to showChars, the entire value is masked. For longer values, the first 4 characters are shown. This gives enough context to identify the credential type (e.g., "ghp_" for GitHub tokens, "AKIA" for AWS keys) without exposing the full secret.
Collector Implementations
SSH Collector: ssh.nim
The SSH collector has four sub-scanners: scanKeys, scanConfig, scanAuthorizedKeys, and scanKnownHosts.
Key scanning is the most complex sub-scanner. It walks the ~/.ssh/ directory, reads each file, and checks if it starts with a PEM header:
proc isPrivateKey*(content: string): bool =
for header in SshKeyHeaders:
if content.startsWith(header):
return true
SshKeyHeaders in config.nim contains all five PEM header formats. The check uses startsWith rather than contains because PEM headers must be at the start of the file.
Once a private key is found, encryption detection checks for known markers:
proc isEncrypted*(content: string): bool =
for marker in SshEncryptedMarkers:
if marker in content:
return true
The severity calculation combines encryption status and permissions:
if not encrypted:
sev = svHigh
else:
sev = svInfo
if perms >= 0 and (perms and WorldReadBit) != 0:
sev = svCritical
elif perms >= 0 and (perms and GroupReadBit) != 0:
if sev < svHigh:
sev = svHigh
An unencrypted key starts at HIGH. An encrypted key starts at INFO. Then permissions override upward: world-readable forces CRITICAL regardless of encryption. Group-readable escalates to at least HIGH.
Config scanning looks for weak settings:
if stripped.toLowerAscii().startsWith("passwordauthentication yes"):
weakSettings.add("PasswordAuthentication enabled")
if stripped.toLowerAscii().startsWith("stricthostkeychecking no"):
weakSettings.add("StrictHostKeyChecking disabled")
These are MEDIUM findings because they weaken the SSH connection security but don't directly expose credentials.
Browser Collector: browser.nim
Firefox scanning starts by reading profiles.ini to find profile directories:
let lines = readFileLines(profilesIniPath)
var profiles: seq[string] = @[]
var currentPath = ""
for line in lines:
let stripped = line.strip()
if stripped.startsWith("[Profile"):
if currentPath.len > 0:
profiles.add(currentPath)
currentPath = ""
if stripped.toLowerAscii().startsWith("path="):
currentPath = stripped.split("=", maxsplit = 1)[1]
This is a minimal INI parser that extracts the Path= value from each [Profile*] section. The maxsplit = 1 is important because profile paths themselves might contain = characters.
For each profile, the scanner checks three credential files:
let credFiles = [
(FirefoxLoginsFile, "Firefox stored logins database"),
(FirefoxCookiesDb, "Firefox cookies database"),
(FirefoxKeyDb, "Firefox key database")
]
for (fileName, desc) in credFiles:
let filePath = profileDir / fileName
if safeFileExists(filePath):
let sev = if isWorldReadable(filePath): svCritical
elif isGroupReadable(filePath): svHigh
else: svMedium
Note that browser credential files are always at least MEDIUM severity even with correct permissions. This is because the files themselves contain sensitive data (encrypted passwords, session cookies) that could be exfiltrated and attacked offline.
Chromium scanning follows a similar pattern but handles multiple browser variants (Chrome, Brave, Vivaldi, Chromium) and numbered profiles (Default, Profile 1, Profile 2, etc.).
Cloud Collector: cloud.nim
AWS scanning demonstrates the most detailed credential analysis. It reads the credentials file line by line, counting profiles and classifying key types:
if stripped.toLowerAscii().startsWith("aws_access_key_id"):
let parts = stripped.split("=", maxsplit = 1)
if parts.len == 2:
let keyVal = parts[1].strip()
if keyVal.startsWith(AwsStaticKeyPrefix):
inc staticKeys
elif keyVal.startsWith(AwsSessionKeyPrefix):
inc sessionKeys
Static keys (prefix AKIA) are long-lived and escalate severity to HIGH. Session keys (prefix ASIA) are temporary and less dangerous. The distinction matters for remediation prioritization.
Kubernetes scanning parses the kubeconfig YAML to count contexts and users, and to detect authentication methods:
if "token:" in stripped:
hasTokenAuth = true
if "client-certificate-data:" in stripped:
hasCertAuth = true
Token authentication is HIGH severity because bearer tokens provide direct API access. Certificate authentication is noted but not escalated because certificates are harder to use in isolation.
History Collector: history.nim
Secret pattern matching checks for known environment variable patterns:
proc matchesSecretPattern*(line: string): bool =
let upper = line.toUpperAscii()
for pattern in SecretPatterns:
if pattern in upper:
if "export " in line.toLowerAscii() or
line.strip().startsWith(pattern.split("=")[0]):
return true
The double check (pattern in upper AND either export or starts with key name) prevents false positives. PATH=/usr/bin contains = but doesn't match KEY=, TOKEN=, or PASSWORD=. The function requires both the pattern match and evidence that it's an actual variable assignment.
Command pattern matching uses a custom glob-like matcher:
proc matchesCommandPattern*(line: string): bool =
let lower = line.toLowerAscii()
for pattern in HistoryCommandPatterns:
let parts = pattern.split(".*")
if parts.len >= 2:
var allFound = true
var searchFrom = 0
for part in parts:
let idx = lower.find(part, start = searchFrom)
if idx < 0:
allFound = false
break
searchFrom = idx + part.len
if allFound:
return true
Patterns like "curl.*-h.*authoriz" are split on ".*" and each segment is searched sequentially. The searchFrom index ensures segments match in order. This implements a basic regex-like matching without pulling in a regex library.
History line limits prevent resource exhaustion:
const MaxHistoryLines = 50000
A developer who's been using the same shell for years might have hundreds of thousands of history entries. Scanning all of them would be slow and memory-intensive. The 50,000 line cap covers the most recent history (where secrets are most likely still valid) while keeping resource usage bounded.
Recursive .env scanning uses depth-limited directory walking:
proc walkForEnv(
dir: string,
depth: int,
excludePatterns: seq[string],
result: var CollectorResult
) =
if depth > MaxEnvDepth:
return
The depth limit of 5 and directory exclusions (node_modules, vendor, .git, __pycache__, .venv, .cache) keep the recursive walk fast. Without these limits, scanning a directory with deeply nested node_modules would take minutes.
Keyring Collector: keyring.nim
The keyring collector scans five different credential stores. The KeePass scanner is notable for its recursive file search:
proc walkForKdbx(
dir: string,
depth: int,
excludePatterns: seq[string],
result: var CollectorResult
) =
if depth > 5:
return
KeePass database files (.kdbx) can be stored anywhere in the home directory, not just in a standard location. The recursive walk finds them wherever they are, while the depth limit and directory exclusions prevent runaway scanning.
The pass (password-store) scanner counts GPG-encrypted entries:
for kind, path in walkDir(passDir, relative = false):
if kind == pcFile and path.endsWith(".gpg"):
inc entryCount
The count of entries tells the user (or attacker) how many credentials are stored, even though the entries themselves are GPG-encrypted and not directly readable.
App Token Collector: apptoken.nim
The app token collector uses a generic AppTarget type to handle application data directories:
type
AppTarget = object
path: string
name: string
description: string
isDir: bool
This lets the collector define scan targets as data rather than code:
let appTargets = [
AppTarget(path: SlackDir, name: "Slack",
description: "Slack desktop session data", isDir: true),
AppTarget(path: DiscordDir, name: "Discord",
description: "Discord desktop session data", isDir: true),
...
]
Each target is checked with the same logic: does it exist, and what are its permissions? The isDir flag determines whether to use file or directory permission checking.
The database credential scanning is more detailed. For PostgreSQL's .pgpass:
let lines = readFileLines(pgpassPath)
var entryCount = 0
for line in lines:
if line.strip().len > 0 and not line.strip().startsWith("#"):
inc entryCount
Non-comment, non-empty lines are counted as connection entries. The count goes into the credential metadata so the output can show "PostgreSQL password file with 3 entries" rather than just "PostgreSQL password file found."
Output Rendering
Terminal Renderer: terminal.nim
The terminal renderer handles the complexity of aligning text in box-drawn tables when strings contain invisible ANSI color codes and multi-byte UTF-8 characters.
Visual length calculation strips ANSI escape sequences and counts only visible characters:
proc visualLen(s: string): int =
var i = 0
while i < s.len:
if s[i] == '\e':
while i < s.len and s[i] != 'm':
inc i
if i < s.len:
inc i
elif (s[i].ord and 0xC0) == 0x80:
inc i
else:
inc result
inc i
ANSI escapes start with \e and end at m. UTF-8 continuation bytes have the pattern 10xxxxxx (the 0xC0 mask checks the top two bits). Only non-escape, non-continuation bytes count as visible characters. This is necessary because a string like "\e[31mERROR\e[0m" is 5 visible characters ("ERROR") but 15 bytes long.
Box line writing uses this visual length to pad each line to exactly BoxWidth characters:
proc writeBoxLine(content: string) =
try:
stdout.write content
let vLen = visualLen(content)
let pad = BoxWidth - vLen - 1
if pad > 0:
stdout.write " ".repeat(pad)
stdout.writeLine BoxVertical
except CatchableError:
discard
The -1 accounts for the closing BoxVertical character. This produces perfectly aligned box borders regardless of how many color codes or Unicode characters are in the content.
Severity badges combine color and label:
proc sevBadge(sev: Severity): string =
SeverityColors[sev] & ColorBold & " " & SeverityLabels[sev] & " " & ColorReset
The SeverityColors and SeverityLabels arrays are indexed by the Severity enum, so looking up the color for a severity is a direct array access.
JSON Renderer: json.nim
The JSON renderer converts each type to a JsonNode with recursive functions:
proc findingToJson(f: Finding): JsonNode =
result = newJObject()
{.cast(raises: []).}:
result["path"] = newJString(f.path)
result["category"] = newJString($f.category)
result["severity"] = newJString($f.severity)
result["description"] = newJString(f.description)
result["permissions"] = newJString(f.permissions)
result["modified"] = newJString(f.modified)
result["size"] = newJInt(f.size)
if f.credential.isSome:
result["credential"] = credentialToJson(f.credential.get())
The {.cast(raises: []).} block is needed because Nim's JSON operations technically can raise, but in practice won't when building objects from known-good data. The cast is scoped to just the JSON construction block.
The $ operator on enum values produces the string representation ("browser", "critical", etc.) defined by the enum value assignments in types.nim.
Testing Strategy
Unit Tests: test_all.nim
The unit tests cover pure functions that don't require filesystem state:
Redaction tests verify boundary conditions:
suite "redactValue":
test "short value fully redacted":
check redactValue("abc", 4) == "***"
test "value longer than showChars":
check redactValue("mysecret", 4) == "myse****"
Key detection tests validate all five key formats plus negative cases:
suite "isPrivateKey":
test "OpenSSH key":
check isPrivateKey("-----BEGIN OPENSSH PRIVATE KEY-----\ndata")
test "public key rejected":
check isPrivateKey("-----BEGIN PUBLIC KEY-----\ndata") == false
Pattern matching tests cover both true positives and true negatives:
suite "matchesSecretPattern":
test "export with KEY=":
check matchesSecretPattern("export API_KEY=some_value")
test "non-secret assignment":
check matchesSecretPattern("export PATH=/usr/bin") == false
Permission severity tests use non-existent paths to test the error handling path:
suite "permissionSeverity":
test "returns svInfo for unreadable path":
check permissionSeverity("/nonexistent/path/abc123") == svInfo
Module parsing tests verify the CLI-to-Category conversion including edge cases:
suite "parseModules":
test "single module":
check parseModules("ssh") == @[catSsh]
test "unknown module ignored":
check parseModules("fake,nonexistent").len == 0
Running tests: just test compiles and runs the test suite.
Integration Tests: Docker
The Docker-based integration test creates a controlled environment with known credential files and validates that the scanner detects all of them.
The Dockerfile uses a multi-stage build:
Stage 1 (builder): Compiles credenum from source using the official Nim Alpine image, producing a static binary.
Stage 2 (runtime): Ubuntu 24.04 with a testuser account. The planted/ directory is copied into the test user's home directory, creating realistic credential files across all 7 categories. Permissions are explicitly set to create specific severity scenarios (e.g., chmod 0644 on the unprotected SSH key to make it world-readable).
The validation script runs credenum against the test user's home directory and checks for expected findings:
OUTPUT=$(credenum --target /home/testuser --format json 2>&1) || true
check "SSH unprotected private key" "no passphrase"
check "AWS credentials with static keys" "static keys"
check "Firefox stored logins" "Firefox stored logins"
check "History secret pattern" "Secret in shell history"
Each check function searches the JSON output for an expected string. The validation covers all 7 collector categories with 30+ individual checks.
Running integration tests: just docker-test builds the Docker image and runs the validation.
Build System
Nim Compiler Configuration: config.nims
The config.nims file configures the Nim compiler without command-line flags:
switch("mm", "orc")
ORC (Overflowing Reference Counting) is Nim's modern memory management. It combines reference counting with a cycle collector, providing deterministic cleanup without a traditional garbage collector pause.
Musl static linking:
when defined(musl):
var muslGcc = findExe("musl-gcc")
if muslGcc.len > 0:
switch("gcc.exe", muslGcc)
switch("gcc.linkerexe", muslGcc)
switch("passL", "-static")
When -d:musl is passed, the compiler uses musl-gcc instead of the system gcc, producing a fully static binary with no glibc dependency. This binary runs on any Linux system regardless of glibc version.
Cross-compilation:
when defined(crossX86):
switch("passC", "-target x86_64-linux-musl")
switch("passL", "-target x86_64-linux-musl")
switch("os", "linux")
switch("cpu", "amd64")
The zigcc integration uses Zig's C compiler backend as a cross-compilation toolchain. Passing target triples through -passC and -passL produces binaries for different architectures without needing a full cross-compilation sysroot.
Justfile Build Targets
The Justfile organizes commands by group:
dev group: build (debug), run (build + execute), scan (build + scan current user), check (type check without compilation)
prod group: release (optimized), release-static (musl), release-small (musl + UPX), build-x86 (cross-compile x86_64), build-arm64 (cross-compile ARM64)
test group: test (unit tests), docker-build (build test image), docker-test (full integration test)
lint group: fmt (format with nph), fmt-check (verify formatting)
The Justfile uses shell variables for DRY configuration:
bin := "bin/credenum"
src := "src/harvester.nim"
All build targets reference these variables, so changing the binary name or entry point requires editing one line.
Code Organization Principles
One file per concern. Each collector is its own file. Types are separate from config. Output renderers are separate from each other. This means you can understand the browser collector by reading browser.nim alone.
Constants separate from logic. All paths, patterns, thresholds, colors, and labels live in config.nim. Collectors import config to get their scan targets. This separation means you can audit every credential path the tool checks by reading one file.
Shared utilities in base, not duplicated. Permission checking, file reading, finding construction, and redaction are in base.nim. No collector reimplements file stat calls or finding construction.
Types define the contract. The CollectorProc type ensures every collector has the same signature. The Report type defines what output renderers receive. The Severity enum's ordering determines comparison behavior. These types are the architecture, enforced by the compiler.
Extending the Code
Adding a New Collector
- Add a new
Categoryvalue intypes.nim(e.g.,catContainer = "container") - Add module name and description to arrays in
config.nim - Create
collectors/container.nim:- Start with
{.push raises: [].} - Import
../types,../config,base - Implement
proc collect*(config: HarvestConfig): CollectorResult - Use
newCollectorResult,makeFinding,makeFindingWithCredfrom base.nim
- Start with
- Import and route in
runner.nim:import collectors/container- Add
of catContainer: container.collectto the case statement
- Add test fixtures in
tests/docker/planted/and checks invalidate.sh
Adding a New Scan Target to an Existing Collector
- Add the path constant to
config.nim(e.g.,PodmanConfig* = ".config/containers/auth.json") - Add scanning logic to the relevant collector (e.g.,
apptoken.nim) - Add a test fixture and validation check
Adding a New Output Format
- Add a value to
OutputFormatintypes.nim - Create
output/sarif.nimwith a render proc - Add the case branch and CLI flag handling in
harvester.nim
Dependencies
The project uses only Nim's standard library. No external packages are required.
| Import | Purpose |
|---|---|
std/parseopt |
CLI argument parsing |
std/os |
File operations, path manipulation, home directory |
std/posix |
POSIX stat for permission inspection |
std/strutils |
String operations (split, strip, find, contains) |
std/times |
Timestamp formatting |
std/monotimes |
Performance timing |
std/options |
Optional[Credential] for findings |
std/tables |
Credential metadata key-value pairs |
std/json |
JSON output construction |
std/unittest |
Test framework |
Zero external dependencies means no supply chain risk, no version conflicts, and no network access needed to build. The entire project compiles from a fresh Nim installation.
Next Steps
- Read 04-CHALLENGES.md for extension ideas ranging from new collectors to remediation automation
- Try adding a new scan target to an existing collector. Start with something simple like adding Docker Compose credential detection to the apptoken collector