AWS Automated Security Helper (ASH, pinned v3.2.6) is the scanner orchestration layer for this repo. It bundles Bandit, Semgrep, detect-secrets, Checkov, cfn-nag, Grype, and npm-audit under one CLI with UV tool isolation and unified SARIF/JSON output.
ASH is wired across four latency tiers, each scoped tighter and budgeted faster than the last. The point is feedback at the speed of typing, escalating to thorough only as the blast radius grows.
| Tier | Trigger | Budget | Scope | What runs |
|---|---|---|---|---|
| 0 on-write | PostToolUse Write/Edit | <300ms | the one file just written | inline Python hooks (smells + secrets) only |
| 1 pre-commit | git commit |
<5s | staged files | single-file checkov on staged IaC, secrets on staged |
| 2 pre-push | git push |
<30s | push range | ash --mode precommit on changed files |
| 3 CI | PR / push to main | minutes | full tree | ash --mode container, all scanners, SARIF upload |
Each tier is a superset of the prior tier's coverage. Tiers 0–2 are
file-scoped; cross-file and cross-resource analysis (e.g. a security group defined
in one .tf and referenced in another) is Tier 3's job only, by design — it
blows the smaller budgets. Tier 3 is the only un-bypassable tier.
Tiers decide how fast; applicability decides whether at all. The change set is
computed once per invocation, then each scanner runs only if the change set
contains a file type it analyzes. The mapping lives in exactly one place,
hooks/scan_router.py, and is mirrored by
.ash/ash.yaml:
| Scanner | Runs only if change set contains |
|---|---|
| Bandit | .py |
| Semgrep | a configured language (.py/.js/.ts/.go/…) |
| Checkov | .tf / CloudFormation / Kubernetes manifest / Dockerfile |
| cfn-nag | a CloudFormation template (detected by content) |
| detect-secrets | any text file |
| npm-audit | package.json / package-lock.json |
| Grype (SCA) | a dependency manifest / lockfile |
CloudFormation is detected by content, not extension: a .yaml file is a
cfn-nag target only if it has a top-level Resources block plus an
AWSTemplateFormatVersion or an AWS::-style resource Type. GitHub Actions
workflows and Kubernetes manifests are YAML too and are deliberately excluded.
An empty or non-applicable change set exits 0, silently — no "nothing to scan" banner. The only success line printed is at Tier 3 CI; Tiers 0–2 stay silent on success.
cdk-nag is disabled in .ash/ash.yaml. ASH's README documents
a CfnInclude BootstrapVersion collision on CDK-synthesized templates that
produces spurious failures. cfn-nag and checkov remain enabled for
CloudFormation coverage. This is an upstream interaction, not a config error — do
not attempt to "fix" the collision.
Measured on this repo (macOS, Apple Silicon, Python 3.14, scanners via uvx).
The numbers — not the table above — decide tier placement.
| Measurement | Result | Decision |
|---|---|---|
checkov -f <one .tf> (steady-state, via uvx) |
~1.55s (1.52 / 1.55 / 1.61s) | >300ms → IaC check lives at Tier 1, not Tier 0. PostToolUse stays Python/secrets-only. Well under the 5s Tier-1 budget. |
pure python3 startup floor |
0.01s | inline Python hooks have ample headroom under 300ms |
ash --mode precommit, cold (first run, tool provisioning) |
82.1s | one-time cost when ASH's tool copies are first built; not paid per-push |
ash --mode precommit, warm (1-file change) |
9.9s | under the 30s Tier-2 budget, but a ~10s fixed floor |
ash --mode precommit, warm (zero matching input) |
9.7s | ASH pays the floor even with nothing to scan → applicability MUST be gated at the wrapper (don't invoke ASH unless a code/IaC scanner applies). Confirms applicability rule 6. |
ash --mode precommit --scanners checkov (warm) |
9.8s | scoping to one scanner does not reduce the floor — the ~10s is ASH orchestration, not scanner count |
ash --mode container (full tree) |
CI-only | needs a container runtime; measured by the first Tier 3 CI run (Docker is present on the runner) |
Why checkov is Tier 1, not Tier 0: the single-file run is ~1.5s, roughly 5× the 300ms on-write budget. Per the integration's own escape clause, the IaC check drops to Tier 1 (pre-commit) where the budget is 5s, and the PostToolUse on-write gate stays the existing inline Python smell + secret checks. Nothing in the ASH bundle starts fast enough for a sub-300ms per-write gate, which is why ASH never runs at Tier 0.
Two facts from the numbers changed the wiring from the naive "run ash --mode precommit on the changed files":
precommitis a speed preset, not a git filter. In ASH v3.2.6,--mode precommitmeans "Python-based scanners only, simplified output" — it scans the whole--source-dir; there is no per-file flag. To keep Tier 2 file-scoped, the pre-push hook stages the push-range files into a temp dir and runs ASH with--source-dirpointed at it. Cross-file analysis is therefore not done at Tier 2 by design — that is Tier 3's job.- ASH pays a ~10s floor even for zero input. So
hooks/prepush_checks.pygates before invoking ASH: it computes the push range (git diff --name-only @{push}, falling back to the default branch on a new branch — never the whole tree), and runs ASH only when a code/IaC scanner (bandit/semgrep/checkov) applies. A docs-only push matches onlydetect-secrets, which is already gated dependency-free at Tiers 0–1 and re-checked by the CI container — so it does not pay the ASH floor and the pre-push exits silently.
The container figure is authoritative from CI, where Docker is present by default.
Suppressions are a system with documented reasons, not silent ignores. Every finding ASH reports is resolved in exactly one of three ways, all visible in version control and reviewed in the PR that introduces them:
- FIXED — the finding is real and corrected in code (e.g. the GitHub
Actions script injection in
npm-publish-manual.ymland thecurl | bashinscripts/xact.shwere fixed, not suppressed). - SUPPRESSED with a reason — a confirmed false positive or an accepted,
explained risk. Each entry in
.ash/ash.yamlglobal_settings.suppressionscarries:- a
rule_idand apathscoped as narrowly as practical (never a blanket repo-wide "ignore everything"), - a
reasonstating why it is not actionable (e.g. "this 40-char hex is the pinned ASH commit SHA, not a secret"; "documentation example of a credential pattern").
- a
- TRACKED — real but out-of-scope-to-fix-now work is suppressed with a
reason that references a Beads task and an
expirationdate that forces re-review (e.g. the GitHub Actions hardening tracked inclaude-code-0qi, expiring2026-09-01).
Rules of thumb: prefer FIX over SUPPRESS; scope suppressions to the narrowest
path + rule_id that covers the false positive; give accepted-risk and
tracked suppressions an expiration so they cannot rot; secrets findings are
suppressed only after confirming each is a non-secret (the inline Tier 0/1
hooks keep real secrets non-suppressible regardless).
Note on local vs CI for secrets: ASH's detect-secrets plugin runs in CI but
no-ops in local --mode local on some machines (the tool runs in an isolated
uvx environment). Treat CI as the authoritative validator for
detect-secrets; the suppressions above were triaged from the CI artifact.
One entry point wires the local spine: bash scripts/setup-hooks.sh. It is
idempotent and:
- symlinks the Claude Code hooks into
~/.claude/hooks/, - installs chained git hooks (Tier 1
pre-commit, Tier 2pre-push) viascripts/install-git-hooks.sh, preserving any pre-existing hook (e.g. the beadspre-commit) by chaining it — nocore.hooksPathoverride.
Tier 0 activation is opt-in. The PostToolUse hooks fire only once their
config is present in ~/.claude/settings.json. By default setup-hooks.sh does
not edit your global settings; pass --wire-settings to merge
hooks/settings.example.json in idempotently (additive — existing hooks are
preserved), or merge it by hand. Tier 3 (CI) needs no install: it ships in
.github/workflows/ash.yml and runs on PRs and pushes to main.
Uninstall with bash scripts/setup-hooks.sh --uninstall (restores any chained
hooks).