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
7 changes: 6 additions & 1 deletion .claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ this directory).
escalations, serialized lanes, gates and close-out. The main session
decides and raises gates; landers do the reviewing, merging,
verifying and releasing.
- `owasp-scan` — a parallel OWASP Top Ten scan of `src/`.
- `owasp-scan` — a parallel OWASP Top 10:2025 scan of a whole repo
(all languages, workflows, Dockerfiles, compose, scripts, config) plus
the permanent scanners, with ASVS 5.0 references and a report file per
run; takes an optional `asvs=V3,V7,V10` manual-review focus. Its
target discovery is `skills/owasp-scan/discover-targets.sh` (run with
`bash`, read-only; `--summary` and `--skipped` show what it skips).
- `frontend-design` — how to design UI here: the "Field Ledger"
direction and its anti-defaults, proposing 2-3 distinct options for
new screens so output doesn't converge on one stock layout, and the
Expand Down
441 changes: 368 additions & 73 deletions .claude/skills/owasp-scan/SKILL.md

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions .claude/skills/owasp-scan/discover-targets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Print the owasp-scan target list for the repository in the current
# directory, one "<category><TAB><path>" line per file (read-only).
#
# Every git-tracked file is a target unless it is skipped for one of
# these reasons (tracked files are source, so there are no test, build,
# dist, target or generated exclusions):
# lockfile an explicit list of lockfile names (osv-scanner's job)
# vendored vendor/, third_party/, node_modules/ at any depth
# binary / empty not text
# too-large over 256 KiB (listed by --skipped, reported as not scanned)
# link-or-submodule a symlink or gitlink: its content is not scanned here
# missing tracked but absent from the worktree
#
# Category: the first rule that matches wins, in this order:
# 1 workflow .github/workflows/*.yml|*.yaml
# 2 workflow composite action (action.yml|action.yaml at any depth)
# 3 docker Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile[.*]
# 4 compose compose[.*].y[a]ml, docker-compose*.y[a]ml
# 5 code by extension:
# shell *.sh *.bash *.zsh Makefile GNUmakefile *.mk
# python *.py tsjs *.ts *.tsx *.js *.jsx *.mjs *.cjs *.mts *.cts
# rust *.rs
# 6 shebang interpreter basename (also via /usr/bin/env [-S] [VAR=x] X):
# sh bash dash zsh ksh ash -> shell; python* pypy* -> python;
# node deno bun tsx ts-node -> tsjs
# 7 config *.yml *.yaml *.json *.jsonc *.toml *.ini *.cfg *.conf
# *.properties *.env .env .env.* Caddyfile
# 8 generic any other text file
#
# Usage: discover-targets.sh [--summary] [--skipped]
# (default) targets on stdout as "<category><TAB><path>", sorted
# --summary target counts per category, then "not-scanned <reason> <n>"
# --skipped "skip:<reason><TAB><path>" for every skipped tracked file
set -euo pipefail

mode=targets
for arg in "$@"; do
case "$arg" in
--summary|--skipped)
if [ "$mode" != targets ] && [ "$mode" != "${arg#--}" ]; then echo "--summary and --skipped cannot be combined" >&2; exit 2; fi
mode=${arg#--} ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done

MAX_BYTES=262144 # files over 256 KiB are listed, not scanned

skip_reason() { # $1 = path; prints a reason when the file is never a target
case "${1##*/}" in
package-lock.json|npm-shrinkwrap.json|pnpm-lock.yaml|yarn.lock|bun.lock|bun.lockb|deno.lock|flake.lock|composer.lock|Cargo.lock|uv.lock|poetry.lock|pdm.lock|Pipfile.lock|Gemfile.lock|go.sum|gradle.lockfile|packages.lock.json|Podfile.lock|mix.lock|pubspec.lock|.terraform.lock.hcl) echo lockfile; return ;;
esac
case "$1" in
node_modules/*|*/node_modules/*|vendor/*|*/vendor/*|third_party/*|*/third_party/*) echo vendored ;;
esac
}

SHEBANG_KIND=""
shebang_kind() { # $1 = path; sets SHEBANG_KIND from the interpreter basename
local first="" words word interp
SHEBANG_KIND=""
IFS= read -r first < "$1" 2>/dev/null || true
first=${first%$'\r'}
case "$first" in '#!'*) ;; *) return 0 ;; esac
read -r -a words <<< "${first#\#!}"
[ "${#words[@]}" -gt 0 ] || return 0
interp=${words[0]##*/}
if [ "$interp" = env ]; then
interp=""
for word in "${words[@]:1}"; do
case "$word" in -*|*=*) continue ;; esac
interp=${word##*/}
break
done
fi
case "$interp" in
sh|bash|dash|zsh|ksh|ash) SHEBANG_KIND=shell ;;
python*|pypy*) SHEBANG_KIND=python ;;
node|deno|bun|tsx|ts-node) SHEBANG_KIND=tsjs ;;
esac
}

categorise() { # $1 = path; prints the category
local base=${1##*/}
case "$1" in
.github/workflows/*.yml|.github/workflows/*.yaml) echo workflow; return ;;
esac
case "$base" in
action.yml|action.yaml) echo workflow; return ;;
Dockerfile|Dockerfile.*|*.Dockerfile|Containerfile|Containerfile.*) echo docker; return ;;
compose.yml|compose.yaml|compose.*.yml|compose.*.yaml|docker-compose*.yml|docker-compose*.yaml) echo compose; return ;;
*.sh|*.bash|*.zsh|Makefile|GNUmakefile|*.mk) echo shell; return ;;
*.py) echo python; return ;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.mts|*.cts) echo tsjs; return ;;
*.rs) echo rust; return ;;
esac
shebang_kind "$1"
if [ -n "$SHEBANG_KIND" ]; then echo "$SHEBANG_KIND"; return; fi
case "$base" in
*.yml|*.yaml|*.json|*.jsonc|*.toml|*.ini|*.cfg|*.conf|*.properties|*.env|.env|.env.*|Caddyfile) echo config ;;
*) echo generic ;;
esac
}

emit() { # prints "<category-or-skip:reason>\t<path>" for every tracked file
local f reason
while IFS= read -r -d '' f; do
if [ -L "$f" ] || [ -d "$f" ]; then printf 'skip:link-or-submodule\t%s\n' "$f"; continue; fi
if [ ! -e "$f" ]; then printf 'skip:missing\t%s\n' "$f"; continue; fi
reason=$(skip_reason "$f")
if [ -n "$reason" ]; then printf 'skip:%s\t%s\n' "$reason" "$f"; continue; fi
if [ ! -s "$f" ]; then printf 'skip:empty\t%s\n' "$f"; continue; fi
if ! grep -Iq -- '' < "$f" 2>/dev/null; then printf 'skip:binary\t%s\n' "$f"; continue; fi
if [ "$(wc -c < "$f")" -gt "$MAX_BYTES" ]; then printf 'skip:too-large\t%s\n' "$f"; continue; fi
printf '%s\t%s\n' "$(categorise "$f")" "$f"
done < <(git ls-files -z)
}

case "$mode" in
targets) emit | grep -v '^skip:' | sort || true ;;
skipped) emit | grep '^skip:' | sort || true ;;
summary)
all=$(emit)
if [ -n "$all" ]; then
printf '%s\n' "$all" | grep -v '^skip:' | cut -f1 | sort | uniq -c | sort -rn || true
printf '%s\n' "$all" | grep '^skip:' | cut -f1 | sort | uniq -c | sed -E 's/^ *([0-9]+) skip:(.*)$/not-scanned \2 \1/' || true
fi
;;
esac
21 changes: 19 additions & 2 deletions .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
It also declares the optional `github_token` build secret (from the
`GITHUB_TOKEN` environment variable, which CI exports) that
authenticates `../scripts/develop.sh`'s downloads; unset locally, it is
simply absent.
simply absent. Its `environment:` sets `CLAUDE_CONFIG_DIR` to the
`claude-config` volume's mount path (see "Do").
- `compose.instance.yml` — instance-owned stub (`ignore` tier, never
touched by sync): an instance's `include:` list and extra `services.myapp`
settings. See `docs/TEMPLATE.md`'s "Instance extension points".
Expand Down Expand Up @@ -155,14 +156,30 @@ need the window reconnected, not a fresh image.
not by Renovate: Renovate can't regenerate this file, so
`../.github/renovate.json` turns its devcontainer feature updates
off rather than let them merge with a stale lockfile.
- Change `CLAUDE_CONFIG_DIR` (`compose.yml`'s `services.myapp`
`environment:`), `claude-config`'s mount path and the
`/home/vscode/.claude` paths in `../scripts/develop.sh` together.
develop.sh creates that directory owned by `vscode` at mode 0700, so a
fresh volume starts out writable by Claude Code. The
variable points Claude Code at the volume for all its state. Without
it, Claude Code keeps `.claude.json` (account, MCP servers,
per-project trust) at `~/.claude.json`, outside the volume, so a
rebuild loses it and first-launch setup runs again. See Claude Code's
[dev container docs](https://code.claude.com/docs/en/devcontainer#persist-authentication-and-settings-across-rebuilds).
The variable, or a new mount path, takes effect only on a full
rebuild, and that first rebuild still starts without `.claude.json`.
To keep it, quit Claude Code and run
`cp ~/.claude.json ~/.claude/.claude.json` (into the volume) as the
last step before rebuilding, or sign in once more afterwards.

## Don't

- Bind- or volume-mount a language runtime's dependency tree, or anything
else you don't want a host antivirus/DLP tool scanning or syncing — it
lives purely in the container's writable layer instead. (`claude-config`
and `gh-config` are deliberate, narrow exceptions — small login and
session state; see `compose.yml`'s comments on those volumes.)
session state, including Claude Code's `.claude.json`; see
`compose.yml`'s comments on those volumes.)
- Add a `ports:` mapping or a `networks:` block anywhere here.
- Pin a feature or image version as `latest` — pin the exact version so
Renovate can bump it deliberately.
14 changes: 11 additions & 3 deletions .devcontainer/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ services:
# adds one.
- claude-config:/home/vscode/.claude
- gh-config:/home/vscode/.config/gh
environment:
# Keeps all Claude Code state, .claude.json included, in the
# claude-config volume. Must match its mount path above and the
# paths in scripts/develop.sh; see .devcontainer/README.md's "Do".
# Set here rather than in devcontainer.json's remoteEnv so every
# process in the container sees it, not just VS Code's.
CLAUDE_CONFIG_DIR: /home/vscode/.claude
command: sleep infinity
# No ports: mapping and no networks: block — see .devcontainer/
# README.md's "Don't". An instance that needs host access adds
Expand All @@ -39,9 +46,10 @@ services:

volumes:
# Persists Claude Code CLI session/credential state across container
# rebuilds. A small amount of session-state data, not a language
# runtime's dependency tree, so the DLP/malware-scanning concern above
# doesn't apply the same way here.
# rebuilds, .claude.json included (see CLAUDE_CONFIG_DIR above). A
# small amount of session-state data, not a language runtime's
# dependency tree, so the DLP/malware-scanning concern above doesn't
# apply the same way here.
claude-config:
# Persists the GitHub CLI's login (`gh auth login`) across rebuilds, for
# the same reason.
Expand Down
12 changes: 8 additions & 4 deletions .github/CONTENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
- `workflows/` — GitHub Actions; see its own `README.md`.
- `scripts/` — helper scripts used by the workflows; see its own
`README.md`.
- `renovate.json` — weekly update PRs for GitHub Actions, every
- `renovate.json` — weekly update PRs for GitHub Actions (pinned to a
full commit SHA with a `# vX.Y.Z` comment, kept current by
`helpers:pinGitHubActionDigests`), pre-commit hook revisions (the
`pre-commit` manager), Docker image digests, GitHub and OSV
vulnerability alerts, every
Dockerfile/compose image tag repo-wide (scanned recursively, unlike
Dependabot), and the `# renovate:` comment-annotated version pins in
`.devcontainer/Dockerfile` (and an instance's top-level runtime
Expand Down Expand Up @@ -33,6 +37,6 @@

## Don't

- Pin an Action to a floating tag like `@v7` or `@main` — pin the exact
patch version (see `docs/TEMPLATE.md`'s "Versions and config"
section).
- Pin an Action to a floating tag like `@v7` or `@main`, or to a tag at
all — pin the full commit SHA with a `# vX.Y.Z` comment (see
`docs/TEMPLATE.md`'s "Versions and config" and "Checks" sections).
26 changes: 25 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
"schedule": ["before 6am on monday"],
"pre-commit": {
"enabled": true
},
"vulnerabilityAlerts": {
"labels": ["security"]
},
"osvVulnerabilityAlerts": true,
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
Expand All @@ -20,6 +27,11 @@
"groupName": "python minor upgrade",
"automerge": false
},
{
"description": "Pin container images to their digest. Scoped to the docker managers on purpose: a global pinDigests also hits the pre-commit manager, whose tag revs can't be converted to digests (the update fails). GitHub Actions are pinned by helpers:pinGitHubActionDigests instead.",
"matchManagers": ["dockerfile", "docker-compose"],
"pinDigests": true
},
{
"description": "Renovate can't regenerate .devcontainer/devcontainer-lock.json, so features are bumped by hand together with it (see .devcontainer/README.md).",
"matchManagers": ["devcontainer"],
Expand Down Expand Up @@ -112,6 +124,18 @@
],
"datasourceTemplate": "docker",
"depNameTemplate": "aquasec/trivy"
},
{
"description": "The ZAP image pinned by tag and digest in scripts/image-dast.sh; Renovate updates both.",
"customType": "regex",
"managerFilePatterns": [
"/^scripts/image-dast\\.sh$/"
],
"matchStrings": [
"zap=ghcr\\.io/zaproxy/zaproxy:(?<currentValue>[0-9][^@\\s]*)@(?<currentDigest>sha256:[a-f0-9]{64})"
],
"datasourceTemplate": "docker",
"depNameTemplate": "ghcr.io/zaproxy/zaproxy"
}
]
}
14 changes: 4 additions & 10 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
that label the test fails until you look up the new image's `python3`
(actions/runner-images) and update `RUNNER_IMAGE_PYTHON`, `RUNNER_PYTHON` and
`ruff.toml` together.
- `test_moderate_labels.py` — checks that every `create "<name>" "<color>"
"<description>"` line in `../workflows/moderate-setup.yml` has a description
of at most 100 characters (GitHub answers HTTP 422 to a longer one, and only
the manually dispatched workflow would ever show it); `scripts-tests` runs it.
- `verify_release_tag.sh` — the `release` job's check that an already
published release's tag points at the commit the run built (fails closed;
retries only transient lookup errors, six attempts over about two
Expand All @@ -98,16 +102,6 @@
read-after-write lag for refs, so the error says to re-run once the tag is
visible. Tested against a fake `gh` in
`test_release_scripts.py`.
- `find_moderation_result.sh` / `test_moderation_scripts.py` — the check
step shared by `../workflows/moderate-bug-fix-apply.yml` and
`../workflows/moderate-feature-build-apply.yml`: asks the API for the
`moderation-result` artifact by name (so it is found however many
artifacts the run has) and writes `found=true|false`. It fails on an
expired artifact (a produced result that can no longer be applied; the
message says to re-run the worker run, since re-running the apply run
cannot bring the artifact back), an
API error, and an empty or unrecognised response. The tests run it
against a fake `gh`.
- `run_claude.sh` — invokes `claude -p` for one issue-moderation stage
inside the devcontainer; shared by all four Claude-invoking
`../workflows/moderate-*.yml` jobs. See `../workflows/README.md`'s
Expand Down
40 changes: 0 additions & 40 deletions .github/scripts/find_moderation_result.sh

This file was deleted.

Loading
Loading