Skip to content
Closed
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
38 changes: 38 additions & 0 deletions .github/workflows/free-security-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: security

# Free replacement for CodeQL, which is licensed for open source only unless
# you hold GitHub Code Security.
#
# This workflow deliberately contains no scanning logic — it just calls
# scripts/security-scan.sh, the same script you run locally:
#
# ./scripts/security-scan.sh
#
# so local and CI results are identical by construction, and neither of you is
# ever blocked waiting on a runner to find out whether your code is clean.
#
# Runs on the self-hosted Macs: no billed minutes, and no dependency on the
# Actions billing state. Once billing is healthy, switch to ubuntu-latest for
# cleaner isolation — Linux minutes fit inside the included tier.

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
scan:
name: security scan
runs-on: [self-hosted, macOS]
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
# gitleaks scans history, not just the tip
fetch-depth: 0

- name: Run security scan
run: ./scripts/security-scan.sh
39 changes: 39 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
title = "Zero-State-LLC secret scanning"

[extend]
# All default rules stay on, including generic-api-key. Rather than disabling
# that noisy rule wholesale, the allowlist below suppresses only the specific
# shapes that were triaged as false positives across the org on 2026-08-15
# (20 findings, 0 real secrets).
useDefault = true

[allowlist]
description = "Triaged false-positive shapes. Deliberately narrow."

regexTarget = "match"
regexes = [
# Swift persistence key constants, e.g.
# private let bestFloorKey = "HexWire.Gauntlet.BestFloor.v1"
# defaults.set(x, forKey: "App.Thing.v1")
#
# Restricted to DOT-separated reverse-DNS values on purpose. Real credentials
# (sk_live_..., ghp_..., AKIA..., xox...) are not dot-separated, so they are
# still reported. Verified: this suppresses the 6 UserDefaults keys in Hexwire
# and none of the sample credentials tested against it.
'''(?i)\w*key\w*\s*=\s*"[A-Za-z0-9]+(\.[A-Za-z0-9]+)+"''',
'''(?i)for\s*key:\s*"[A-Za-z0-9]+(\.[A-Za-z0-9]+)+"''',
]

paths = [
# Golden/snapshot test fixtures: identifiers like "key": "lsd_open_42".
'''(?i).*/fixtures?/.*''',
'''(?i).*golden.*\.json$''',

# Synthetic PEM and "sk-abc123" literals that exist so tests can assert the
# privacy preflight BLOCKS them. Provider-specific rules still apply here.
'''.*test_privacy_preflight\.py$''',

# Prose that discusses credentials rather than containing them.
'''(?i).*PRIVACY\.md$''',
'''(?i)^docs/.*\.md$''',
]
110 changes: 110 additions & 0 deletions scripts/security-scan.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
#
# Security scan — run this locally, exactly as CI runs it.
#
# ./scripts/security-scan.sh # scan, fail on findings
# REPORT_ONLY=1 ./scripts/security-scan.sh # scan, always exit 0
#
# CodeQL is licensed for open source only, so these private repos use
# permissively-licensed scanners instead. Nothing here uploads source code:
# gitleaks and bandit are entirely local, osv-scanner sends package names and
# versions, semgrep downloads rules and runs with metrics disabled.
#
# Tools are cached outside the repo, so the working tree stays clean.

set -euo pipefail

GITLEAKS_VERSION="${GITLEAKS_VERSION:-8.30.1}"
OSV_VERSION="${OSV_VERSION:-2.5.0}"
REPORT_ONLY="${REPORT_ONLY:-0}"

ROOT="$(git rev-parse --show-toplevel)"
TOOLDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/zsl-security-tools"
BIN="$TOOLDIR/bin"
VENV="$TOOLDIR/venv"
mkdir -p "$BIN"

failed=()
note() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
record() { failed+=("$1"); printf '\033[31mFAIL: %s\033[0m\n' "$1"; }

case "$(uname -s)" in Darwin) OS=darwin ;; *) OS=linux ;; esac
case "$(uname -m)" in arm64|aarch64) ARCH=arm64 ;; *) ARCH=amd64 ;; esac

# ---------------------------------------------------------------- install ---
if [ ! -x "$BIN/gitleaks" ]; then
note "installing gitleaks $GITLEAKS_VERSION"
GL_ARCH="$ARCH"; [ "$ARCH" = amd64 ] && GL_ARCH=x64
curl -fsSL -o "$TOOLDIR/gl.tar.gz" \
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${OS}_${GL_ARCH}.tar.gz"
tar xzf "$TOOLDIR/gl.tar.gz" -C "$BIN" gitleaks
fi

if [ ! -x "$BIN/osv-scanner" ]; then
note "installing osv-scanner $OSV_VERSION"
curl -fsSL -o "$BIN/osv-scanner" \
"https://github.com/google/osv-scanner/releases/download/v${OSV_VERSION}/osv-scanner_${OS}_${ARCH}"
chmod +x "$BIN/osv-scanner"
fi

has_python=0
if find "$ROOT" -name '*.py' -not -path '*/.git/*' -print -quit 2>/dev/null | grep -q .; then
has_python=1
fi

if [ ! -x "$VENV/bin/semgrep" ]; then
note "installing semgrep + bandit"
python3 -m venv "$VENV"
"$VENV/bin/pip" install --quiet --upgrade pip
# Deliberately unpinned: stale rules are worse than non-reproducible ones
# for a security scanner.
"$VENV/bin/pip" install --quiet semgrep bandit
fi

EXCLUDES=(--exclude=.git --exclude=build --exclude=DerivedData
--exclude=node_modules --exclude=.venv --exclude=Pods)

# ------------------------------------------------------------------ scans ---
note "secrets (gitleaks, full history)"
GL_CONF=()
[ -f "$ROOT/.gitleaks.toml" ] && GL_CONF=(--config "$ROOT/.gitleaks.toml")
"$BIN/gitleaks" detect --source "$ROOT" "${GL_CONF[@]}" \
--redact --no-banner || record "gitleaks: secrets detected"

BANDIT_EXCLUDE="$ROOT/.git,$ROOT/build,$ROOT/DerivedData,$ROOT/.venv,$ROOT/node_modules"

if [ "$has_python" = 1 ]; then
# Note on flags: -l/-ll/-lll is severity LOW/MEDIUM/HIGH and above, and
# -i/-ii/-iii is the same for confidence. So -ll -ii means MEDIUM and above,
# NOT high -- which is why this gate first failed on repos whose worst
# finding was medium.
note "python SAST (bandit) — informational, medium severity and above"
"$VENV/bin/bandit" -r "$ROOT" -ll -ii -q --exclude "$BANDIT_EXCLUDE" || true

note "python SAST (bandit) — gate, high severity AND high confidence"
"$VENV/bin/bandit" -r "$ROOT" -lll -iii -q --exclude "$BANDIT_EXCLUDE" \
|| record "bandit: high-severity, high-confidence python findings"
else
note "python SAST (bandit) — no python sources, skipped"
fi

note "multi-language SAST (semgrep)"
"$VENV/bin/semgrep" scan --config=p/security-audit \
--metrics=off --error --severity=ERROR --quiet \
"${EXCLUDES[@]}" "$ROOT" || record "semgrep: ERROR-severity findings"

note "dependencies (osv-scanner) — advisory only"
# Never blocking: a fresh CVE in a transitive dependency should not stop an
# unrelated PR. Review these, do not gate on them.
"$BIN/osv-scanner" scan source --recursive "$ROOT" || true

# ----------------------------------------------------------------- result ---
echo
if [ ${#failed[@]} -eq 0 ]; then
printf '\033[32mAll blocking checks passed.\033[0m\n'
exit 0
fi
printf '\033[31m%d blocking check(s) failed:\033[0m\n' "${#failed[@]}"
printf ' - %s\n' "${failed[@]}"
[ "$REPORT_ONLY" = "1" ] && { echo "REPORT_ONLY=1 — exiting 0 anyway."; exit 0; }
exit 1
Loading