Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6101d8f
fix(action): a fork's branch name must not reach the shell holding th…
Rome-1 Sep 17, 2026
19795f5
Merge pull request #260 from Raftersecurity/fix/sable-oubg-v1-key-exfil
Rome-1 Sep 17, 2026
86893d6
fix(git): reject unrecognized-host remotes instead of guessing owner/…
Rome-1 Sep 19, 2026
129a09d
fix(git): close host-check bypass via userinfo, ssh:// rejection, mar…
Rome-1 Sep 19, 2026
1dcff97
Merge pull request #261 from Raftersecurity/fix/sable-pqmw-remote-hos…
Rome-1 Sep 20, 2026
5d5f37a
docs(changelog): drop an entry that referenced a private repository
Rome-1 Sep 20, 2026
b69b1ef
fix(python): do not render local variables in tracebacks
Rome-1 Oct 1, 2026
dba2961
fix: never take RAFTER_* settings from a project .env
Rome-1 Oct 1, 2026
f0ee218
fix(run): refuse an auto-detected branch that is not on the remote
Rome-1 Oct 2, 2026
e708bb0
fix(secrets): reject a --diff ref that starts with "-"
Rome-1 Oct 2, 2026
63b3b46
fix(node): pass paths to child processes as arguments, not shell strings
Rome-1 Oct 2, 2026
5a80d9f
Merge pull request #264 from Raftersecurity/mayor-agent/typer-locals
Rome-1 Oct 2, 2026
c8e2c6a
Merge pull request #265 from Raftersecurity/mayor-agent/dotenv-creden…
Rome-1 Oct 2, 2026
48bf044
Merge pull request #266 from Raftersecurity/mayor-agent/run-branch-on…
Rome-1 Oct 2, 2026
9366bfb
Merge pull request #267 from Raftersecurity/mayor-agent/diff-ref-opti…
Rome-1 Oct 2, 2026
f0e7533
Merge pull request #268 from Raftersecurity/mayor-agent/gemini-link-n…
Rome-1 Oct 2, 2026
298f0a2
release: v0.10.6
Rome-1 Oct 2, 2026
eda0fab
Merge pull request #269 from Raftersecurity/release/v0.10.6
Raftersecurity Oct 2, 2026
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
28 changes: 28 additions & 0 deletions .github/workflows/test-github-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ on:
- main
paths:
- 'github-action/**'
- 'action.yml'
- '.github/workflows/test-github-action.yml'
pull_request:
paths:
- 'github-action/**'
# The ROOT action.yml is a SECOND action file, and rf-v2mj lived in it.
# Without this line the probe that guards it would never fire on a change
# to it — a check watching a path its subject is not on.
- 'action.yml'
- '.github/workflows/test-github-action.yml'
workflow_dispatch:

Expand Down Expand Up @@ -404,6 +409,29 @@ jobs:
[ "$FAIL" -eq 0 ] && echo "PASS: counts reached the gate and the gate failed the build."
exit $FAIL

# sable-oubg / rf-7xv0 — a fork's branch name reached a shell holding
# RAFTER_API_KEY. Both probes FAIL on the pre-fix file and pass after, and
# both carry controls, so neither can pass by doing nothing.
test-trigger-injection:
name: "Trigger: a branch name cannot reach the key's shell"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the injection probe
run: bash github-action/tests/test-trigger-injection.sh

# sable-oubg / rf-v2mj — the ROOT action.yml derived its finding count with
# `|| echo "0"`, so an unreadable report rendered as a clean scan. Note this
# is the OTHER action.yml: the same defect class in github-action/action.yml
# was fixed under sable-fgk7 and this file was never touched by it.
test-root-action-counts:
name: "Root action: an unreadable report is not a clean scan"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run the count-derivation probe
run: bash github-action/tests/test-root-action-counts.sh

test-yaml-validity:
name: action.yml is valid YAML
runs-on: ubuntu-latest
Expand Down
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.10.6] - 2026-10-02

### Security

- **Unhandled errors no longer print your API key.** A command that failed with an unhandled exception could include the key and other local values in its traceback, printed to the terminal or a CI log. Tracebacks are now printed without local variables.

- **A project's `.env` can no longer supply Rafter's own settings.** Previously, if the working directory had a `.env` file, its `RAFTER_*` values (including `RAFTER_API_KEY`) were read and could override the key and settings you configured yourself — a repository you merely scanned could supply credentials the CLI would then use. `.env` can no longer set any `RAFTER_*` variable; your own shell environment and the value stored in `~/.rafter/config.json` are unaffected. **Behavior change:** if you were relying on `RAFTER_API_KEY` (or another `RAFTER_*` setting) in a project `.env`, move it to your shell environment or to the config file — the CLI will now report the key as missing if `.env` was its only source.

- **`rafter secrets --diff <ref>` and `rafter agent scan --diff <ref>` no longer accept a value that looks like a command-line option.** A ref beginning with `-` could previously be misread by git as an option rather than a ref, which could overwrite an unrelated file and report a scan as clean with no secrets found. Such a value is now rejected before it reaches git.

- **`rafter agent init --local --with-gemini` no longer runs through a shell.** A path containing shell metacharacters could previously have part of it executed as a command during skill registration. Paths are now passed directly to the subprocess, never interpreted by a shell.

### Fixed

- **`rafter run` now checks that an auto-detected branch has been pushed before scanning it.** Running `rafter run` without `--branch` from a local branch that doesn't exist on the remote used to queue a scan that failed later with a "branch not found" error. The CLI now checks first and fails immediately with a clear message to push the branch or pass `--branch` explicitly. If the branch exists on the remote but your local commit is ahead of it, the CLI now notes that the scan covers the pushed commit, not your local changes.

## [0.10.5] - 2026-09-13

### Security
Expand Down Expand Up @@ -72,7 +88,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Rafter Sites CLI + MCP** (Rome-1/securable-bolt#151). New `rafter sites create|scan|list|get` commands and matching MCP tools (`sites_create`/`sites_scan`/`sites_list`/`sites_get`) for Rafter Sites — live-application security monitoring (exposed backends, DNS misconfig, SEO, accessibility) — calling the new API-key-authenticated `/api/static/sites*` endpoints. `sites scan` accepts either a project id or a URL. Node + Python parity, MCP tools resolve the API key from `RAFTER_API_KEY` or stored config rather than exiting, so a missing key fails the one tool call instead of the whole server.
- Live-tested against production before release, which surfaced and fixed: `--format md` now fails with a clear error instead of silently returning JSON (the Sites API has no markdown representation yet); MCP `sites_scan` now rejects being given both `projectId` and `url` instead of silently preferring `projectId`; a double-slash in constructed request URLs; and unreachable per-status default error messages.

## [0.9.1] - 2026-07-21
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Requires Python 3.10+. Full feature parity with Node.js including local security
Agentic security audits backed by a full SAST/SCA toolchain, via the Rafter API. The analysis engine examines your codebase the way a professional cybersecurity auditor would — following data flows across files, reasoning about authentication and authorization logic, and identifying vulnerabilities that pattern-matching alone cannot catch — then validates and enriches findings with industry-standard static analysis, dependency scanning, and secret detection. Runs against the **remote repository** on GitHub, not local files. Your code is deleted immediately after analysis completes. Auto-detection uses your local Git config to determine which repo and branch to analyze.

```sh
export RAFTER_API_KEY="your-key" # or use .env file
export RAFTER_API_KEY="your-key"

rafter run # scan current repo (auto-detected)
rafter scan --repo myorg/myrepo --branch main # scan specific repo
Expand Down Expand Up @@ -153,7 +153,7 @@ rafter get SCAN_ID > scan_results.json

1. Sign up at [rafter.so](https://rafter.so)
2. Dashboard → Settings → API Keys
3. `export RAFTER_API_KEY="your-key"` or add to `.env`
3. `export RAFTER_API_KEY="your-key"`

---

Expand Down
24 changes: 22 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,30 @@ runs:
# ({_note, scan_mode, triage_applied, results: [...]}); older versions
# emitted a bare array. Handle both so users pinning `version:` to an
# older release don't break.
# rf-v2mj — `|| echo "0"` here was the whole bug: jq fails on a truncated
# report, an HTML error page or an auth failure, and the fallback turned
# every one of those into finding-count=0. Zero findings and "I could not
# read the output" became the same value, so the gate passed PRECISELY
# when it could not see its input. An unreadable report is not a clean
# scan; the count is emitted only when a parse actually succeeded.
if [ "${{ inputs.format }}" = "json" ]; then
COUNT=$(echo "${OUTPUT}" | jq '[(if type == "array" then . else .results end) | .[]?.matches[]?] | length' 2>/dev/null || echo "0")
if ! COUNT=$(printf '%s' "${OUTPUT}" | jq -e '[(if type == "array" then . else .results end) | .[]?.matches[]?] | length' 2>/dev/null); then
echo "::error::Rafter's output could not be parsed as JSON, so the finding count is unknown."
echo "::error::A report this action cannot read is not a clean scan. Re-run, or check the scanner's stderr above."
# Deliberately NOT written as 0: a consumer reading finding-count
# gets an empty string, never a fabricated zero.
echo "finding-count=" >> "$GITHUB_OUTPUT"
exit 1
fi
else
COUNT=$(echo "${OUTPUT}" | grep -c 'Secret:' 2>/dev/null || echo "0")
# grep exits 1 for "no matches", which is a CLEAN result and must not
# be confused with grep failing (exit >1).
COUNT=$(printf '%s' "${OUTPUT}" | grep -c 'Secret:'); GREP_STATUS=$?
if [ "$GREP_STATUS" -gt 1 ]; then
echo "::error::Could not scan Rafter's output for findings (grep exited ${GREP_STATUS})."
echo "finding-count=" >> "$GITHUB_OUTPUT"
exit 1
fi
fi
echo "finding-count=${COUNT}" >> "$GITHUB_OUTPUT"

Expand Down
25 changes: 20 additions & 5 deletions github-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,35 @@ runs:
RAFTER_API_KEY: ${{ inputs.api-key }}
RAFTER_URL: ${{ inputs.rafter-url }}
SCAN_MODE: ${{ inputs.scan-mode }}
# rf-7xv0 — these two reach the script through the ENVIRONMENT, never
# through `${{ }}` inside `run:`. The runner expands `${{ }}` into the
# script TEXT before bash sees it, so an interpolated value is not data,
# it is source code — and `github.head_ref` is the branch name chosen by
# whoever opened the pull request. Any stranger can open one from a fork.
# This step's env carries RAFTER_API_KEY, so the branch name was running
# in a shell that could read the key.
GH_REPOSITORY: ${{ github.repository }}
GH_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
# --fail-with-body: non-2xx → body printed to stdout AND exit nonzero.
# We capture body+status separately so future failures self-explain
# (instead of just "curl exit 22"). API key never echoed.
BODY_FILE="$(mktemp)"
# Built by jq from environment variables, not by pasting values into a
# quoted string. That fixes two things at once: the shell never sees the
# branch name as code, and a branch name containing a quote or backslash
# produces VALID JSON instead of a malformed body. Hand-quoting would
# have to get both right; jq --arg gets both right by construction.
PAYLOAD=$(jq -nc \
--arg repository_name "$GH_REPOSITORY" \
--arg branch_name "$GH_BRANCH" \
--arg scan_mode "$SCAN_MODE" \
'{repository_name: $repository_name, branch_name: $branch_name, scan_mode: $scan_mode}')
HTTP_CODE=$(curl -sS --connect-timeout 10 --max-time 60 \
-o "$BODY_FILE" -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "x-api-key: ${RAFTER_API_KEY}" \
-d "{
\"repository_name\": \"${{ github.repository }}\",
\"branch_name\": \"${{ github.head_ref || github.ref_name }}\",
\"scan_mode\": \"${SCAN_MODE}\"
}" \
-d "$PAYLOAD" \
"${RAFTER_URL}/api/static/scan") || {
echo "::error::curl transport error contacting ${RAFTER_URL}/api/static/scan"
cat "$BODY_FILE" || true
Expand Down
108 changes: 108 additions & 0 deletions github-action/tests/test-root-action-counts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# rf-v2mj — an unreadable report must not render as a clean scan.
#
# The root action derives `finding-count` from the scanner's stdout. It used to
# end that derivation with `|| echo "0"`, so a truncated report, an HTML error
# page or an auth failure all produced finding-count=0: the value that means
# CLEAN. A consumer gating on it was told "no findings" precisely when the
# action could not see any output at all.
#
# Corpus: the ROOT action.yml. The separate github-action/action.yml had the
# same class of defect in its five severity counts and was fixed under
# sable-fgk7; this file was never touched by that work. Two action.yml files,
# and only one of them had been fixed.
#
# The control is the point: a fix that fails on everything would pass the first
# case and be useless. A genuinely clean report must still succeed with count 0.
set -uo pipefail

ROOT="$(cd "$(dirname "$0")/../.." && pwd)/action.yml"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
failures=0

# The scan step, with the runner's `${{ inputs.* }}` expansion applied.
render() {
python3 - "$ROOT" "$1" <<'PY'
import sys, yaml, re
d = yaml.safe_load(open(sys.argv[1])); fmt = sys.argv[2]
for s in d["runs"]["steps"]:
if s.get("id") == "scan":
r = s["run"]
r = re.sub(r"\$\{\{\s*inputs\.format\s*\}\}", fmt, r)
r = re.sub(r"\$\{\{\s*inputs\.(scan-path|args)\s*\}\}", "", r)
r = re.sub(r"\$\{\{.*?\}\}", "", r)
print(r); break
else:
sys.exit("no step id 'scan'")
PY
}

# A stub `rafter` that emits exactly what we want to test, with a chosen exit code.
stub() {
mkdir -p "$TMP/bin"
{ echo '#!/usr/bin/env bash'; echo "cat <<'RAFTER_STUB_EOF'"; printf '%s\n' "$1"; echo 'RAFTER_STUB_EOF'; echo "exit ${2}"; } > "$TMP/bin/rafter"
chmod +x "$TMP/bin/rafter"
}

run_scan() {
render "$1" > "$TMP/scan.sh"
: > "$TMP/gh_output"
( cd "$TMP" && PATH="$TMP/bin:$PATH" GITHUB_OUTPUT="$TMP/gh_output" \
timeout 30 bash "$TMP/scan.sh" >"$TMP/out" 2>&1 )
echo $?
}
count_written() { sed -n 's/^finding-count=\(.*\)$/\1/p' "$TMP/gh_output" | tail -1; }

echo "== rf-v2mj: an unreadable report is not a clean scan =="

# 1. THE BUG. Unparseable stdout with a success exit code — a truncated report,
# an error page, anything jq cannot read.
stub '<html><body>502 Bad Gateway</body></html>' 0
rc=$(run_scan json); c=$(count_written)
if [ "$rc" -eq 0 ] && [ "$c" = "0" ]; then
echo "FAIL: an unparseable report produced finding-count=0 and exit 0 — a clean scan"
failures=$((failures+1))
elif [ "$rc" -eq 0 ]; then
echo "FAIL: an unparseable report exited 0 (count written: '${c}')"
failures=$((failures+1))
else
echo "PASS: an unparseable report fails the step (exit ${rc}, count '${c}')"
fi

# 2. Truncated JSON — the likeliest real shape, and still not a clean scan.
stub '{"results": [{"matches": [{"rule":' 0
rc=$(run_scan json); c=$(count_written)
if [ "$rc" -eq 0 ]; then
echo "FAIL: truncated JSON exited 0 (count written: '${c}')"
failures=$((failures+1))
else
echo "PASS: truncated JSON fails the step (exit ${rc})"
fi

# 3. THE CONTROL. A genuinely clean report must still succeed, with count 0.
# Without this, "fail on everything" would pass the two cases above.
stub '{"_note":"x","scan_mode":"fast","triage_applied":false,"results":[]}' 0
rc=$(run_scan json); c=$(count_written)
if [ "$rc" -eq 0 ] && [ "$c" = "0" ]; then
echo "PASS: a genuinely clean report still passes, count 0"
else
echo "FAIL: CONTROL — a clean report no longer passes (exit ${rc}, count '${c}')"
failures=$((failures+1))
fi

# 4. Second control: real findings must still be counted, not just tolerated.
stub '{"results":[{"matches":[{"rule":"aws"},{"rule":"gh"}]}]}' 1
rc=$(run_scan json); c=$(count_written)
if [ "$c" = "2" ]; then
echo "PASS: real findings are counted (2)"
else
echo "FAIL: CONTROL — findings miscounted: got '${c}', expected 2"
failures=$((failures+1))
fi

echo ""
echo "── results ──────────────────────────────────────────────"
echo "Failures: $failures"
[ "$failures" -eq 0 ] || exit 1
echo "OK: the count is emitted only when the report was actually read"
Loading
Loading