Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/am-i-being-recorded/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "am-i-being-recorded",
"version": "1.0.0",
"description": "Audit screen/tab capture surfaces and the browser extensions that can start them",
"description": "Monitor screen capture surfaces and the apps that can start them",
"license": "MIT",
"author": "Isaac Bell <[email protected]>",
"repository": {
Expand All @@ -10,9 +10,9 @@
"directory": "apps/am-i-being-recorded"
},
"bugs": {
"url": "https://github.com/IsaacBell/secure-devtools/issues"
"url": "https://github.com/IsaacBell/secure-devtools/security/advisories/new"
},
"homepage": "https://github.com/IsaacBell/secure-devtools/tree/main/apps/am-i-being-recorded",
"homepage": "https://isaacbell.github.io/secure-devtools/tools/am-i-being-recorded",

"funding": "https://ko-fi.com/ibell",

Expand Down
2 changes: 1 addition & 1 deletion apps/am-i-hacked/bin/safe-pull.sh
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ else
fi

printf 'safe-pull: fetching %s\n' "$REMOTE"
git -C "$ROOT" fetch --quiet "$REMOTE"
SAFE_PULL=1 git -C "$ROOT" fetch --quiet "$REMOTE"

if ! git -C "$ROOT" rev-parse --verify --quiet "$BRANCH" >/dev/null; then
printf 'safe-pull: %s does not exist after fetch\n' "$BRANCH" >&2
Expand Down
4 changes: 2 additions & 2 deletions apps/am-i-hacked/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
"directory": "apps/am-i-hacked"
},
"bugs": {
"url": "https://github.com/IsaacBell/secure-devtools/issues"
"url": "https://github.com/IsaacBell/secure-devtools/security/advisories/new"
},
"homepage": "https://github.com/IsaacBell/secure-devtools/tree/main/apps/am-i-hacked",
"homepage": "https://isaacbell.github.io/secure-devtools/tools/am-i-hacked",

"funding": "https://ko-fi.com/ibell",

Expand Down
45 changes: 45 additions & 0 deletions apps/fetch-guard/bin/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail

# bin/install-fetch-guard.sh
#
# Installs the git reference-transaction hook (git-hooks/reference-transaction) for every repo of the current
# user, so a raw `git fetch`, `pull` or `clone` cannot move remote-tracking refs. safe-pull.sh stays the way in.
#
# It copies the hook to ~/.config/git/fetch-guard-hooks and points the global `core.hooksPath` there, so an edit
# to the checkout never changes the installed guard. A global hooksPath replaces each repo's own hooks
# directory, so the installer also writes a pass-through shim for every standard hook name.
#
# This changes global git configuration: run it yourself, not through an agent.
# Usage: install-fetch-guard.sh [--uninstall]

SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../git-hooks" && pwd)"
DEST="${XDG_CONFIG_HOME:-$HOME/.config}/git/fetch-guard-hooks"
STANDARD_HOOKS=(applypatch-msg pre-applypatch post-applypatch pre-commit pre-merge-commit prepare-commit-msg
commit-msg post-commit pre-rebase post-checkout post-merge pre-push pre-receive update post-receive
post-update push-to-checkout pre-auto-gc post-rewrite sendemail-validate fsmonitor-watchman p4-pre-submit
post-index-change)

current="$(git config --global --get core.hooksPath || true)"

if [[ "${1:-}" == "--uninstall" ]]; then
[[ "$current" == "$DEST" ]] && git config --global --unset core.hooksPath
rm -rf "$DEST"
echo "fetch-guard removed"
exit 0
fi

if [[ -n "$current" && "$current" != "$DEST" ]]; then
echo "install-fetch-guard: core.hooksPath is already set to $current; merge by hand, nothing changed" >&2
exit 2
fi

mkdir -p "$DEST"
install -m 755 "$SRC/reference-transaction" "$DEST/reference-transaction"
for name in "${STANDARD_HOOKS[@]}"; do
printf '#!/usr/bin/env bash\nhook="$(git rev-parse --git-path hooks/%s)"\n[[ -x "$hook" ]] && exec "$hook" "$@"\nexit 0\n' \
"$name" >"$DEST/$name"
chmod 755 "$DEST/$name"
done
git config --global core.hooksPath "$DEST"
echo "fetch-guard installed: core.hooksPath=$DEST"
38 changes: 38 additions & 0 deletions apps/fetch-guard/git-hooks/reference-transaction
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Git reference-transaction hook: refuse writes to refs/remotes/* made by fetch, pull, clone or remote update.
# A fetch only writes objects, but moving a remote-tracking ref is what makes later checkouts, merges and tools
# pick up what a compromised remote served. Content comes in through safe-pull.sh (bin/), which inspects the
# incoming tree first and sets SAFE_PULL=1 for its own fetch. `git push` also moves refs/remotes/*; it is told
# apart by GIT_REFLOG_ACTION and stays allowed. Not covered: tools that bypass git hooks (jj, libgit2).
#
# Contract: git runs this with the transaction state as $1 and "<old> <new> <ref>" lines on stdin.
# Exit non-zero in the "prepared" state aborts the whole transaction.
set -uo pipefail

state="${1:-}"
input="$(cat)"

if [[ "$state" == prepared && "${SAFE_PULL:-}" != 1 ]]; then
case "${GIT_REFLOG_ACTION:-}" in
fetch* | pull* | clone* | remote*)
while read -r _ _ ref; do
if [[ "$ref" == refs/remotes/* ]]; then
{
echo "BLOCKED by fetch-guard: a raw fetch, pull or clone may not move $ref."
echo "Read names and commit ids with: git ls-remote <remote>"
echo "Bring content in with: bash apps/secure-devtools/apps/am-i-hacked/bin/safe-pull.sh --remote <name> --branch <name>"
} >&2
exit 1
fi
done <<<"$input"
;;
esac
fi

# Global core.hooksPath replaces the repo's own hooks directory, so hand over to the repo's own hook.
repo_hook="$(git rev-parse --git-path hooks/reference-transaction 2>/dev/null)" || exit 0
if [[ -x "$repo_hook" ]]; then
printf '%s\n' "$input" | "$repo_hook" "$@"
exit $?
fi
exit 0
46 changes: 46 additions & 0 deletions apps/fetch-guard/test/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Tests the reference-transaction hook against real git commands in throwaway repos.
# Usage: test/fetch-guard.sh <empty-or-new-work-dir>
# The hook is wired per command with `-c core.hooksPath`, so no global git config is touched.
set -uo pipefail

WORK="${1:?usage: fetch-guard.sh <work-dir>}"
HOOKS="$(cd "$(dirname "${BASH_SOURCE[0]}")/../git-hooks" && pwd)"
mkdir -p "$WORK" && WORK="$(cd "$WORK" && pwd)"
pass=0 fail=0

g() { git -c user.name=t -c [email protected] -c commit.gpgsign=false -c core.hooksPath="$HOOKS" "$@"; }
plain() { git -c user.name=t -c [email protected] -c commit.gpgsign=false "$@"; }
check() { # check <name> <want: allow|block> <command...>
local name="$1" want="$2" out rc; shift 2
out="$("$@" 2>&1)"; rc=$?
if { [[ "$want" == allow && $rc -eq 0 ]] || [[ "$want" == block && $rc -ne 0 && "$out" == *"BLOCKED by fetch-guard"* ]]; }; then
echo "PASS $want: $name"; pass=$((pass + 1))
else
echo "FAIL $want: $name (rc=$rc) $out"; fail=$((fail + 1))
fi
}
advance() { echo "$1" >"$WORK/other/f"; plain -C "$WORK/other" commit -qam "$1" && plain -C "$WORK/other" push -q origin main; }

plain init -q --bare "$WORK/origin.git"
plain init -q -b main "$WORK/w"
plain -C "$WORK/w" remote add origin "$WORK/origin.git"
echo a >"$WORK/w/f"; plain -C "$WORK/w" add f; plain -C "$WORK/w" commit -qm init
check "push moves refs/remotes" allow g -C "$WORK/w" push -q -u origin main
plain clone -q "$WORK/origin.git" "$WORK/other"

advance b
check "ls-remote" allow g -C "$WORK/w" ls-remote origin
check "fetch" block g -C "$WORK/w" fetch -q
check "pull --ff-only" block g -C "$WORK/w" pull -q --ff-only
check "remote update" block g -C "$WORK/w" remote update
check "clone" block g clone -q "$WORK/origin.git" "$WORK/clone-blocked"
check "safe-pull style fetch (SAFE_PULL=1)" allow env SAFE_PULL=1 git -c core.hooksPath="$HOOKS" -C "$WORK/w" fetch -q

advance c
printf '#!/usr/bin/env bash\necho "BLOCKED by fetch-guard: repo hook ran" >&2\nexit 1\n' >"$WORK/w/.git/hooks/reference-transaction"
chmod +x "$WORK/w/.git/hooks/reference-transaction"
check "repo-local hook still runs (chained)" block g -C "$WORK/w" commit --allow-empty -qm x

echo "$pass passed, $fail failed"
[[ $fail -eq 0 ]]
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "secure-devtools",
"version": "0.0.0",
"version": "0.0.7",
"private": true,
"description": "Dev-time supply-chain security tools (monorepo)",
"description": "Real-time, drop-in security tools (monorepo)",
"license": "MIT",
"scripts": {
"test": "pnpm -r --if-present test",
Expand Down
61 changes: 61 additions & 0 deletions skills/process-data/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
name: process-data
description: "Use when searching, filtering or summarizing a large or awkward file or history: a wide CSV, a big log, JSON, a git history, a long tool output. Covers narrowing before reading (bisecting columns, rows, dates and commits), choosing the cheapest tool that is installed, and keeping output small enough to read."
version: 1.0.0
verified-against: "git 2.51, jq, sqlite3, awk, gzip, ripgrep and uv on macOS; check the installed versions"
---

# Process data

**BLUF:** Read the least you can. Find where the answer is, then read only that. Every step below narrows the data before anyone reads it.

Tools change. Check what is installed before choosing one (`command -v duckdb xsv qsv mlr jq sqlite3 uv`), and check the version before relying on a flag.

## 1. Look at the shape first

- `wc -l` for rows and `wc -c` for bytes. Open a compressed file with `gzip -dc file | head`, never by unpacking it.
- For a table, count columns in the header before printing a row. One 400-column row is most of a screen.
- Never print a whole file you have not sized. Print `head -c 600` of a line, or a row's non-empty fields only.

## 2. Bisect

Halve the search space instead of reading. Pick the axis that fits the question.

- **Columns.** Stream the file once and count non-empty cells per column. Most wide exports are empty in most columns. Keep the few that carry data.
- **Time.** Count events per day or month first, then look closely only at the days around the event you care about. In a log with thousands of rows, one burst day often explains the question.
- **Rarity.** Count each action or kind. Print the rare ones (three or fewer) in full, and only totals for the common ones.
- **History.** To find when something entered a repository: `git log -S'<string>' --reverse -- <path>` names the first commit that added it. `git grep -l '<string>' <ref>` searches a commit or branch without checking it out. `git cat-file -s <ref>:<path>` gives a file's size at any commit, which exposes a config file that quietly grew from 500 bytes to 38,000. `git bisect` finds the commit where a test started failing.
- **Mismatch.** Compare two copies (`diff`, `git diff --stat`) and read only the difference.

## 3. Pick the cheapest tool that is installed

| Job | Options, cheapest first |
| --- | --- |
| Count, group, top N | `sort \| uniq -c \| sort -rn`, `awk`, a short script |
| Pick columns from simple CSV | `cut`, `awk -F,` (breaks on quoted commas) |
| Real CSV (quotes, embedded commas, very wide) | a script with a CSV parser in any language; `sqlite3` with `.import`; `duckdb`, `xsv` or `qsv` where installed |
| JSON | `jq` |
| Search text or code | `rg`, then `grep` |
| Anything repeated | write the script once, take its inputs as arguments, keep it next to the output |

A short Python script run with `uv run --no-project python -I script.py args` handled a 3,500-row, 400-column CSV using only the standard library. It is one option, not the rule. `-I` stops Python importing from the current folder, which matters when the data came from outside.

On the machine this was checked on, `jq`, `sqlite3`, `awk`, `gzip`, `rg` and `uv` were installed, and `duckdb`, `xsv`, `qsv` and `mlr` were not. Do not assume; check.

## 4. Keep output small

- Write long output to a file and read the summary. A tool result over a size limit can be saved and cut, and the part you needed can be the part that is lost. Run once, save, then read the file.
- Print counts and dates, not rows. Mask or leave out personal data (addresses, IPs, tokens) unless the question needs it.
- Do not rerun a long command only to filter its output differently. Save the first output and filter the file.

## 5. Trust, but test the detector

- A pattern that finds nothing proves nothing until it finds the known bad. Run it on a sample that should match and on one that should not.
- Start with the narrowest pattern. A broad pattern that matches normal code buries the real hit. Tighten it, and widen it only if it misses.
- Name what you could not see: a truncated read, an empty column, an export without IPs. An absent field is not an absent event.

## 6. Mistakes that cost time

- A shell glob with no match can abort the whole command (zsh). Check with `ls` first or quote the pattern.
- Relative paths break when the working directory differs between calls. Say where you are, or use absolute paths.
- Quoting a number from a tool result without opening the file it came from. Read the line.
54 changes: 54 additions & 0 deletions skills/repo-compromise-triage/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
name: repo-compromise-triage
description: "Use when a hosted repository or its account may have been tampered with: a pushed commit differs from the one made locally, a config file grew, a scanner flags a branch, or someone says 'check the repo for a malicious commit'. Read-only investigation using git history, the hosting API, the account security log and a code scanner, without building or running anything. Produces a confirmed / not known report and a fix order."
version: 1.0.0
verified-against: "git 2.51, GitHub CLI 2.75, am-i-hacked 2.0.1 (main)"
---

# Repo compromise triage

**BLUF:** Treat the repository as data. Never install, build, test, run or open it in an editor. Compare, count and search with git and the hosting API, scan saved copies, and report what is confirmed and what is not known. Never say "safe".

Related: `quarantine-review` (inspect or salvage a local folder), `secure-skill-pull` (third-party skills), `process-data` (narrowing large output).

## 1. Signals that start a triage

- A pushed commit has the same author, time and parent as a local commit but a different hash. The commit was rewritten after it was made.
- A tool config file (`tailwind.config.*`, `postcss.config.*`, `eslint.config.*`, `next.config.*`, `vite.config.*`) is far larger than it should be. A clean one is usually under 1 KB.
- A commit's author date and committer date are weeks apart, or its message does not match what it changed.
- A scanner flags a branch, or a security check in CI has never actually run.

## 2. Read-only steps, in order

1. **Compare the pair.** `git log -1 --format='%h %an %cn %ad parents=%p' <ref>` for the local and pushed commit, then `git diff --stat <local> <pushed>`. Read the difference, not the files.
2. **Size every tip.** For each remote branch, `git cat-file -s <ref>:<config-file>` for the config files above. One size outlier across branches finds the spread in seconds.
3. **Search without checking out.** `git grep -l -F '<marker>' <ref>` and `git log -S'<marker>' --reverse -- <path>`. Search every branch tip and open pull-request branch, not only the default.
4. **Test the detector.** Save the bad blob and a clean blob as plain files (`git show <ref>:<path> > file`) in a working folder, and run the scanner (`npx am-i-hacked <folder>`) on each. It must flag the first and pass the second before you trust a clean result elsewhere.
5. **Check what the code could reach.** With the hosting CLI, read only: how many workflow runs happened since the first bad commit; the names (never values) of repository secrets in scope; collaborators and their roles; webhook hosts and deploy keys.
6. **Read the account security log.** Export it from the account settings. Look for: second-factor recovery, sign-ins from new devices, country changes, password or key changes, token and application authorization churn. Bisect by date around the bad commits (see `process-data`).
7. **Check the machine.** Scan the working tree, then run the host scan. If a scan hangs or prints nothing, say the machine is not confirmed clean.

## 3. Cautions

- A fetch writes objects and runs nothing. The risk starts at checkout, merge, install, build, test or opening the folder in an editor. A project can still choose a stricter rule: if one says never fetch from a compromised remote, follow it and use `git ls-remote` (names and commit ids only). A guarded pull exists in this repository: `am-i-hacked`'s `safe-pull.sh`.
- Do not run, deobfuscate by execution or paste a payload. Count its size and markers, name the file and the commit, and stop there.
- Commit authorship and dates are claims, not proof. Unsigned commits can carry any author. The hosting API's event list shows which account pushed and when, within its limits (recent events only).
- An export may lack IP addresses and locations, and git pushes may not appear in the account log. An absent field is not an absent event.
- If the same file is infected on the default branch and everywhere else, the spread predates any single commit. Date the earliest one with `git log -S`.

## 4. The fix order

1. Stop anything that builds or runs the repository, including CI on open pull requests and any agent.
2. Rotate, from a clean device, every secret the workflows could read, plus account tokens and keys. Use the secret names from step 5.
3. Remove unknown collaborators and review webhooks and deploy keys.
4. Restore the clean files from a known-good local commit in a pull request. The bad history stays. Removing it needs a history rewrite and a force push, which many teams ban; the alternative is a fresh repository with clean history.
5. Add the scanner as a required check so a config file this large fails the build.

## 5. Report

1. **Confirmed**: each fact with the command that proves it.
2. **Not known**: how the commit was altered, what the payload does, whether the machine is clean.
3. **Exposure**: runs, secrets in scope, collaborators, webhooks.
4. **Timeline**: bad commit dates against the account security log.
5. **What you did that you should not have**: for example a fetch against a stricter rule.
6. **Fix order** from section 4.
Loading