diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index 59d275d..c97d44b 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -654,10 +654,11 @@ Preserve all completed task evidence below. - Evidence: a hook push with `OUSAST_PUSH_ENGINE=background` followed by a second push showing the previous result. - _Done 2026-10-02:_ `--engine inline|background|off` (hook: `OUSAST_PUSH_ENGINE`) and `--background-deadline` (default 900 s). `off` and `background` run the quick tier and record `engine_off`/`engine_background` with a plain skip line; the deadline line names the deadline and the background option. New `push/background.py` starts one detached explicit replay (`start_new_session`, stdin closed, output to a log) per artifact directory and repository (lock file with a live-pid check: a second start reports busy), writes its artifact under `/engine//.json`, and the next run prints "Engine result for from the previous push: ..." once, then marks it shown. Not usable through the Docker wrapper (the container exits with the hook); documented in 17.6. Tests: `tests/test_push_background.py` (4, one launching a real detached run). Gates: 2011 passed, 42 skipped; ruff, format, mypy clean. - _Requirements: 9.5, 4.1, 4.2_ -- [ ] 17.6 Install, Docker and documentation for a first-time user +- [x] 17.6 Install, Docker and documentation for a first-time user - `ousast pre-push install|uninstall [--force] [--docker] [REPO]` ships the hook as package data, honours `core.hooksPath`, refuses an existing hook unless `--force` (which chains it as `pre-push.before-ousast`), and works from a pip install. `ops/ousast-docker` is a hook-callable script (not a shell function) that passes stdin and the artifact path through. The `openultrasast:dev` image is rebuilt only if `df -h /` shows at least 4 GB free; otherwise the rebuild command is documented and the record says it was not rebuilt. `docs/scanning.md` gains a first-run guide, a glossary of skip reasons, a GitHub Actions recipe and full `--help` text; README tiers 1 and 2 updated, pulling `ghcr.io/norandom/openultrasast:2.0.1` where the wrapper allows. - Files: `src/openultrasast/push/hook/pre-push`, `src/openultrasast/push/install.py` (new), `src/openultrasast/cli.py`, `ops/ousast-docker`, `docs/scanning.md`, `README.md`, `ops/README.md`. Tests: `tests/test_push_install.py` (new), `mkdocs build --strict`. - Evidence: install from the package into a scratch clone, push through it, uninstall restores the prior hook. + - _Done 2026-10-02:_ `ousast pre-push install|uninstall [REPO] [--force] [--docker]` (`push/install.py`) installs the hook shipped as package data (`openultrasast/push/hook/pre-push`, byte-identical to `ops/pre-push`, verified in the built wheel), honours `core.hooksPath` without editing it, refuses an existing hook unless `--force` (ours is replaced; a foreign hook moves to `pre-push.before-ousast` and is chained), and uninstall removes only a hook it wrote and restores the chained one. `ops/ousast-docker` (also package data; `--docker` installs it beside the hook, which then uses it) is a script that mounts the repository read-only and the artifact directory at their own absolute paths, passes stdin with `-i`, runs as the user with no network and `--memory 3g`, defaults to `ghcr.io/norandom/openultrasast:2.0.1`, never pulls inside a hook, and exits 0 unless `OUSAST_PUSH_MODE=blocking`. The hook passes `--engine`/`--comparison-base` only when set, so an older ousast or image still runs. Every pre-push flag has help text; `docs/scanning.md` has the first-run guide, settings table, new-branch and background notes, the artifact keys, a skip-reason glossary (a test keeps it in sync with `push/explain.py`), a GitHub Actions recipe and the full `--help` text; README tiers 1 and 2 and `ops/README.md` updated (pull and tag the GHCR image instead of building). **Image not rebuilt:** `df -h /` showed 1.1 GB free (< 4 GB); rebuild with `docker build -t openultrasast:dev .`. Tests: `tests/test_push_install.py` (11) and the glossary test. Gates: 2022 passed, 42 skipped; ruff, format, mypy clean; `mkdocs build --strict` exit 0. - _Requirements: 9.6, 7.4_ - [ ] 17.7 Prove it on the audit's three repositories - Rerun records, serve-static and unrolled/secure (same planted commits) with the new build in quick mode and with the hook installed, plus one Docker engine run on serve-static if free memory >= 3 GB and free disk >= 1 GB. Prove each run read its input (files, bytes). diff --git a/README.md b/README.md index 75c28f4..dceb884 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ uv run ousast scan . --mode quick --fail-on verified # CI: exit 1 on any evide This runs the language-scoped pattern rules, entry-point reachability hints, ranking, verification and scoring. It is deterministic and reproducible. It needs no network and no keys. +The same install gives you the advisory pre-push hook for any repository. It runs the quick rules +on the lines a push changes, names every check it could not run, and never blocks a push: + +```bash +cd /path/to/your/repository && ousast pre-push install # remove: ousast pre-push uninstall +``` + What it cannot do: - There is no engine. Nothing follows a value across statements or files. @@ -70,12 +77,20 @@ the overlay. ### Tier 2: standard and deep scans (Docker) -`standard` and `deep` need Joern. The Docker image `openultrasast:dev` ships three things in one -image: the tool, Joern v4.0.625 and `php-cli` (the PHP frontend needs it). You build it locally -from the `Dockerfile`; there is no published image yet. It is big: a JRE plus about 2 GB of Joern. +`standard` and `deep` need Joern. The Docker image ships three things in one image: the tool, +Joern v4.0.625 and `php-cli` (the PHP frontend needs it). Each release publishes it to GHCR. It is +big: a JRE plus about 2 GB of Joern. Pull it instead of building, and tag it with the name the +shell wrapper uses: -The shell wrapper builds the image on first use. Then it runs `ousast` in the container. Your -directory is mounted read-only and the container network is off. +```bash +docker pull ghcr.io/norandom/openultrasast:2.0.1 +docker tag ghcr.io/norandom/openultrasast:2.0.1 openultrasast:dev # or build: docker compose build +``` + +The shell wrapper runs `ousast` in the container. It builds the image only if `openultrasast:dev` +is missing. Your directory is mounted read-only and the container network is off. For the pre-push +hook, use `ousast pre-push install --docker`: Git cannot call a shell function, so it installs the +script `ousast-docker` beside the hook. ```bash source /path/to/OpenUltraSAST/ops/shell/ousast.sh # PowerShell: ops/shell/ousast.ps1 @@ -158,8 +173,11 @@ loads settings. Every run writes these files under `/.openultrasast/runs and `trace/events.jsonl`. `ousast pre-push` (experimental) analyses the commits a push would publish against their base. -Only new or worsened defects become candidates. It is advisory by default. Its capability registry -is empty, so it emits no normal alert today. +First, the quick rules run on the changed lines. Then the engine compares head with base, and only +new or worsened defects become candidates. It is advisory by default, and it prints one line per +check it could not run. Its capability registry is empty, so it emits no normal alert today. An +engine finding the push introduced is shown as advisory. Install, settings, skip reasons and a CI +recipe: [docs/scanning.md](docs/scanning.md#first-run-install-the-hook-on-any-repository). Flows and the per-language rule table: [docs/scanning.md](docs/scanning.md). diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index d46b7b3..06f5a39 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,7 +1,7 @@ { - "modules": 154, + "modules": 155, "counts": { - "load_bearing": 149, + "load_bearing": 150, "standalone_capability": 5 }, "orphaned": [], @@ -1262,6 +1262,14 @@ "push.runner" ] }, + { + "module": "push.install", + "classification": "load_bearing", + "reason": "reachable from gate, map_gate, pair_gate, cli", + "importers": [ + "cli" + ] + }, { "module": "push.policy", "classification": "load_bearing", diff --git a/docs/scanning.md b/docs/scanning.md index 2dcc14c..2874193 100644 --- a/docs/scanning.md +++ b/docs/scanning.md @@ -131,23 +131,288 @@ candidates. Sources: `ousast pre-push --help`, `src/openultrasast/push/`, flowchart TD input["git push refs on stdin, or --base / --head"] --> resolve["Resolve both commit identities"] resolve --> snap["Materialise tracked bytes in private scratch storage"] - snap --> regions["Discover changed regions, up to --max-regions"] - regions --> engine["Evidence ranker and arbiter on head"] + snap --> quick["Quick rules on the changed head lines (advisory, under 1 s)"] + quick --> eng{"--engine"} + eng -- "off" --> art + eng -- "background" --> bg["Detached engine run; result shown on the next push"] + bg --> art + eng -- "inline, the default" --> engine["Engine on head: changed regions up to --max-regions, ranker, arbiter"] engine --> cmp["Targeted base comparison: new or worsened only"] cmp --> admit{"Admission policy: capability registry"} - admit -- "registry empty today" --> diag["Diagnostic candidates in the artifact"] + admit -- "registry empty today" --> diag["Advisory engine findings, not alerts"] admit -- "an enabled capability" --> alert["Normal alert"] - diag --> art["JSON artifact outside the repository"] + diag --> art["Terminal lines + JSON artifact outside the repository"] alert --> art art --> exit{"--mode"} exit -- "advisory, the default" --> allow["Exit 0: allow"] exit -- "blocking, explicit opt-in" --> block["May block the push"] ``` -- `--deadline` (30 s by default) bounds preparation, both revisions and reporting together. -- `--incomplete-coverage block` opts into failing when coverage is incomplete. +- `--deadline` (30 s by default) bounds preparation, the quick rules, both revisions and reporting together. +- The quick rules run first on the changed lines. Their matches are shown as advisory pattern + matches, never as alerts, and never block. - The default capability registry is empty until independent qualification passes. So no normal - alert is emitted today. Every candidate stays diagnostic. + alert is emitted today. An engine finding the push introduced is shown as an advisory engine + finding; every other candidate stays diagnostic in the artifact. - A model is called only with an explicit `--model-config`. -- Hook installation (`ops/install-pre-push`) and the private cache (`--cache-dir`) are documented - in [Engine and pre-push](ops/README.md#experimental-pre-push-integration). +- The private cache (`--cache-dir`) and the manual install recipe are documented in + [Engine and pre-push](ops/README.md#experimental-pre-push-integration). + +### First run: install the hook on any repository + +The hook is advisory. It never rejects a push unless you set `OUSAST_PUSH_MODE=blocking`. A check +that cannot run says so in one line and lets the push through: a missing `ousast`, a missing +Docker image, an invalid setting, a crash. + +1. Install the tool. The package is not on PyPI. Install it from the repository (or from a + release wheel on the GitHub releases page): + + ```bash + pip install "git+https://github.com/norandom/OpenUltraSAST" + ``` + +2. Install the hook in the repository you want checked. Git's effective hook directory is used, + including `core.hooksPath`, and that setting is never edited: + + ```bash + cd /path/to/your/repository + ousast pre-push install # refuses if a pre-push hook already exists + ousast pre-push install --force # moves an existing hook to pre-push.before-ousast and chains it + ``` + +3. Push as usual. Without the Joern engine, you get the quick rules and one line saying the + engine did not run. For the engine, choose one: + + - a host install with `joern-parse` on the PATH (`pyinfra @local ops/joern.py`, see + [Engine and pre-push](ops/README.md)); + - Docker: `docker pull ghcr.io/norandom/openultrasast:2.0.1`, then + `ousast pre-push install --docker`. The wrapper `ousast-docker` is installed beside the + hook and runs the check in the container (repository read-only, no network, + `--memory 3g`). The container runs the code of its image's version: the quick-rule + tier, the plain skip lines and `--engine` need an image built from a version that has them + (`docker build -t openultrasast:dev .`, then `OUSAST_IMAGE=openultrasast:dev`). + +4. Remove it with `ousast pre-push uninstall`. Only a hook this tool wrote is removed, and a + chained previous hook is restored. + +A push on the 2026-10-02 proof repository (serve-static, a new `debug.js` that evaluates +`req.query.code`, no Joern on the host) prints: + +```text +Quick rules: 2 pattern match(es) on changed code (1 file(s), 87 bytes read). Advisory: not verified by the engine. +- js-eval (CWE-95, critical) at debug.js:2: Dynamic JavaScript evaluation needs review +- js-xss-response (CWE-79, medium) at debug.js:2: Unsanitized HTTP response reflection needs review +No actionable defects reported; analysis is incomplete. +Skipped: the changed code has no counterpart in the base revision to compare against +Skipped: engine did not run: the code graph could not be built (is Joern installed? ...) +Skipped: no base revision was analysed, so no engine finding can be called new or worsened +Notice: Engine checks did not run. Install Joern, or run the hook through ops/ousast-docker. Review the details. +Details: /home/you/.cache/openultrasast/push/push-12345.json +``` + +The hook reads these settings from the environment of `git push`: + +| Variable | Default | Effect | +| --- | --- | --- | +| `OUSAST_PUSH_DEADLINE` | `30` | seconds for the whole check | +| `OUSAST_PUSH_MODE` | `advisory` | `blocking` is the only way a result can reject a push | +| `OUSAST_INCOMPLETE_COVERAGE` | `allow` | with `blocking`: `block` also rejects incomplete coverage | +| `OUSAST_PUSH_ENGINE` | `inline` | `background`: return after the quick rules, run the engine detached; `off`: quick rules only | +| `OUSAST_COMPARISON_BASE` | unset | a local ref to compare a new branch against | +| `OUSAST_ARTIFACT_DIR` | `~/.cache/openultrasast/push` | where the JSON results go; outside the repository | +| `OUSAST_COMMAND` | `ousast` | the command the hook runs, for example the Docker wrapper | +| `OUSAST_IMAGE` | `ghcr.io/norandom/openultrasast:2.0.1` | the Docker wrapper's image | +| `OUSAST_DOCKER_MEMORY` | `3g` | the Docker wrapper's memory limit | + +**A new branch.** Git gives the hook no remote base for a branch the remote does not have yet. +The check says so ("new branch: the remote has no base to compare against"), runs the quick rules +over the files the branch's last commit changed instead, and says that too. Set +`OUSAST_COMPARISON_BASE=main` to compare a new branch against your local `main`. + +**The engine and the deadline.** The engine does not fit a 30 s deadline today. The 2026-10-02 +audit measured 260 s on a 26-file repository; graph reuse is the planned fix. When it cannot +finish, one line says so. With `OUSAST_PUSH_ENGINE=background`, the hook returns after the quick +rules and starts one detached engine run (`--background-deadline`, 900 s by default). The next push +in the same repository prints `Engine result for from the previous push: ...` once. Only +one background run happens at a time. The Docker wrapper cannot run the engine in the background, +because the container ends with the hook. + +**The artifact.** Every run writes one JSON file, and the terminal prints its path after +`Details:`. It records the following: + +- `snapshots[].bytes_read`: proof that the input was read; +- `quick_tier`: the quick-rule matches, with the files and bytes read; +- `admission.coverage_reasons`: the raw skip reasons; +- `admission.dispositions`: every engine candidate and its vetoes; +- `engine_background`: background runs started or shown; +- `timings`. + +Files accumulate, one per push; delete old ones when you like. + +### Skip reasons + +The terminal prints one `Skipped:` line per distinct reason in plain words. The artifact keeps the +raw reason. Repository paths are hex-encoded there, and per-question reasons end in a hash. + +| Raw reason (artifact) | Terminal line says | What to do | +| --- | --- | --- | +| `cpg_build_failed`, `cpg_unavailable` | the engine did not run: the code graph could not be built | install Joern, or use the Docker wrapper | +| `cpg_empty` | the engine read no code | check that the changed files are in a supported language | +| `frontend_unsupported` | no engine frontend for a language in this push | none: that language gets the quick rules at most | +| `language_not_covered:::` | language not covered: `` (n changed files, m in the repository) | none: neither the engine nor the quick rules cover it, so silence is not a clean result | +| `deadline_exhausted` | the engine did not finish within the deadline | raise `OUSAST_PUSH_DEADLINE`, or set `OUSAST_PUSH_ENGINE=background` | +| `change_context_deadline_exhausted` | the deadline ran out while relating the change to the code | as above | +| `report_deadline_exhausted` | the deadline ran out while saving the details | as above | +| `engine_off`, `engine_background` | the engine was switched off, or runs after the push | none | +| `resolution:missing_base` | new branch: the remote has no base to compare against | set `OUSAST_COMPARISON_BASE` | +| `resolution:unsupported_base`, `resolution:no_merge_base`, `resolution:ambiguous_base`, `resolution:missing_target`, `resolution:unsupported_target`, `resolution:resolution_error` | the pushed ref or its remote base could not be resolved locally | fetch the remote, then push again | +| `base_comparison_unavailable`, `base_unavailable` | no base revision was analysed, so nothing can be called new | see the line before it for the cause | +| `base_counterpart_unresolved` | the changed code has no counterpart in the base to compare against | none: expected for new files | +| `:ambiguous_line_correspondence`, `:line_correspondence_unavailable`, `:ambiguous_path_rename_correspondence` | changed lines or a renamed file could not be matched to the base | none: findings there stay diagnostic | +| `unmatched_added_deleted_path_correspondence` | files were both added and deleted, so a move cannot be told from a new file | push the move separately from other edits | +| `declaration_paths_unavailable` | project declaration files could not be listed | rerun | +| `capability_unavailable`, `capability_disabled`, `capability_unevaluated`, `capability_ambiguous` | engine findings are not qualified as alerts yet | none: they show as advisory engine findings | +| `eligibility:` | the capability registry does not match this build | reinstall a consistent build | +| `comparison_unknown`, `base_incomplete` and the other novelty reasons | whether this push introduced some engine findings could not be established | none: they stay diagnostic | +| `dynamic_external_or_depth_context_unresolved:...` | n engine checks depend on dynamic or external code the engine cannot follow | none | +| `query_failed`, `query_budget_reserved`, `query_too_expensive`, `regions_truncated`, `files_unparsed` | some engine questions were not answered | raise the deadline or `--max-regions` | +| `cross_partition_semantics_unresolved` | flows between languages are not followed | none | +| `dependency_unresolved`, `change_unsupported` | engine findings depend on unresolved code or have no supported link to the change | none | +| `snapshot:` | some files could not be materialised for analysis | see the reason (symlinks, submodules, LFS) | +| `_failed:`, `push_input_failed:` | the named step failed | rerun; report it if it repeats | + +A reason not in this table is still printed as its own words, followed by "see the details file". + +### In CI + +The same check runs on a pushed range in CI. This GitHub Actions job is advisory: it never fails +the build, and it uploads the artifact. + +```yaml +name: ousast pre-push (advisory) +on: push +jobs: + pre-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "git+https://github.com/norandom/OpenUltraSAST" + - name: Check what this push changed + if: github.event.before != '0000000000000000000000000000000000000000' + run: | + ousast pre-push . --base ${{ github.event.before }} --head ${{ github.sha }} \ + --artifact "$RUNNER_TEMP/ousast-pre-push.json" --engine off --deadline 120 || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ousast-pre-push + path: ${{ runner.temp }}/ousast-pre-push.json +``` + +A branch's first push has no `github.event.before` to compare against, and the job skips it. For +the engine in CI, run the job in the container image instead of `pip install`, and drop +`--engine off`. + +### `ousast pre-push --help` + +```text +usage: ousast pre-push [-h] [--base BASE] [--head HEAD] [--remote NAME URL] + [--comparison-base COMPARISON_BASE] [--model-config MODEL_CONFIG] + [--prior-hook PRIOR_HOOK] --artifact ARTIFACT [--cache-dir CACHE_DIR] + [--deadline DEADLINE] [--cancellation-allowance CANCELLATION_ALLOWANCE] + [--max-regions MAX_REGIONS] [--mode {advisory,blocking}] + [--incomplete-coverage {allow,block}] [--engine {inline,background,off}] + [--background-deadline BACKGROUND_DEADLINE] + [--experimental-declarations EXPERIMENTAL_DECLARATIONS] + [--experimental-record-vetoes] + path + +Check what a push changes: quick rules on the changed lines, then the Joern engine on head and +base within one deadline. Advisory: exit 0 unless --mode blocking. Install the Git hook with +'ousast pre-push install [REPO] [--force] [--docker]'; remove it with 'ousast pre-push uninstall +[REPO]'. + +positional arguments: + path the repository to check (the hook passes '.') + +options: + -h, --help show this help message and exit + --base BASE explicit replay: the base revision (with --head; instead of --remote) + --head HEAD explicit replay: the head revision to check against --base + --remote NAME URL Git hook mode: the remote name and URL Git passes to pre-push; the push + input is read from stdin + --comparison-base COMPARISON_BASE + hook mode: a local ref to compare a NEW branch against (the remote has no + base for it); the hook reads OUSAST_COMPARISON_BASE + --model-config MODEL_CONFIG + explicit optional witness selection endpoint configuration + --prior-hook PRIOR_HOOK + explicitly chain an existing hook with the same input and arguments + --artifact ARTIFACT where to write the complete JSON result; must be outside the repository + --cache-dir CACHE_DIR + reuse compatible local artifacts in a private directory outside the + repository + --deadline DEADLINE seconds for the whole check: resolution, quick rules, engine and reporting + (default 30; hook: OUSAST_PUSH_DEADLINE) + --cancellation-allowance CANCELLATION_ALLOWANCE + seconds after the deadline to stop work and save the result (default 2) + --max-regions MAX_REGIONS + most code regions the engine examines, highest-ranked first (default 500) + --mode {advisory,blocking} + advisory (default) never blocks; blocking is the explicit opt-in that + rejects a push for an admitted alert (hook: OUSAST_PUSH_MODE) + --incomplete-coverage {allow,block} + with --mode blocking only: also block when coverage is incomplete (hook: + OUSAST_INCOMPLETE_COVERAGE) + --engine {inline,background,off} + inline (default): run the Joern engine inside the deadline; background: + return after the quick rules and run the engine detached, showing its + result on the next run; off: quick rules only + --background-deadline BACKGROUND_DEADLINE + seconds the detached engine run of --engine background may take (default + 900) + --experimental-declarations EXPERIMENTAL_DECLARATIONS + explicit --base/--head replay only: unreviewed capability declarations + used to render the experimental evaluation explanation; never consulted by + admission and cannot enable any capability + --experimental-record-vetoes + explicit --base/--head replay only: record each comparison and admission + veto per raw finding instead of applying it; output is labeled + experimental, changes no result and enables no hook capability +``` + +```text +usage: ousast pre-push install [-h] [--force] [--docker] [repository] + +Install the advisory OpenUltraSAST pre-push hook into a repository's effective hook directory +(core.hooksPath is honoured, never edited). + +positional arguments: + repository the repository (default: current directory) + +options: + -h, --help show this help message and exit + --force install: replace our hook, or move a foreign hook to pre-push.before-ousast and + chain it; uninstall: remove a hook this tool did not write + --docker also install the ousast-docker wrapper beside the hook, so the check runs in the + container image +``` + +```text +usage: ousast pre-push uninstall [-h] [--force] [repository] + +Remove the OpenUltraSAST pre-push hook and restore a chained previous hook. + +positional arguments: + repository the repository (default: current directory) + +options: + -h, --help show this help message and exit + --force install: replace our hook, or move a foreign hook to pre-push.before-ousast and + chain it; uninstall: remove a hook this tool did not write +``` diff --git a/ops/README.md b/ops/README.md index 002da85..1a05e8c 100644 --- a/ops/README.md +++ b/ops/README.md @@ -291,13 +291,20 @@ The default capability registry is empty pending independent qualification. Inst this interface does not make the project rollout-ready. Advisory is the default; blocking and strict incomplete-coverage handling are separate explicit choices. -Put the installed `ousast` executable on the Git process's PATH. From this tool's -checkout, explicitly install into the desired repository: +The supported install is `ousast pre-push install [REPO] [--force] [--docker]`, shipped in +the wheel; `ousast pre-push uninstall [REPO]` removes it. The first-run guide, settings, skip +reasons and a CI recipe are in [Scanning](../scanning.md#first-run-install-the-hook-on-any-repository). +If `ousast` is not on the Git process's PATH, the hook prints one line and lets the push through. +From this tool's checkout, the script installer remains available: ```sh ops/install-pre-push /absolute/path/to/repository ``` +`ops/ousast-docker` is the hook-callable Docker wrapper (`OUSAST_COMMAND=/path/to/ops/ousast-docker`, +or installed beside the hook by `--docker`). It is a script, not a shell function, so Git can run +it. It passes the push input through and mounts the artifact directory at its own path. + The installer resolves Git's effective hook directory (including `core.hooksPath`), leaves that configuration untouched, and refuses to overwrite any existing hook. For an existing hook, inspect it and explicitly move it aside **before** installation: diff --git a/ops/ousast-docker b/ops/ousast-docker new file mode 100755 index 0000000..5010671 --- /dev/null +++ b/ops/ousast-docker @@ -0,0 +1,61 @@ +#!/bin/sh +# OpenUltraSAST in Docker, callable from a Git hook. A script, not a shell function: Git runs +# hooks non-interactively and never sees functions from ~/.bashrc. +# +# OUSAST_COMMAND=/path/to/ousast-docker git push # or: ousast pre-push install --docker +# +# The repository is mounted READ-ONLY at its own absolute path and the artifact directory +# read-write at its own path, so every path in the arguments and in the output ("Details: ...") +# means the same thing inside and outside the container. The push input on stdin is passed +# through (-i). The container runs as your user, with no network and --memory 3g. +# +# Advisory like the hook: when Docker or the image is missing, it says the check did not run +# and exits 0. Pull the image once first; a hook never downloads ~3 GB on its own: +# +# docker pull ghcr.io/norandom/openultrasast:2.0.1 +# +# --engine background does not work through this wrapper: the container ends with the hook. +image=${OUSAST_IMAGE:-ghcr.io/norandom/openultrasast:2.0.1} +if ! command -v docker >/dev/null 2>&1; then + echo "ousast-docker: check did not run: docker is not installed. Push continues." + exit 0 +fi +if ! docker image inspect "$image" >/dev/null 2>&1; then + echo "ousast-docker: check did not run: image $image is not present." \ + "Run 'docker pull $image' once. Push continues." + exit 0 +fi +repo=$(pwd -P) +artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push} +previous= +for argument in "$@"; do + if [ "$previous" = "--artifact" ]; then + artifact_dir=$(dirname -- "$argument") + fi + previous=$argument +done +mkdir -p -- "$artifact_dir" || exit 0 +artifact_dir=$(cd -- "$artifact_dir" && pwd -P) +# A linked worktree keeps its objects in the main repository's git directory: mount that too. +common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "$repo/.git") +case "$common" in + "$repo"/*) extra_mount= ;; + *) extra_mount="-v $common:$common:ro" ;; +esac +# shellcheck disable=SC2086 # OUSAST_DOCKER_ARGS and extra_mount are deliberately word-split +docker run --rm -i --network none --memory "${OUSAST_DOCKER_MEMORY:-3g}" \ + --user "$(id -u):$(id -g)" -e HOME=/tmp -e TMPDIR=/work --tmpfs /work:size=4g \ + -v "$repo:$repo:ro" -w "$repo" -v "$artifact_dir:$artifact_dir" $extra_mount \ + ${OUSAST_DOCKER_ARGS:-} "$image" "$@" +status=$? +if [ "$status" -ge 125 ]; then + echo "ousast-docker: check did not run: the container could not start (docker exit $status). Push continues." + exit 0 +fi +# Only an explicit blocking opt-in may turn the container's exit into a rejected push; an older +# image that rejects a newer argument must not block either. +if [ "$status" -ne 0 ] && [ "${OUSAST_PUSH_MODE:-advisory}" != blocking ]; then + echo "ousast-docker: the check exited with status $status; push continues (advisory)." + exit 0 +fi +exit "$status" diff --git a/ops/pre-push b/ops/pre-push index ff0eb7e..e1146dc 100755 --- a/ops/pre-push +++ b/ops/pre-push @@ -5,6 +5,11 @@ # A prior hook is chained only after it was moved to the sibling path "$0.before-ousast". artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push} prior="$0.before-ousast" +# The Docker wrapper installed beside this hook (`ousast pre-push install --docker`) is used +# unless OUSAST_COMMAND names another command; otherwise `ousast` from PATH. +if [ -z "${OUSAST_COMMAND:-}" ] && [ -x "$(dirname -- "$0")/ousast-docker" ]; then + OUSAST_COMMAND="$(dirname -- "$0")/ousast-docker" +fi command=${OUSAST_COMMAND:-ousast} if ! command -v "$command" >/dev/null 2>&1; then echo "ousast: pre-push check did not run: '$command' is not on PATH." \ @@ -19,8 +24,11 @@ remote_url=$2 set -- pre-push . --artifact "$artifact_dir/push-$$.json" \ --deadline "${OUSAST_PUSH_DEADLINE:-30}" \ --mode "${OUSAST_PUSH_MODE:-advisory}" \ - --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \ - --engine "${OUSAST_PUSH_ENGINE:-inline}" + --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" +# Optional settings are passed only when set, so an older ousast (or image) still runs. +if [ -n "${OUSAST_PUSH_ENGINE:-}" ]; then + set -- "$@" --engine "$OUSAST_PUSH_ENGINE" +fi if [ -n "${OUSAST_COMPARISON_BASE:-}" ]; then set -- "$@" --comparison-base "$OUSAST_COMPARISON_BASE" fi diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index a68ec81..6722fbe 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -132,28 +132,71 @@ def main(argv: list[str] | None = None) -> int: def _main(argv: list[str] | None) -> int: - if (argv if argv is not None else sys.argv[1:])[:1] != ["pre-push"]: + given = list(argv if argv is not None else sys.argv[1:]) + if given[:1] == ["pre-push"] and given[1:2] in (["install"], ["uninstall"]): + return _hook_install(given[1], given[2:]) + if given[:1] != ["pre-push"]: load_dotenv() parser = argparse.ArgumentParser(prog="ousast") subparsers = parser.add_subparsers(dest="command", required=True) - replay_parser = subparsers.add_parser("pre-push", help="check Git push input or replay local revisions (experimental)") - replay_parser.add_argument("path", type=Path) - replay_parser.add_argument("--base") - replay_parser.add_argument("--head") - replay_parser.add_argument("--remote", nargs=2, metavar=("NAME", "URL")) - replay_parser.add_argument("--comparison-base") + replay_parser = subparsers.add_parser( + "pre-push", + help="check Git push input or replay local revisions (experimental, advisory)", + description="Check what a push changes: quick rules on the changed lines, then the Joern engine on head and base " + "within one deadline. Advisory: exit 0 unless --mode blocking. Install the Git hook with " + "'ousast pre-push install [REPO] [--force] [--docker]'; remove it with 'ousast pre-push uninstall [REPO]'.", + ) + replay_parser.add_argument("path", type=Path, help="the repository to check (the hook passes '.')") + replay_parser.add_argument("--base", help="explicit replay: the base revision (with --head; instead of --remote)") + replay_parser.add_argument("--head", help="explicit replay: the head revision to check against --base") + replay_parser.add_argument( + "--remote", + nargs=2, + metavar=("NAME", "URL"), + help="Git hook mode: the remote name and URL Git passes to pre-push; the push input is read from stdin", + ) + replay_parser.add_argument( + "--comparison-base", + help="hook mode: a local ref to compare a NEW branch against (the remote has no base for it); " + "the hook reads OUSAST_COMPARISON_BASE", + ) replay_parser.add_argument("--model-config", type=Path, help="explicit optional witness selection endpoint configuration") replay_parser.add_argument("--prior-hook", type=Path, help="explicitly chain an existing hook with the same input and arguments") - replay_parser.add_argument("--artifact", type=Path, required=True) + replay_parser.add_argument( + "--artifact", type=Path, required=True, help="where to write the complete JSON result; must be outside the repository" + ) replay_parser.add_argument( "--cache-dir", type=Path, help="reuse compatible local artifacts in a private directory outside the repository" ) - replay_parser.add_argument("--deadline", type=float, default=30.0) - replay_parser.add_argument("--cancellation-allowance", type=float, default=2.0) - replay_parser.add_argument("--max-regions", type=int, default=500) - replay_parser.add_argument("--mode", choices=("advisory", "blocking"), default="advisory") - replay_parser.add_argument("--incomplete-coverage", choices=("allow", "block"), default="allow") + replay_parser.add_argument( + "--deadline", + type=float, + default=30.0, + help="seconds for the whole check: resolution, quick rules, engine and reporting (default 30; hook: OUSAST_PUSH_DEADLINE)", + ) + replay_parser.add_argument( + "--cancellation-allowance", + type=float, + default=2.0, + help="seconds after the deadline to stop work and save the result (default 2)", + ) + replay_parser.add_argument( + "--max-regions", type=int, default=500, help="most code regions the engine examines, highest-ranked first (default 500)" + ) + replay_parser.add_argument( + "--mode", + choices=("advisory", "blocking"), + default="advisory", + help="advisory (default) never blocks; blocking is the explicit opt-in that rejects a push for an admitted " + "alert (hook: OUSAST_PUSH_MODE)", + ) + replay_parser.add_argument( + "--incomplete-coverage", + choices=("allow", "block"), + default="allow", + help="with --mode blocking only: also block when coverage is incomplete (hook: OUSAST_INCOMPLETE_COVERAGE)", + ) replay_parser.add_argument( "--engine", choices=("inline", "background", "off"), @@ -421,6 +464,45 @@ def _main(argv: list[str] | None) -> int: return 2 +def _hook_install(action: str, argv: list[str]) -> int: + """`ousast pre-push install|uninstall [REPO]` from the hook shipped in the package (task 17.6).""" + from .push.install import install, uninstall + + parser = argparse.ArgumentParser( + prog=f"ousast pre-push {action}", + description="Install the advisory OpenUltraSAST pre-push hook into a repository's effective hook directory " + "(core.hooksPath is honoured, never edited)." + if action == "install" + else "Remove the OpenUltraSAST pre-push hook and restore a chained previous hook.", + ) + parser.add_argument("repository", nargs="?", type=Path, default=Path("."), help="the repository (default: current directory)") + parser.add_argument( + "--force", + action="store_true", + help="install: replace our hook, or move a foreign hook to pre-push.before-ousast and chain it; " + "uninstall: remove a hook this tool did not write", + ) + if action == "install": + parser.add_argument( + "--docker", + action="store_true", + help="also install the ousast-docker wrapper beside the hook, so the check runs in the container image", + ) + args = parser.parse_args(argv) + try: + code, lines = ( + install(args.repository, force=args.force, docker=args.docker) + if action == "install" + else uninstall(args.repository, force=args.force) + ) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f"ousast pre-push {action}: {error}", file=sys.stderr) + return 1 + for line in lines: + print(line, file=sys.stderr if code else sys.stdout) + return code + + def _advisory_hook(argv: list[str]) -> bool: """A Git hook invocation (`--remote NAME URL`, stdin input) that did not opt into blocking.""" if argv[:1] != ["pre-push"] or "--remote" not in argv: diff --git a/src/openultrasast/push/hook/ousast-docker b/src/openultrasast/push/hook/ousast-docker new file mode 100755 index 0000000..5010671 --- /dev/null +++ b/src/openultrasast/push/hook/ousast-docker @@ -0,0 +1,61 @@ +#!/bin/sh +# OpenUltraSAST in Docker, callable from a Git hook. A script, not a shell function: Git runs +# hooks non-interactively and never sees functions from ~/.bashrc. +# +# OUSAST_COMMAND=/path/to/ousast-docker git push # or: ousast pre-push install --docker +# +# The repository is mounted READ-ONLY at its own absolute path and the artifact directory +# read-write at its own path, so every path in the arguments and in the output ("Details: ...") +# means the same thing inside and outside the container. The push input on stdin is passed +# through (-i). The container runs as your user, with no network and --memory 3g. +# +# Advisory like the hook: when Docker or the image is missing, it says the check did not run +# and exits 0. Pull the image once first; a hook never downloads ~3 GB on its own: +# +# docker pull ghcr.io/norandom/openultrasast:2.0.1 +# +# --engine background does not work through this wrapper: the container ends with the hook. +image=${OUSAST_IMAGE:-ghcr.io/norandom/openultrasast:2.0.1} +if ! command -v docker >/dev/null 2>&1; then + echo "ousast-docker: check did not run: docker is not installed. Push continues." + exit 0 +fi +if ! docker image inspect "$image" >/dev/null 2>&1; then + echo "ousast-docker: check did not run: image $image is not present." \ + "Run 'docker pull $image' once. Push continues." + exit 0 +fi +repo=$(pwd -P) +artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push} +previous= +for argument in "$@"; do + if [ "$previous" = "--artifact" ]; then + artifact_dir=$(dirname -- "$argument") + fi + previous=$argument +done +mkdir -p -- "$artifact_dir" || exit 0 +artifact_dir=$(cd -- "$artifact_dir" && pwd -P) +# A linked worktree keeps its objects in the main repository's git directory: mount that too. +common=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo "$repo/.git") +case "$common" in + "$repo"/*) extra_mount= ;; + *) extra_mount="-v $common:$common:ro" ;; +esac +# shellcheck disable=SC2086 # OUSAST_DOCKER_ARGS and extra_mount are deliberately word-split +docker run --rm -i --network none --memory "${OUSAST_DOCKER_MEMORY:-3g}" \ + --user "$(id -u):$(id -g)" -e HOME=/tmp -e TMPDIR=/work --tmpfs /work:size=4g \ + -v "$repo:$repo:ro" -w "$repo" -v "$artifact_dir:$artifact_dir" $extra_mount \ + ${OUSAST_DOCKER_ARGS:-} "$image" "$@" +status=$? +if [ "$status" -ge 125 ]; then + echo "ousast-docker: check did not run: the container could not start (docker exit $status). Push continues." + exit 0 +fi +# Only an explicit blocking opt-in may turn the container's exit into a rejected push; an older +# image that rejects a newer argument must not block either. +if [ "$status" -ne 0 ] && [ "${OUSAST_PUSH_MODE:-advisory}" != blocking ]; then + echo "ousast-docker: the check exited with status $status; push continues (advisory)." + exit 0 +fi +exit "$status" diff --git a/src/openultrasast/push/hook/pre-push b/src/openultrasast/push/hook/pre-push new file mode 100755 index 0000000..e1146dc --- /dev/null +++ b/src/openultrasast/push/hook/pre-push @@ -0,0 +1,38 @@ +#!/bin/sh +# OpenUltraSAST pre-push safety net. Advisory: nothing here rejects a push unless +# OUSAST_PUSH_MODE=blocking is set explicitly. Install with `ousast pre-push install` +# (or ops/install-pre-push from a checkout); remove with `ousast pre-push uninstall`. +# A prior hook is chained only after it was moved to the sibling path "$0.before-ousast". +artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push} +prior="$0.before-ousast" +# The Docker wrapper installed beside this hook (`ousast pre-push install --docker`) is used +# unless OUSAST_COMMAND names another command; otherwise `ousast` from PATH. +if [ -z "${OUSAST_COMMAND:-}" ] && [ -x "$(dirname -- "$0")/ousast-docker" ]; then + OUSAST_COMMAND="$(dirname -- "$0")/ousast-docker" +fi +command=${OUSAST_COMMAND:-ousast} +if ! command -v "$command" >/dev/null 2>&1; then + echo "ousast: pre-push check did not run: '$command' is not on PATH." \ + "Install it with 'pip install openultrasast' or set OUSAST_COMMAND. Push continues." + if [ -e "$prior" ] || [ -L "$prior" ]; then + exec "$prior" "$@" + fi + exit 0 +fi +remote_name=$1 +remote_url=$2 +set -- pre-push . --artifact "$artifact_dir/push-$$.json" \ + --deadline "${OUSAST_PUSH_DEADLINE:-30}" \ + --mode "${OUSAST_PUSH_MODE:-advisory}" \ + --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" +# Optional settings are passed only when set, so an older ousast (or image) still runs. +if [ -n "${OUSAST_PUSH_ENGINE:-}" ]; then + set -- "$@" --engine "$OUSAST_PUSH_ENGINE" +fi +if [ -n "${OUSAST_COMPARISON_BASE:-}" ]; then + set -- "$@" --comparison-base "$OUSAST_COMPARISON_BASE" +fi +if [ -e "$prior" ] || [ -L "$prior" ]; then + set -- "$@" --prior-hook "$prior" +fi +exec "$command" "$@" --remote "$remote_name" "$remote_url" diff --git a/src/openultrasast/push/install.py b/src/openultrasast/push/install.py new file mode 100644 index 0000000..95f4670 --- /dev/null +++ b/src/openultrasast/push/install.py @@ -0,0 +1,117 @@ +"""`ousast pre-push install|uninstall`: the hook as package data (task 17.6, Requirements 7.4, 9.6). + +Works from a pip install: the hook script and the Docker wrapper ship inside the wheel. Git's +effective hook directory is resolved (including `core.hooksPath`) and never edited. An existing +hook is never overwritten silently: without `--force` installation refuses; with it the existing +hook moves to `pre-push.before-ousast` and is chained with the same input and arguments. +Uninstall removes only a hook this tool wrote and restores the chained hook. +""" + +from __future__ import annotations + +import os +import subprocess +from importlib import resources +from pathlib import Path + +PRIOR_SUFFIX = ".before-ousast" +# First lines this tool has written into an installed hook: the current one and the 2026-09 one. +_MARKERS = (b"# OpenUltraSAST pre-push safety net.", b"# Explicitly copy using the non-overwriting recipe in ops/README.md.") + + +def packaged(name: str) -> bytes: + """The shipped `pre-push` hook or `ousast-docker` wrapper.""" + return resources.files("openultrasast.push").joinpath("hook", name).read_bytes() + + +def hooks_dir(repository: Path) -> Path: + out = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "--path-format=absolute", "--git-path", "hooks"], + capture_output=True, + check=False, + timeout=30, + ) + if out.returncode != 0: + raise ValueError(f"{repository} is not a Git repository") + return Path(os.fsdecode(out.stdout.rstrip(b"\n"))) + + +def ours(path: Path) -> bool: + try: + head = path.read_bytes()[:400] + except OSError: + return False + return any(marker in head for marker in _MARKERS) + + +def _write(path: Path, data: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o755) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + os.fchmod(stream.fileno(), 0o755) + + +def _exists(path: Path) -> bool: + return path.exists() or path.is_symlink() + + +def install(repository: Path, *, force: bool = False, docker: bool = False) -> tuple[int, list[str]]: + hooks = hooks_dir(repository) + hooks.mkdir(parents=True, exist_ok=True) + hook = hooks / "pre-push" + prior = hooks / ("pre-push" + PRIOR_SUFFIX) + wrapper = hooks / "ousast-docker" + lines: list[str] = [] + if _exists(hook): + if not force: + what = "an OpenUltraSAST hook is already installed" if ours(hook) else "an existing pre-push hook" + return 1, [ + f"Installation refused: {what} at {hook}.", + "Rerun with --force to replace ours, or to move a foreign hook to pre-push.before-ousast and chain it.", + ] + if ours(hook): + hook.unlink() + lines.append(f"Replaced the OpenUltraSAST hook at {hook}.") + elif _exists(prior): + return 1, [f"Installation refused: {prior} already holds a chained hook; resolve it by hand first."] + else: + os.rename(hook, prior) + lines.append(f"Moved the existing hook to {prior}; it runs after the check with the same input.") + if docker: + if _exists(wrapper) and not (force and ours_wrapper(wrapper)): + return 1, [f"Installation refused: {wrapper} exists; rerun with --force to replace it."] + if _exists(wrapper): + wrapper.unlink() + _write(wrapper, packaged("ousast-docker")) + lines.append(f"Installed the Docker wrapper {wrapper} (image: ghcr.io/norandom/openultrasast:2.0.1; pull it once).") + _write(hook, packaged("pre-push")) + lines.insert(0, f"Installed {hook}") + lines.append("Advisory: the hook never blocks a push unless OUSAST_PUSH_MODE=blocking. Remove with 'ousast pre-push uninstall'.") + return 0, lines + + +def ours_wrapper(path: Path) -> bool: + try: + return b"OpenUltraSAST in Docker, callable from a Git hook" in path.read_bytes()[:400] + except OSError: + return False + + +def uninstall(repository: Path, *, force: bool = False) -> tuple[int, list[str]]: + hooks = hooks_dir(repository) + hook = hooks / "pre-push" + prior = hooks / ("pre-push" + PRIOR_SUFFIX) + wrapper = hooks / "ousast-docker" + if not _exists(hook): + return 1, [f"Nothing to remove: no pre-push hook at {hook}."] + if not ours(hook) and not force: + return 1, [f"Removal refused: {hook} was not written by OpenUltraSAST. Inspect it, or rerun with --force."] + hook.unlink() + lines = [f"Removed {hook}"] + if _exists(wrapper) and ours_wrapper(wrapper): + wrapper.unlink() + lines.append(f"Removed {wrapper}") + if _exists(prior): + os.rename(prior, hook) + lines.append(f"Restored the previous hook from {prior}.") + return 0, lines diff --git a/tests/test_push_explain.py b/tests/test_push_explain.py index d26686a..5d4b8a7 100644 --- a/tests/test_push_explain.py +++ b/tests/test_push_explain.py @@ -39,6 +39,19 @@ def test_audit_reasons_become_one_plain_line_each(): assert sum("could not be established" in line for line in lines) == 1 +def test_every_glossary_reason_is_documented_for_users(): + """17.6: the skip-reason glossary in docs/scanning.md names every reason the terminal explains.""" + from pathlib import Path + + from openultrasast.push.explain import _NOVELTY_REASONS, _RESOLUTION, GLOSSARY + + docs = (Path(__file__).resolve().parents[1] / "docs" / "scanning.md").read_text() + missing = [key for key in GLOSSARY if key not in docs] + missing += [f"resolution:{key}" for key in _RESOLUTION if f"`resolution:{key}`" not in docs] + assert not missing, missing + assert "base_incomplete" in docs and _NOVELTY_REASONS + + def test_unknown_reason_is_still_printed(): assert explain(("some_future_reason",)) == ["some future reason (see the details file)"] diff --git a/tests/test_push_install.py b/tests/test_push_install.py new file mode 100644 index 0000000..49c85b1 --- /dev/null +++ b/tests/test_push_install.py @@ -0,0 +1,125 @@ +"""`ousast pre-push install|uninstall` and the hook-callable Docker wrapper (task 17.6, Req 7.4, 9.6).""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest +from test_push_runner import history + +from openultrasast.cli import main +from openultrasast.push.install import install, packaged, uninstall + +OPS = Path(__file__).resolve().parents[1] / "ops" + + +def repo(tmp_path): + root, base, head, git = history(tmp_path) + git("config", "core.hooksPath", ".custom-hooks") + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) + git("remote", "add", "origin", str(remote)) + return root, git + + +def test_packaged_hook_and_wrapper_are_the_ops_scripts(): + assert packaged("pre-push") == (OPS / "pre-push").read_bytes() + assert packaged("ousast-docker") == (OPS / "ousast-docker").read_bytes() + + +def test_install_uninstall_through_the_cli_honours_hooks_path(tmp_path, capsys): + root, git = repo(tmp_path) + assert main(["pre-push", "install", str(root)]) == 0 + hook = root / ".custom-hooks" / "pre-push" + assert hook.read_bytes() == packaged("pre-push") and os.access(hook, os.X_OK) + assert git("config", "core.hooksPath") == ".custom-hooks" + assert main(["pre-push", "install", str(root)]) == 1 # already installed: refused without --force + assert "already installed" in capsys.readouterr().err + assert main(["pre-push", "install", str(root), "--force"]) == 0 + assert main(["pre-push", "uninstall", str(root)]) == 0 + assert not hook.exists() + + +def test_existing_hook_is_refused_then_chained_and_restored(tmp_path): + root, git = repo(tmp_path) + hooks = root / ".custom-hooks" + hooks.mkdir() + log = tmp_path / "prior-input" + original = f'#!/bin/sh\ncat > "{log}"\nexit 0\n'.encode() + (hooks / "pre-push").write_bytes(original) + (hooks / "pre-push").chmod(0o755) + code, lines = install(root) + assert code == 1 and "existing pre-push hook" in lines[0] and (hooks / "pre-push").read_bytes() == original + code, lines = install(root, force=True) + assert code == 0 and (hooks / "pre-push.before-ousast").read_bytes() == original + env = {**os.environ, "OUSAST_COMMAND": "ousast-not-installed-anywhere", "OUSAST_ARTIFACT_DIR": str(tmp_path / "a")} + pushed = subprocess.run(["git", "-C", str(root), "push", "origin", "HEAD:refs/heads/main"], env=env, capture_output=True) + assert pushed.returncode == 0 and "refs/heads/main" in log.read_text() + code, _ = uninstall(root) + assert code == 0 and (hooks / "pre-push").read_bytes() == original + assert not (hooks / "pre-push.before-ousast").exists() + code, lines = uninstall(root) + assert code == 1 and "not written by OpenUltraSAST" in lines[0] + + +def fake_docker(tmp_path, *, inspect=0, run=0): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + record = tmp_path / "docker-args" + stdin = tmp_path / "docker-stdin" + script = f'#!/bin/sh\nif [ "$1" = image ]; then exit {inspect}; fi\nprintf "%s\\n" "$@" > "{record}"\ncat > "{stdin}"\nexit {run}\n' + (bin_dir / "docker").write_text(script) + (bin_dir / "docker").chmod(0o755) + return bin_dir, record, stdin + + +def test_docker_install_routes_the_hook_through_the_wrapper_with_stdin_and_mounts(tmp_path): + root, git = repo(tmp_path) + assert install(root, docker=True)[0] == 0 + bin_dir, record, stdin = fake_docker(tmp_path) + artifacts = tmp_path / "artifacts" + env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "OUSAST_ARTIFACT_DIR": str(artifacts)} + env.pop("OUSAST_COMMAND", None) + pushed = subprocess.run(["git", "-C", str(root), "push", "origin", "HEAD:refs/heads/main"], env=env, capture_output=True) + assert pushed.returncode == 0, pushed.stderr + args = record.read_text().splitlines() + real = str(root.resolve()) + assert args[:2] == ["run", "--rm"] and "-i" in args and "--network" in args and "none" in args + assert f"{real}:{real}:ro" in args and str(artifacts.resolve()) + ":" + str(artifacts.resolve()) in args + assert args[args.index("-w") + 1] == real + assert "ghcr.io/norandom/openultrasast:2.0.1" in args and "pre-push" in args and "--remote" in args + assert stdin.read_text().startswith("HEAD ") and "refs/heads/main" in stdin.read_text() + + +@pytest.mark.parametrize(("inspect", "run", "needle"), [(1, 0, "is not present"), (0, 125, "could not start")]) +def test_wrapper_failures_never_block(tmp_path, inspect, run, needle): + bin_dir, _, _ = fake_docker(tmp_path, inspect=inspect, run=run) + env = {**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "OUSAST_ARTIFACT_DIR": str(tmp_path / "artifacts")} + result = subprocess.run( + [str(OPS / "ousast-docker"), "pre-push", ".", "--artifact", str(tmp_path / "x" / "r.json")], + env=env, + cwd=tmp_path, + input=b"", + capture_output=True, + ) + assert result.returncode == 0 and needle.encode() in result.stdout + + +@pytest.mark.parametrize(("mode", "status", "expected"), [("blocking", 1, 1), ("advisory", 1, 0), ("advisory", 2, 0)]) +def test_only_explicit_blocking_passes_a_nonzero_exit_through(tmp_path, mode, status, expected): + bin_dir, _, _ = fake_docker(tmp_path, run=status) + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "OUSAST_ARTIFACT_DIR": str(tmp_path / "artifacts"), + "OUSAST_PUSH_MODE": mode, + } + result = subprocess.run([str(OPS / "ousast-docker"), "pre-push", "."], env=env, cwd=tmp_path, input=b"", capture_output=True) + assert result.returncode == expected + + +def test_hook_passes_optional_settings_only_when_set(): + text = (OPS / "pre-push").read_text() + assert '--engine "$OUSAST_PUSH_ENGINE"' in text and 'if [ -n "${OUSAST_PUSH_ENGINE:-}" ]' in text