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
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
pip install -e ".[dev]"

- name: Lint (ruff)
run: ruff check secscan/ tests/
run: ruff check security_scan/ tests/

- name: Tests (pytest)
run: pytest -q
Expand All @@ -58,6 +58,6 @@ jobs:
with:
context: .
push: false
tags: secscan:ci
tags: security-scan:ci
# Disable provenance/sbom for faster CI; can re-enable when we cut a release.
provenance: false
98 changes: 98 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Publish image

# Builds and publishes the security-scan image to Docker Hub on every tag named v*.
# The tag must match the [project.version] in pyproject.toml and the
# `version:` in SECURITY-SCAN-MANIFEST.yaml (a guard step verifies this).
#
# Required repository secrets:
# DOCKERHUB_USERNAME the Docker Hub user/org that owns leverj/security-scan
# DOCKERHUB_TOKEN Docker Hub access token with read+write on the repo
#
# Cut a release:
# git tag v0.2.0 && git push origin v0.2.0
#
# The workflow tags the image with:
# leverj/security-scan:v0.2.0 (immutable per release)
# leverj/security-scan:latest (always the most recent tag)

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g., v0.2.0). Must match pyproject.toml + manifest version."
required: true

permissions:
contents: read

env:
IMAGE: leverj/security-scan
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}

- name: Resolve tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
tag="${{ github.event.inputs.tag }}"
else
tag="${GITHUB_REF#refs/tags/}"
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Strip the leading 'v' for comparison against pyproject / manifest.
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"

- name: Verify version alignment
run: |
py_version=$(grep -E '^version\s*=' pyproject.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
mf_version=$(grep -E '^version:' SECURITY-SCAN-MANIFEST.yaml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
want='${{ steps.tag.outputs.version }}'

echo "tag=$want pyproject=$py_version manifest=$mf_version"

if [[ "$py_version" != "$want" ]]; then
echo "::error::pyproject.toml version ($py_version) != tag ($want). Bump pyproject.toml or fix the tag." >&2
exit 1
fi
if [[ "$mf_version" != "$want" ]]; then
echo "::error::SECURITY-SCAN-MANIFEST.yaml version ($mf_version) != tag ($want). Bump the manifest or fix the tag." >&2
exit 1
fi

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
# Multi-arch — runners build amd64/arm64 in parallel.
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}
${{ env.IMAGE }}:latest
provenance: false

- name: Smoke-test the published image (manifest readable)
run: |
docker run --rm --entrypoint cat \
"${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}" \
/app/SECURITY-SCAN-MANIFEST.yaml | head -5
9 changes: 6 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ htmlcov/
work/
/tmp_*

# Per-deployment config (use config.example.yaml as the template; keep secrets out of git)
config.yaml
# Per-deployment config (use config/config.example.yaml as the template; keep secrets out of git)
config/config.yaml

# Personal 1Password reference template (paths to your vault items)
.env.1password.tpl
config/.env.1password.tpl

# IDE
.idea/
.vscode/

# Claude Code session state (per-checkout; not part of the repo)
.claude/

# OS
.DS_Store
12 changes: 8 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# secscan — single-repo security scanner. Stateless. State lives in GitHub Issues.
# security-scan — single-repo security scanner. Stateless. State lives in GitHub Issues.
#
# Mount points (bind-mount at runtime — no VOLUME directive, so anonymous volumes
# never accumulate when --rm is used):
Expand Down Expand Up @@ -109,17 +109,21 @@ RUN set -eux; \
chmod +x /usr/local/bin/syft; \
syft --version

# --- secscan itself -------------------------------------------------------
# --- security-scan itself -------------------------------------------------------
WORKDIR /app
COPY pyproject.toml /app/pyproject.toml
COPY secscan /app/secscan
COPY security_scan /app/security_scan
COPY README.md /app/README.md
# Manifest the consuming skill reads to see version + needed config migrations.
# Pull it out without starting the scanner:
# docker run --rm --entrypoint cat leverj/security-scan:<tag> /app/SECURITY-SCAN-MANIFEST.yaml
COPY SECURITY-SCAN-MANIFEST.yaml /app/SECURITY-SCAN-MANIFEST.yaml
RUN pip install --no-cache-dir /app

# Make sure the mount points exist (no VOLUME directive — keeps `--rm` from
# leaving anonymous volumes behind on each run).
RUN mkdir -p /config /rules /work

# Default entrypoint runs the scanner against /config/config.yaml.
ENTRYPOINT ["python", "-m", "secscan", "--config", "/config/config.yaml", "--work-dir", "/work"]
ENTRYPOINT ["python", "-m", "security_scan", "--config", "/config/config.yaml", "--work-dir", "/work"]
CMD []
58 changes: 38 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# secscan
# security-scan

[![CI](https://github.com/leverj/security-scanner/actions/workflows/ci.yml/badge.svg)](https://github.com/leverj/security-scanner/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
Expand All @@ -17,27 +17,27 @@ Closing/fixing findings is out of scope — another system owns that.
```bash
# 1. Create (or pick) a GitHub Projects v2 board for security findings.
# Note its number (visible in the URL: /projects/<number>).
# On first run secscan provisions two single-select fields on the board:
# On first run security-scan provisions two single-select fields on the board:
# - Severity (critical, high, medium, low, info)
# - Category (dependency, secret, sast, iac, license)

# 2. Copy the example config
cp config.example.yaml config.yaml
$EDITOR config.yaml # set repo, ref, project.owner, project.number
cp config/config.example.yaml config/config.yaml
$EDITOR config/config.yaml # set repo, ref, project.owner, project.number

# 3. Set up secrets — pick ONE of the two paths in the next section

# 4. Verify your setup, then run
./secscan.sh check # green checks across the board?
./secscan.sh build
./secscan.sh run # defaults to --dry-run; add --no-dry-run to actually file issues
./security-scan.sh check # green checks across the board?
./security-scan.sh build
./security-scan.sh run # defaults to --dry-run; add --no-dry-run to actually file issues
```

---

## Setup: secrets

secscan needs a GitHub Personal Access Token, and optionally a Slack webhook URL.
security-scan needs a GitHub Personal Access Token, and optionally a Slack webhook URL.
**Secrets never go into `config.yaml`** — they come in via env vars at runtime.

`config.yaml` declares which path you're using:
Expand Down Expand Up @@ -68,7 +68,7 @@ export GITHUB_TOKEN=github_pat_...
# Optional Slack — get a webhook from https://api.slack.com/apps
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

./secscan.sh run
./security-scan.sh run
```

To persist, put the `export` lines in `~/.zshrc` or `~/.bashrc`. The script verifies
Expand All @@ -86,8 +86,8 @@ brew install 1password-cli
op signin

# Copy the template and edit the vault/item paths to point at your own entries
cp .env.1password.tpl.example .env.1password.tpl
$EDITOR .env.1password.tpl
cp config/.env.1password.tpl.example config/.env.1password.tpl
$EDITOR config/.env.1password.tpl
```

`.env.1password.tpl` then looks like:
Expand All @@ -105,7 +105,7 @@ secrets:
```

```bash
./secscan.sh run # auto-wraps with: op run --env-file=.env.1password.tpl -- docker run ...
./security-scan.sh run # auto-wraps with: op run --env-file=.env.1password.tpl -- docker run ...
```

The file `.env.1password.tpl` is `.gitignore`d. The committed
Expand All @@ -116,7 +116,7 @@ and never commit your filled-in copy.

For container orchestrators (Docker Swarm, K8s, GitHub Actions, etc.), populate
`GITHUB_TOKEN` (and friends) via your platform's secret mechanism so it appears
in the container's environment. With `secrets.source: env`, `secscan.sh` (or a
in the container's environment. With `secrets.source: env`, `security-scan.sh` (or a
direct `docker run`) will pick it up.

---
Expand All @@ -136,15 +136,15 @@ need re-surfacing of regressions, that's the external fixing system's concern.

## Troubleshooting

`./secscan.sh check` reports the status of every prerequisite:
`./security-scan.sh check` reports the status of every prerequisite:

```
== config ==
✓ /path/to/config.yaml
== docker ==
✓ docker is running
== image ==
secscan:latest present # ⚠ "not built yet" if you skipped `build`
security-scan:latest present # ⚠ "not built yet" if you skipped `build`
== secrets (1password) ==
✓ op (1Password CLI) installed
✓ op signed in
Expand All @@ -157,12 +157,12 @@ Common failure modes and what `check` says:

| Symptom | Fix |
|---|---|
| `config not found` | `cp config.example.yaml config.yaml` |
| `config not found` | `cp config/config.example.yaml config/config.yaml` |
| `GITHUB_TOKEN unset` (env source) | `export GITHUB_TOKEN=…` or switch to `secrets.source: "1password"` |
| `op not installed` (1Password source) | `brew install 1password-cli && op signin` |
| `.env.1password.tpl missing` | `cp .env.1password.tpl.example .env.1password.tpl && $EDITOR …` |
| `.env.1password.tpl missing` | `cp config/.env.1password.tpl.example config/.env.1password.tpl && $EDITOR …` |
| `SLACK_… unset` (slack.enabled=true) | Either export the var, add it to the 1Password env file, or set `slack.enabled: false` |
| `image not built yet` | `./secscan.sh build` |
| `image not built yet` | `./security-scan.sh build` |
| `docker daemon not reachable` | Start Docker Desktop |

---
Expand All @@ -176,10 +176,28 @@ python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"

The scanner binaries (osv-scanner, gitleaks, semgrep) live only inside the Docker
image — local tests use SARIF fixtures and mocked subprocesses. To exercise the
real binaries, run via `./secscan.sh run`.
real binaries, run via `./security-scan.sh run`.

---

## Use as a Claude Code skill

The companion bundle at [`leverj/ai-skills`](https://github.com/leverj/ai-skills)
ships a `security-scan` skill that drives this image directly:

```
/plugin marketplace add leverj/ai-skills
/plugin install leverj@leverj-ai-skills
# then: /leverj:security-scan run
```

The skill pulls and runs the published Docker image
`leverj/security-scan:<tag>`, bind-mounts your `config/` directory at
`/config:ro`, and offers a user-confirmed upgrade flow when a newer image
version is available (the image ships a `SECURITY-SCAN-MANIFEST.yaml` describing
its version + any config fields the skill should add to your local
`config.yaml`).

## Spec

See [secscan-spec.md](secscan-spec.md) for the full design.
See [security-scan-spec.md](security-scan-spec.md) for the full design.
Loading
Loading