delete
This commit is contained in:
parent
9edf1f6cff
commit
4ec156ecf8
|
|
@ -1,591 +0,0 @@
|
|||
# Credential Enumeration Audit
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans
|
||||
> to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Address all gaps identified in the audit.
|
||||
|
||||
**Architecture:** All changes are modifications to existing files unless noted.
|
||||
|
||||
**Tech Stack:** Nim 2.2+, Docker, Bash (Justfile)
|
||||
|
||||
---
|
||||
|
||||
## Impression
|
||||
|
||||
Solid architecture for a Nim CLI tool — clean type hierarchy, consistent
|
||||
`{.push raises: [].}` discipline, well-structured collector pattern. The
|
||||
bones are genuinely good. But two of the command-detection patterns silently
|
||||
match nothing, the terminal box renderer computes stats it never prints,
|
||||
and the only test mechanism (Docker) can't actually build because the
|
||||
Justfile passes the wrong build context. The tool scans 7 credential
|
||||
categories competently but misses several high-value targets (.netrc,
|
||||
npm/pip tokens, Terraform, Vault) that a real post-access operator would
|
||||
check first.
|
||||
|
||||
## Project Assessment
|
||||
|
||||
**Type:** Rule-based credential detection CLI tool (post-access)
|
||||
**Primary Axis:** Completeness — weighted 65/35 over code quality
|
||||
**Why:** A scanner's value is directly proportional to what it catches.
|
||||
Missing a credential category is a harder failure than a rendering bug.
|
||||
|
||||
## Findings
|
||||
|
||||
### Finding 1: Docker test build context is wrong — entire test pipeline broken
|
||||
**Severity:** CRITICAL
|
||||
**Axis:** Code Quality
|
||||
**Files:** Justfile:88-89, tests/docker/Dockerfile:1-12
|
||||
|
||||
**Issue:** The Justfile recipe `docker-build` runs
|
||||
`docker build -t credenum-test tests/docker`, setting the build context to
|
||||
`tests/docker/`. But the Dockerfile's first stage copies `src/`, `config.nims`,
|
||||
and `credential-enumeration.nimble` from the build context root — none of which
|
||||
exist under `tests/docker/`. The build fails immediately with
|
||||
"COPY failed: file not found in build context."
|
||||
|
||||
**Proof:** The Dockerfile contains:
|
||||
```dockerfile
|
||||
COPY src/ src/
|
||||
COPY config.nims .
|
||||
COPY credential-enumeration.nimble .
|
||||
```
|
||||
With context `tests/docker/`, Docker looks for `tests/docker/src/`,
|
||||
`tests/docker/config.nims`, `tests/docker/credential-enumeration.nimble`.
|
||||
None exist — `find tests/docker/ -name "config.nims"` returns nothing.
|
||||
The only test mechanism for this project has never run successfully with
|
||||
this Justfile recipe.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — Docker build context semantics are deterministic;
|
||||
this is not a maybe.
|
||||
|
||||
**Fix:**
|
||||
`Justfile:88-89` — change the docker-build recipe to use the project root as context:
|
||||
```just
|
||||
[group('test')]
|
||||
docker-build:
|
||||
docker build -t credenum-test -f tests/docker/Dockerfile .
|
||||
```
|
||||
And update `docker-test` accordingly (it depends on docker-build, so no change needed
|
||||
there since it just `docker run`s the image).
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just docker-build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Finding 2: matchesCommandPattern has case mismatch — 2/7 patterns are dead code
|
||||
**Severity:** CRITICAL
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/history.nim:38-54, src/config.nim:120-128
|
||||
|
||||
**Issue:** `matchesCommandPattern` lowercases the input line (`line.toLowerAscii()`)
|
||||
then searches for pattern fragments that contain uppercase characters. Two patterns
|
||||
are affected:
|
||||
|
||||
- `"curl.*-H.*[Aa]uthoriz"` splits into `["curl", "-H", "[Aa]uthoriz"]` —
|
||||
`-H` (uppercase) will never be found in a lowercased string, and
|
||||
`[Aa]uthoriz` is treated as a literal (not a character class)
|
||||
- `"wget.*--header.*[Aa]uthoriz"` splits into `["wget", "--header", "[Aa]uthoriz"]` —
|
||||
`[Aa]uthoriz` is literal and will never appear in real history
|
||||
|
||||
This means `curl -H "Authorization: Bearer ..."` commands in shell history
|
||||
are silently missed — one of the most common credential-leaking patterns.
|
||||
|
||||
**Proof:** Trace through `matchesCommandPattern` with input
|
||||
`curl -H "Authorization: Bearer token" https://api.example.com`:
|
||||
1. `lower` = `curl -h "authorization: bearer token" https://api.example.com`
|
||||
2. Pattern `"curl.*-H.*[Aa]uthoriz"` → parts = `["curl", "-H", "[Aa]uthoriz"]`
|
||||
3. `lower.find("curl")` → found at 0
|
||||
4. `lower.find("-H")` → NOT FOUND (lowercase string has `-h`, not `-H`)
|
||||
5. `allFound = false` → returns false
|
||||
|
||||
The pattern never matches. The planted test data in `.bash_history` line 4
|
||||
has `curl -H "Authorization: ..."` which should trigger this pattern but
|
||||
the validate.sh check labeled "Sensitive command" passes only because
|
||||
OTHER patterns (like `sshpass`, `mysql.*-p`) produce matches.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — Nim's `find` is case-sensitive by default;
|
||||
this is deterministic.
|
||||
|
||||
**Fix:**
|
||||
`src/config.nim:120-128` — lowercase all pattern fragments:
|
||||
```nim
|
||||
HistoryCommandPatterns* = [
|
||||
"curl.*-h.*authoriz",
|
||||
"curl.*-u ",
|
||||
"wget.*--header.*authoriz",
|
||||
"wget.*--password",
|
||||
"mysql.*-p",
|
||||
"psql.*password",
|
||||
"sshpass"
|
||||
]
|
||||
```
|
||||
|
||||
**Test:**
|
||||
Add a Docker test assertion that specifically validates curl -H Authorization
|
||||
detection. After fix, run `just docker-test`.
|
||||
|
||||
---
|
||||
|
||||
### Finding 3: Module header stats computed but never rendered
|
||||
**Severity:** MAJOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/output/terminal.nim:40-57
|
||||
|
||||
**Issue:** `renderModuleHeader` computes a `stats` string containing
|
||||
the finding count and duration, but the padding calculation
|
||||
`padLen - stats.len + stats.len` simplifies to just `padLen` — then
|
||||
writes padding spaces without ever writing `stats` to stdout.
|
||||
The finding count and per-module duration are silently dropped from output.
|
||||
|
||||
**Proof:** The arithmetic:
|
||||
```nim
|
||||
let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset
|
||||
let padLen = 76 - name.len - desc.len - 5
|
||||
if padLen > 0:
|
||||
stdout.write " ".repeat(padLen - stats.len + stats.len) # = " ".repeat(padLen)
|
||||
stdout.writeLine " " & BoxVertical
|
||||
```
|
||||
`stats` is never passed to `stdout.write`. The line is equivalent to
|
||||
`stdout.write " ".repeat(padLen)` followed by the box border — no stats
|
||||
anywhere.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — the variable is computed and never
|
||||
appears in any write call in the function.
|
||||
|
||||
**Fix:**
|
||||
`src/output/terminal.nim:51-55` — compute visual width (excluding ANSI codes),
|
||||
pad to fill the box, then write stats:
|
||||
```nim
|
||||
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
|
||||
inc i
|
||||
else:
|
||||
inc result
|
||||
inc i
|
||||
|
||||
proc renderModuleHeader(name: string, desc: string, findingCount: int, durationMs: int64) =
|
||||
try:
|
||||
stdout.writeLine boxLine(78)
|
||||
stdout.write BoxVertical & " "
|
||||
stdout.write ColorBold & ColorCyan
|
||||
stdout.write name.toUpperAscii()
|
||||
stdout.write ColorReset
|
||||
stdout.write ColorDim
|
||||
stdout.write " " & Arrow & " " & desc
|
||||
stdout.write ColorReset
|
||||
|
||||
let stats = $findingCount & " findings" & ColorDim & " (" & $durationMs & "ms)" & ColorReset
|
||||
let usedWidth = 2 + name.len + 3 + desc.len
|
||||
let statsVisual = visualLen(stats)
|
||||
let padLen = 78 - usedWidth - statsVisual - 2
|
||||
if padLen > 0:
|
||||
stdout.write " ".repeat(padLen)
|
||||
stdout.write stats
|
||||
stdout.writeLine " " & BoxVertical
|
||||
stdout.writeLine boxMid(78)
|
||||
except CatchableError:
|
||||
discard
|
||||
```
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just run --target /tmp | head -20
|
||||
```
|
||||
Verify module headers show "N findings (Xms)" right-aligned.
|
||||
|
||||
---
|
||||
|
||||
### Finding 4: Terminal box right-border alignment broken for variable content
|
||||
**Severity:** MAJOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/output/terminal.nim:60-84, 98-126
|
||||
|
||||
**Issue:** `renderFinding` writes descriptions and paths of arbitrary length
|
||||
then appends `" " & BoxVertical` with no padding to reach column 78. Long
|
||||
descriptions push past the box. Short ones leave the right border floating
|
||||
at different positions. Same issue in `renderSummary` — hardcoded
|
||||
`" ".repeat(69)` and `" ".repeat(20)` assume fixed content widths that
|
||||
vary with finding counts, module counts, and durations.
|
||||
|
||||
**Proof:** A finding with path `/home/user/.config/google-chrome/Default/Login Data`
|
||||
(49 chars) plus permissions `[0644]` plus modified timestamp is ~90+ chars of
|
||||
content in a 78-char box. The right `BoxVertical` gets pushed to column ~95.
|
||||
A finding with path `/home/user/.pgpass` (18 chars) leaves the right border
|
||||
at ~column 50.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — the code has zero width calculation before
|
||||
writing the trailing BoxVertical.
|
||||
|
||||
**Fix:**
|
||||
Create a `padWrite` helper that calculates visual width of content written so
|
||||
far and pads to fill the 78-char box before writing the closing border.
|
||||
Apply it to `renderFinding`, `renderSummary`, and `renderModuleErrors`.
|
||||
Truncate content that would exceed box width.
|
||||
|
||||
In `src/output/terminal.nim`, add the `visualLen` proc from Finding 3
|
||||
(shared), then refactor each line that writes content + BoxVertical:
|
||||
```nim
|
||||
proc padToBox(content: string, boxWidth: int = 78) =
|
||||
let vLen = visualLen(content)
|
||||
let pad = boxWidth - vLen - 1
|
||||
if pad > 0:
|
||||
stdout.write " ".repeat(pad)
|
||||
stdout.writeLine BoxVertical
|
||||
```
|
||||
|
||||
Then each finding line becomes:
|
||||
```nim
|
||||
var line = BoxVertical & " " & sevBadge(f.severity) & " " & f.description
|
||||
stdout.write line
|
||||
padToBox(line)
|
||||
```
|
||||
|
||||
Apply this pattern consistently to all content rows in the terminal renderer.
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just docker-test
|
||||
```
|
||||
Visual inspection of terminal output — all right borders should align at column 78.
|
||||
|
||||
---
|
||||
|
||||
### Finding 5: scanGitCredentials reports svHigh for empty credential files
|
||||
**Severity:** MAJOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/git.nim:11-39
|
||||
|
||||
**Issue:** If `.git-credentials` exists but is empty or contains no valid URLs,
|
||||
`credCount` stays at 0 but the function still creates a finding with
|
||||
"Plaintext Git credential store with 0 entries" at severity svHigh
|
||||
(or svCritical if world-readable). An empty file is not a high-severity
|
||||
credential exposure.
|
||||
|
||||
**Proof:** Trace through `scanGitCredentials` with an empty `.git-credentials`:
|
||||
1. `safeFileExists` returns true
|
||||
2. `readFileLines` returns `@[]`
|
||||
3. Loop runs zero iterations, `credCount = 0`
|
||||
4. Code falls through to create credential and finding with `svHigh`
|
||||
5. Report shows "Plaintext Git credential store with 0 entries" as HIGH
|
||||
|
||||
**Proof Check:** Confidence: HIGH — there is no guard checking `credCount > 0`
|
||||
before creating the finding.
|
||||
|
||||
**Fix:**
|
||||
`src/collectors/git.nim` — add early return after counting:
|
||||
```nim
|
||||
if credCount == 0:
|
||||
return
|
||||
```
|
||||
Insert after the for-loop that counts credentials (after line 22), before
|
||||
the credential/finding construction.
|
||||
|
||||
**Test:**
|
||||
Create an empty `.git-credentials` file, run scanner, verify no git finding
|
||||
appears.
|
||||
|
||||
---
|
||||
|
||||
### Finding 6: `just test` references non-existent test_all.nim
|
||||
**Severity:** MAJOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** Justfile:84-85
|
||||
|
||||
**Issue:** The Justfile `test` recipe runs `nim c -r tests/test_all.nim`,
|
||||
but this file does not exist. There are no unit tests in the project.
|
||||
The only testing is Docker-based integration testing (validate.sh), which
|
||||
itself is broken (Finding 1).
|
||||
|
||||
**Proof:** `test -f tests/test_all.nim` returns non-zero. The `tests/`
|
||||
directory contains only `docker/`.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — file does not exist.
|
||||
|
||||
**Fix:**
|
||||
Create `tests/test_all.nim` with unit tests for each collector's core logic.
|
||||
At minimum, test:
|
||||
- `isPrivateKey` with various key headers
|
||||
- `isEncrypted` with encrypted/unencrypted markers
|
||||
- `matchesSecretPattern` with positive and negative cases
|
||||
- `matchesCommandPattern` (after fixing Finding 2) with all 7 patterns
|
||||
- `redactValue` edge cases
|
||||
- `permissionSeverity` logic
|
||||
- `parseModules` from CLI parsing
|
||||
|
||||
These should be fast, in-process tests that don't require Docker or
|
||||
real credential files.
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Finding 7: Missing credential categories — .netrc, npm/pip tokens, Terraform, Vault, GitHub CLI
|
||||
**Severity:** MAJOR
|
||||
**Axis:** Completeness
|
||||
**Files:** src/config.nim, src/collectors/apptoken.nim
|
||||
|
||||
**Issue:** The tool covers 7 categories but misses several high-value
|
||||
credential stores that a post-access operator would check:
|
||||
|
||||
| Missing Target | Path | Why It Matters |
|
||||
|---|---|---|
|
||||
| `.netrc` | `~/.netrc` | Universal HTTP auth store; Heroku, Artifactory, many tools |
|
||||
| `.npmrc` | `~/.npmrc` | npm registry auth tokens (`_authToken=`) |
|
||||
| `.pypirc` | `~/.pypirc` | PyPI upload tokens |
|
||||
| GitHub CLI | `~/.config/gh/hosts.yml` | GitHub OAuth tokens |
|
||||
| Terraform | `~/.terraform.d/credentials.tfrc.json` | Terraform Cloud API tokens |
|
||||
| Vault | `~/.vault-token` | HashiCorp Vault root/user tokens |
|
||||
| `~/.config/helm/repositories.yaml` | Helm chart repo credentials |
|
||||
| `~/.config/rclone/rclone.conf` | Cloud storage credentials (S3, GCS, etc.) |
|
||||
|
||||
Industry comparison: LaZagne (closest post-access tool) covers 20+
|
||||
credential categories on Linux alone. `truffleHog` detects 700+ secret
|
||||
patterns. This tool's 7 categories leave real coverage gaps.
|
||||
|
||||
**Proof:** `grep -r "netrc\|npmrc\|pypirc\|vault-token\|terraform\|gh/hosts" src/`
|
||||
returns zero matches.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — the files are either scanned or they're not.
|
||||
|
||||
**Fix:**
|
||||
Add constants to `src/config.nim`:
|
||||
```nim
|
||||
const
|
||||
NetrcFile* = ".netrc"
|
||||
NpmrcFile* = ".npmrc"
|
||||
PypircFile* = ".pypirc"
|
||||
GhCliHosts* = ".config/gh/hosts.yml"
|
||||
TerraformCreds* = ".terraform.d/credentials.tfrc.json"
|
||||
VaultTokenFile* = ".vault-token"
|
||||
HelmRepos* = ".config/helm/repositories.yaml"
|
||||
RcloneConf* = ".config/rclone/rclone.conf"
|
||||
```
|
||||
|
||||
Add scanning logic to `src/collectors/apptoken.nim` — each is a simple
|
||||
file-exists-and-check-contents pattern, consistent with existing
|
||||
`scanDbCredFiles` approach. `.netrc` deserves content parsing (look for
|
||||
`password` or `login` tokens). `.npmrc` should check for `_authToken=`.
|
||||
`.pypirc` should check for `password` under `[pypi]` section.
|
||||
|
||||
**Test:**
|
||||
Add planted files to `tests/docker/planted/` and assertions to `validate.sh`.
|
||||
|
||||
---
|
||||
|
||||
### Finding 8: matchesExclude uses substring matching, not glob patterns
|
||||
**Severity:** MINOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/base.nim:90-94
|
||||
|
||||
**Issue:** `matchesExclude` checks `if pattern in path` — plain substring.
|
||||
An exclude pattern of `"env"` would exclude `/home/user/.venv/something`,
|
||||
`/home/user/environment/data`, and the intended `.env` file. The CLI help
|
||||
says `--exclude <patterns>` suggesting glob behavior, but the implementation
|
||||
is substring containment.
|
||||
|
||||
**Proof:** `matchesExclude("/home/user/.venv/lib/site.py", @["env"])`
|
||||
returns `true`, excluding a Python virtualenv file that has nothing to do
|
||||
with environment secrets.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — `in` is Nim's substring containment
|
||||
operator for strings.
|
||||
|
||||
**Fix:**
|
||||
`src/collectors/base.nim:90-94` — use `std/os.extractFilename` and simple
|
||||
glob matching, or at minimum document that patterns are substrings. Better
|
||||
fix: use Nim's `std/strutils.contains` with path-segment awareness:
|
||||
```nim
|
||||
proc matchesExclude*(path: string, patterns: seq[string]): bool =
|
||||
let name = path.extractFilename()
|
||||
for pattern in patterns:
|
||||
if pattern in name or pattern in path.splitPath().head:
|
||||
return true
|
||||
```
|
||||
|
||||
Or implement basic glob support with `*` matching.
|
||||
|
||||
**Test:**
|
||||
Unit test that `.venv/lib/site.py` is NOT excluded by pattern `".env"`.
|
||||
|
||||
---
|
||||
|
||||
### Finding 9: JSON renderJson silently discards file-write errors
|
||||
**Severity:** MINOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/output/json.nim:72-85
|
||||
|
||||
**Issue:** When `--output <path>` specifies an invalid path (read-only dir,
|
||||
nonexistent parent), `writeFile` throws, the exception is caught and
|
||||
discarded. The JSON is then also written to stdout, but if stdout is
|
||||
redirected and also fails, both errors are silently swallowed. The user
|
||||
gets zero indication that their requested output file was not created.
|
||||
|
||||
**Proof:** Run `credenum --format json --output /root/nope.json` as
|
||||
non-root — the file write fails silently, output goes only to stdout.
|
||||
If stdout is piped to a broken pipe, both writes fail and the user
|
||||
sees nothing.
|
||||
|
||||
**Proof Check:** Confidence: MEDIUM — the stdout fallback usually works,
|
||||
so the practical impact is limited to the file path case.
|
||||
|
||||
**Fix:**
|
||||
`src/output/json.nim:77-80` — write a warning to stderr on file write failure:
|
||||
```nim
|
||||
except CatchableError as e:
|
||||
try:
|
||||
stderr.writeLine "Warning: could not write to " & outputPath & ": " & e.msg
|
||||
except CatchableError:
|
||||
discard
|
||||
```
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just run --format json --output /dev/full 2>&1 | grep "Warning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Finding 10: redactLine strips leading quote but keeps trailing quote
|
||||
**Severity:** MINOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/history.nim:15-28
|
||||
|
||||
**Issue:** `redactLine` strips a leading `"` or `'` from the value via
|
||||
`value[1 .. ^1]`, but `^1` is the last index in Nim (inclusive), so
|
||||
this removes only the first character. Input `"secret"` becomes
|
||||
`secret"` — the trailing quote survives into the redacted preview.
|
||||
|
||||
**Proof:** Input line `export API_KEY="mysecret"`:
|
||||
1. `eqIdx` = 14 (position of `=`)
|
||||
2. `value` = `"mysecret"` (after strip)
|
||||
3. `value.startsWith("\"")` → true
|
||||
4. `cleanValue` = `value[1 .. ^1]` = `mysecret"` (trailing quote kept)
|
||||
5. `redactValue("mysecret\"", 4)` = `myse****"`
|
||||
|
||||
**Proof Check:** Confidence: HIGH — `^1` is the last character in Nim slice
|
||||
notation; this is deterministic.
|
||||
|
||||
**Fix:**
|
||||
`src/collectors/history.nim:24-26`:
|
||||
```nim
|
||||
let cleanValue = if (value.startsWith("\"") and value.endsWith("\"")) or
|
||||
(value.startsWith("'") and value.endsWith("'")):
|
||||
value[1 ..< ^1]
|
||||
else:
|
||||
value
|
||||
```
|
||||
|
||||
Note: `^1` in `[1 ..< ^1]` excludes the last character (half-open range).
|
||||
|
||||
**Test:**
|
||||
Unit test: `redactLine("export KEY=\"secret\"")` should produce `KEY=secr**`
|
||||
with no trailing quote.
|
||||
|
||||
---
|
||||
|
||||
### Finding 11: isRelative computed but unused in Firefox profile parsing
|
||||
**Severity:** MINOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/browser.nim:11-48
|
||||
|
||||
**Issue:** The `scanFirefox` proc parses `IsRelative=0` from profiles.ini
|
||||
and stores it in `isRelative`, but this variable is never read. Profile
|
||||
path resolution uses `profile.startsWith("/")` instead. The variable is
|
||||
dead code from an abandoned design path.
|
||||
|
||||
**Proof:** `isRelative` is set on lines 23 and 37, but never appears in
|
||||
any conditional or expression after the parsing loop.
|
||||
|
||||
**Proof Check:** Confidence: HIGH — grep for `isRelative` in browser.nim
|
||||
shows only assignments, zero reads.
|
||||
|
||||
**Fix:**
|
||||
`src/collectors/browser.nim` — remove the `isRelative` variable entirely
|
||||
(lines 23, 37). The `startsWith("/")` check on line 43 is sufficient for
|
||||
Linux path detection.
|
||||
|
||||
**Test:**
|
||||
```bash
|
||||
just check
|
||||
```
|
||||
Verify compilation succeeds with no warnings about unused variable.
|
||||
|
||||
---
|
||||
|
||||
### Finding 12: Azure scanner adds directory finding unconditionally
|
||||
**Severity:** MINOR
|
||||
**Axis:** Code Quality
|
||||
**Files:** src/collectors/cloud.nim:140-144
|
||||
|
||||
**Issue:** `scanAzure` always adds an svInfo finding for the Azure CLI
|
||||
directory after checking for specific token files. If token cache findings
|
||||
were already added, this creates redundant noise. If no tokens were found,
|
||||
a bare directory finding at svInfo adds very little value.
|
||||
|
||||
**Proof:** If `~/.azure/` exists with `accessTokens.json`, the output shows:
|
||||
1. "Azure token cache" at svMedium — useful
|
||||
2. "Azure CLI configuration directory" at svInfo — noise, adds nothing
|
||||
|
||||
**Proof Check:** Confidence: MEDIUM — it's noise, not incorrect data. Could
|
||||
argue the directory finding is useful as a "this user has Azure CLI installed"
|
||||
signal, but only if no token files were found.
|
||||
|
||||
**Fix:**
|
||||
`src/collectors/cloud.nim:140-144` — only add the directory finding if no
|
||||
token files were found:
|
||||
```nim
|
||||
if result.findings.len == 0 or
|
||||
result.findings[^1].category != catCloud:
|
||||
result.findings.add(makeFinding(
|
||||
azDir,
|
||||
"Azure CLI configuration directory",
|
||||
catCloud, svInfo
|
||||
))
|
||||
```
|
||||
|
||||
Better: track whether any Azure-specific findings were added and only emit
|
||||
the directory finding as a fallback.
|
||||
|
||||
**Test:**
|
||||
Docker test — verify Azure directory finding only appears when no token
|
||||
findings exist.
|
||||
|
||||
---
|
||||
|
||||
## Self-Interrogation
|
||||
|
||||
Looking at these 12 findings as a whole:
|
||||
|
||||
- **Did I miss a dimension?** The tool has no rate-limiting or size-limiting on
|
||||
file reads. `readFileContent` reads entire files into memory. A malicious
|
||||
(or just large) `.bash_history` of several GB would cause OOM. But the
|
||||
history scanner has `MaxHistoryLines = 50000` via `readFileLines`, which
|
||||
mitigates this for its use case. Other collectors reading full files
|
||||
(git config, kubeconfig) are typically small. Not worth a finding.
|
||||
|
||||
- **Are any findings weak?** Finding 12 (Azure directory) is the weakest —
|
||||
it's a UX preference, not a bug. Keeping it as MINOR is appropriate.
|
||||
Finding 11 (dead variable) is real but trivial. Everything MAJOR and above
|
||||
is solid.
|
||||
|
||||
- **Completeness check:** The tool has 7 modules covering the major
|
||||
categories but Finding 7 lists 8 specific credential stores that any
|
||||
practitioner would expect. The `.netrc` omission alone is notable since
|
||||
it's been the standard Unix credential store since the 1980s.
|
||||
|
||||
## Summary
|
||||
|
||||
**Total Findings:** 12 (2 critical, 5 major, 5 minor)
|
||||
**Code Quality Findings:** 11
|
||||
**Completeness Findings:** 1
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
# Credential Enumeration Tool — Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
A post-access credential enumeration tool written in Nim that scans Linux systems for exposed secrets across 7 categories. Compiles to a single static binary with zero dependencies — drop on target, run, get a structured report of every credential file, its exposure level, and severity rating.
|
||||
|
||||
**Language:** Nim 2.2.x
|
||||
**Binary name:** `credenum`
|
||||
**Architecture:** Modular collector pattern — one module per credential category, common interface, central runner
|
||||
|
||||
---
|
||||
|
||||
## Core Types (`src/types.nim`)
|
||||
|
||||
- **Severity** — enum: `info`, `low`, `medium`, `high`, `critical`
|
||||
- **Category** — enum: `browser`, `ssh`, `cloud`, `history`, `keyring`, `git`, `apptoken`
|
||||
- **Credential** — discovered credential data (source, credential type, value or redacted preview, metadata)
|
||||
- **Finding** — a single discovery (path, category, severity, description, optional Credential, file permissions, timestamps)
|
||||
- **CollectorResult** — `seq[Finding]` + collector metadata (name, duration, errors encountered)
|
||||
- **HarvestConfig** — runtime configuration (target home dir, enabled modules, exclude patterns, output format, flags)
|
||||
- **Report** — all collector results + summary stats + timestamp + target info
|
||||
|
||||
**Severity assignment rules:**
|
||||
- Critical: plaintext credentials in world-readable files
|
||||
- High: unprotected private keys, plaintext credential stores
|
||||
- Medium: overly permissive file permissions on credential files
|
||||
- Low: credential files exist but properly permissioned
|
||||
- Info: enumeration data (host lists, profile counts, existence checks)
|
||||
|
||||
---
|
||||
|
||||
## Collector Modules
|
||||
|
||||
Each module exports `proc collect(config: HarvestConfig): CollectorResult`. The runner calls each in sequence. No inheritance needed — just a common return type and a seq of collector procs populated at init.
|
||||
|
||||
### 1. Browser Credential Store Scanner (`src/collectors/browser.nim`)
|
||||
- Firefox: locate profiles via `profiles.ini`, check `logins.json`, `cookies.sqlite`, `key4.db`
|
||||
- Chromium: locate `Login Data`, `Cookies`, `Web Data` SQLite databases
|
||||
- Report: file locations, permissions, entry counts, last-modified timestamps
|
||||
- Flag world-readable/group-readable databases as critical
|
||||
- Detection + metadata level (no decryption)
|
||||
|
||||
### 2. SSH Key & Config Auditor (`src/collectors/ssh.nim`)
|
||||
- Scan `~/.ssh/` for private keys (RSA, Ed25519, ECDSA, non-standard filenames)
|
||||
- Read key headers to determine passphrase protection (encrypted PEM vs unencrypted)
|
||||
- Flag unprotected keys as high severity
|
||||
- Check permissions (keys=600, directory=700)
|
||||
- Parse `~/.ssh/config` — enumerate hosts, identify weak settings
|
||||
- Read `authorized_keys` and `known_hosts` for enumeration
|
||||
|
||||
### 3. Cloud Provider Config Scanner (`src/collectors/cloud.nim`)
|
||||
- AWS: `~/.aws/credentials`, `~/.aws/config` — count profiles, identify static vs session keys
|
||||
- GCP: `~/.config/gcloud/` — application default credentials, service account keys
|
||||
- Azure: `~/.azure/` — access tokens, profile info
|
||||
- Kubernetes: `~/.kube/config` — enumerate contexts, clusters, auth methods
|
||||
- Permission checks, flag anything broader than owner-only
|
||||
|
||||
### 4. Shell History & Environment Scanner (`src/collectors/history.nim`)
|
||||
- Read `.bash_history`, `.zsh_history`, `.fish_history`
|
||||
- Pattern match for inline secrets: KEY=, SECRET=, TOKEN=, PASSWORD= exports, DB connection strings, curl/wget with auth headers
|
||||
- Scan for `.env` files in home directory tree
|
||||
- Report: file, line region, redacted preview
|
||||
|
||||
### 5. Keyring & Password Store Scanner (`src/collectors/keyring.nim`)
|
||||
- GNOME Keyring: `~/.local/share/keyrings/`
|
||||
- KDE Wallet: `~/.local/share/kwalletd/`
|
||||
- KeePass/KeePassXC: search for `.kdbx` files
|
||||
- pass (password-store): `~/.password-store/`
|
||||
- Bitwarden: `~/.config/Bitwarden/` local vault data
|
||||
- Report locations, file sizes, permissions, last modified
|
||||
|
||||
### 6. Git Credential Store Scanner (`src/collectors/git.nim`)
|
||||
- `~/.git-credentials` — plaintext storage (high severity)
|
||||
- `~/.gitconfig` — check `credential.helper` setting
|
||||
- Search for credential cache socket files
|
||||
- Check for GitHub/GitLab PATs in config files
|
||||
|
||||
### 7. Application Token Scanner (`src/collectors/apptoken.nim`)
|
||||
- Slack: `~/.config/Slack/` session/cookie storage
|
||||
- Discord: `~/.config/discord/` token storage
|
||||
- VS Code: `~/.config/Code/` stored secrets
|
||||
- Database configs: `~/.pgpass`, `~/.my.cnf`, Redis configs
|
||||
- MQTT broker configs, common application credential files
|
||||
|
||||
---
|
||||
|
||||
## CLI Interface
|
||||
|
||||
```
|
||||
credenum [flags]
|
||||
|
||||
Flags:
|
||||
--target <user> Target user home directory (default: current user)
|
||||
--modules <list> Comma-separated module list (default: all)
|
||||
--exclude <patterns> Glob patterns for paths to skip
|
||||
--format <fmt> Output format: terminal, json, both (default: terminal)
|
||||
--output <path> Write JSON output to file
|
||||
--dry-run List paths that would be scanned without reading
|
||||
--quiet Suppress banner and progress, output findings only
|
||||
--verbose Show all scanned paths, not just findings
|
||||
```
|
||||
|
||||
**CLI parsing:** `std/parseopt` (stdlib, no dependencies)
|
||||
|
||||
---
|
||||
|
||||
## Terminal Output Design
|
||||
|
||||
Hacker-aesthetic terminal output:
|
||||
- ASCII art banner with tool name and version
|
||||
- Box-drawing characters for section borders
|
||||
- Color-coded severity badges (critical=red, high=magenta, medium=yellow, low=cyan, info=dim)
|
||||
- Clean table formatting for findings
|
||||
- Summary footer with totals by severity, modules scanned, duration
|
||||
- Progress indicators showing which module is currently scanning
|
||||
|
||||
---
|
||||
|
||||
## Output Formats
|
||||
|
||||
### Terminal (ANSI)
|
||||
Colored, formatted output designed for interactive use. Banner, per-module sections, severity badges, summary.
|
||||
|
||||
### JSON
|
||||
Structured report:
|
||||
```json
|
||||
{
|
||||
"metadata": { "timestamp": "...", "target": "...", "version": "...", "duration_ms": 0 },
|
||||
"modules": [
|
||||
{
|
||||
"name": "ssh",
|
||||
"findings": [
|
||||
{
|
||||
"category": "ssh",
|
||||
"severity": "high",
|
||||
"path": "/home/user/.ssh/id_rsa",
|
||||
"description": "Unprotected private key (no passphrase)",
|
||||
"permissions": "0644",
|
||||
"modified": "2026-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"duration_ms": 12,
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"summary": { "critical": 2, "high": 5, "medium": 8, "low": 3, "info": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build & Distribution
|
||||
|
||||
### Static binary via musl
|
||||
- `config.nims` configures musl-gcc for fully static Linux binaries
|
||||
- Zero runtime dependencies
|
||||
|
||||
### Cross-compilation
|
||||
- x86_64-linux (primary)
|
||||
- aarch64-linux (ARM64)
|
||||
- Uses zig cc for cross-compilation
|
||||
- Justfile tasks: `just build-x86`, `just build-arm64`
|
||||
|
||||
### Build modes
|
||||
- `just build` — debug build with all checks
|
||||
- `just release` — optimized static binary (`-d:release -d:lto --opt:size`)
|
||||
- `just release-small` — stripped + UPX compressed
|
||||
|
||||
### Justfile tasks
|
||||
- `just build` / `just release` / `just release-small`
|
||||
- `just test` — run unit tests
|
||||
- `just docker-test` — build + run in Docker test environment
|
||||
- `just fmt` — format with nph
|
||||
- `just clean`
|
||||
|
||||
---
|
||||
|
||||
## Docker Test Environment
|
||||
|
||||
**`tests/docker/Dockerfile`** — Ubuntu-based container planting fake credentials across all 7 categories:
|
||||
|
||||
- SSH: test key pairs (some protected, some not), various permissions
|
||||
- Browser: mock Firefox profile with dummy `logins.json`, mock Chromium dirs
|
||||
- Cloud: fake AWS credentials, dummy GCP service account JSON, mock kubeconfig
|
||||
- History: seeded `.bash_history`/`.zsh_history` with fake tokens
|
||||
- Keyrings: mock `.kdbx`, mock `pass` store
|
||||
- Git: `.git-credentials` with dummy entries
|
||||
- App tokens: mock Slack/Discord/VS Code configs, `.pgpass`, `.my.cnf`
|
||||
|
||||
All values are obviously fake (`AKIA_FAKE_ACCESS_KEY_12345`).
|
||||
|
||||
`just docker-test` builds, runs credenum inside, validates all findings discovered with correct severity.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
credential-enumeration/
|
||||
├── src/
|
||||
│ ├── harvester.nim # Entry point, CLI parsing
|
||||
│ ├── config.nim # Constants, paths, patterns, severities
|
||||
│ ├── types.nim # Core types
|
||||
│ ├── runner.nim # Execute collectors, aggregate results
|
||||
│ ├── output/
|
||||
│ │ ├── terminal.nim # ANSI terminal output with hacker aesthetic
|
||||
│ │ └── json.nim # JSON serialization
|
||||
│ └── collectors/
|
||||
│ ├── base.nim # Collector registration
|
||||
│ ├── browser.nim
|
||||
│ ├── ssh.nim
|
||||
│ ├── cloud.nim
|
||||
│ ├── history.nim
|
||||
│ ├── keyring.nim
|
||||
│ ├── git.nim
|
||||
│ └── apptoken.nim
|
||||
├── tests/
|
||||
│ └── docker/
|
||||
│ ├── Dockerfile
|
||||
│ └── planted/ # Mock credential files
|
||||
├── learn/
|
||||
│ ├── 00-OVERVIEW.md
|
||||
│ ├── 01-CONCEPTS.md
|
||||
│ ├── 02-ARCHITECTURE.md
|
||||
│ ├── 03-IMPLEMENTATION.md
|
||||
│ └── 04-CHALLENGES.md
|
||||
├── config.nims # Build config (static linking, cross-compile)
|
||||
├── credential-enumeration.nimble # Package manifest
|
||||
├── Justfile
|
||||
├── install.sh
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Learn Folder
|
||||
|
||||
- **00-OVERVIEW.md** — What credential enumeration is, why it matters, prerequisites, quick start
|
||||
- **01-CONCEPTS.md** — Linux credential storage locations, file permission model, where apps store secrets and why defaults are insecure. Real-world breach references.
|
||||
- **02-ARCHITECTURE.md** — Modular collector design, data flow, why Nim for security tooling
|
||||
- **03-IMPLEMENTATION.md** — Code walkthrough: core types, collector pattern, CLI parsing, output formatting, Nim type system and modules
|
||||
- **04-CHALLENGES.md** — Extensions: new collectors, encrypted output, network enumeration, framework integration
|
||||
|
||||
---
|
||||
|
||||
## What This Project Teaches
|
||||
|
||||
- Linux credential storage locations across browsers, SSH, cloud tools, shells, keyrings, Git, and applications
|
||||
- File permission models and their security implications
|
||||
- Nim programming: static compilation, module system, type system, FFI potential
|
||||
- Why Nim is adopted in the security assessment community (small static binaries, C-level performance)
|
||||
- Modular tool architecture with common interfaces
|
||||
- Building visually polished CLI tools
|
||||
- Docker-based testing for security tools
|
||||
- Cross-compilation and static linking for portable binaries
|
||||
Loading…
Reference in New Issue