pre-push(17.6): install, Docker wrapper and first-run docs

`ousast pre-push install|uninstall [REPO] [--force] [--docker]` ships in the
wheel with the hook as package data; it honours core.hooksPath, refuses an
existing hook unless --force (which chains it as pre-push.before-ousast), and
uninstall restores the chained hook. ops/ousast-docker is a hook-callable
script: the repository read-only and the artifact directory at their own
paths, stdin passed through, no network, --memory 3g, the GHCR 2.0.1 image by
default, advisory exit unless blocking. Help text for every pre-push flag; a
first-run guide, settings, skip-reason glossary, CI recipe and full --help in
docs/scanning.md; README tiers 1 and 2 pull the published image. The image
was not rebuilt: 1.1 GB free, below the 4 GB threshold. Requirements 7.4, 9.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-10-02 22:51:22 +02:00
co-authored by Claude Opus 5.5
parent 66357ffea1
commit 591818d5fc
13 changed files with 840 additions and 36 deletions
+2 -1
View File
@@ -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. - 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 `<artifact dir>/engine/<repo key>/<head>.json`, and the next run prints "Engine result for <head> 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. - _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 `<artifact dir>/engine/<repo key>/<head>.json`, and the next run prints "Engine result for <head> 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_ - _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. - `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`. - 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. - 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_ - _Requirements: 9.6, 7.4_
- [ ] 17.7 Prove it on the audit's three repositories - [ ] 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). - 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).
+25 -7
View File
@@ -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, 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. 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: What it cannot do:
- There is no engine. Nothing follows a value across statements or files. - 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) ### Tier 2: standard and deep scans (Docker)
`standard` and `deep` need Joern. The Docker image `openultrasast:dev` ships three things in one `standard` and `deep` need Joern. The Docker image ships three things in one image: the tool,
image: the tool, Joern v4.0.625 and `php-cli` (the PHP frontend needs it). You build it locally Joern v4.0.625 and `php-cli` (the PHP frontend needs it). Each release publishes it to GHCR. It is
from the `Dockerfile`; there is no published image yet. It is big: a JRE plus about 2 GB of Joern. 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 ```bash
directory is mounted read-only and the container network is off. 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 ```bash
source /path/to/OpenUltraSAST/ops/shell/ousast.sh # PowerShell: ops/shell/ousast.ps1 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 `<target>/.openultrasast/runs
and `trace/events.jsonl`. and `trace/events.jsonl`.
`ousast pre-push` (experimental) analyses the commits a push would publish against their base. `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 First, the quick rules run on the changed lines. Then the engine compares head with base, and only
is empty, so it emits no normal alert today. 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). Flows and the per-language rule table: [docs/scanning.md](docs/scanning.md).
@@ -1,7 +1,7 @@
{ {
"modules": 154, "modules": 155,
"counts": { "counts": {
"load_bearing": 149, "load_bearing": 150,
"standalone_capability": 5 "standalone_capability": 5
}, },
"orphaned": [], "orphaned": [],
@@ -1262,6 +1262,14 @@
"push.runner" "push.runner"
] ]
}, },
{
"module": "push.install",
"classification": "load_bearing",
"reason": "reachable from gate, map_gate, pair_gate, cli",
"importers": [
"cli"
]
},
{ {
"module": "push.policy", "module": "push.policy",
"classification": "load_bearing", "classification": "load_bearing",
+274 -9
View File
@@ -131,23 +131,288 @@ candidates. Sources: `ousast pre-push --help`, `src/openultrasast/push/`,
flowchart TD flowchart TD
input["git push refs on stdin, or --base / --head"] --> resolve["Resolve both commit identities"] input["git push refs on stdin, or --base / --head"] --> resolve["Resolve both commit identities"]
resolve --> snap["Materialise tracked bytes in private scratch storage"] resolve --> snap["Materialise tracked bytes in private scratch storage"]
snap --> regions["Discover changed regions, up to --max-regions"] snap --> quick["Quick rules on the changed head lines (advisory, under 1 s)"]
regions --> engine["Evidence ranker and arbiter on head"] 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"] engine --> cmp["Targeted base comparison: new or worsened only"]
cmp --> admit{"Admission policy: capability registry"} 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"] 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 alert --> art
art --> exit{"--mode"} art --> exit{"--mode"}
exit -- "advisory, the default" --> allow["Exit 0: allow"] exit -- "advisory, the default" --> allow["Exit 0: allow"]
exit -- "blocking, explicit opt-in" --> block["May block the push"] exit -- "blocking, explicit opt-in" --> block["May block the push"]
``` ```
- `--deadline` (30 s by default) bounds preparation, both revisions and reporting together. - `--deadline` (30 s by default) bounds preparation, the quick rules, both revisions and reporting together.
- `--incomplete-coverage block` opts into failing when coverage is incomplete. - 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 - 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`. - A model is called only with an explicit `--model-config`.
- Hook installation (`ops/install-pre-push`) and the private cache (`--cache-dir`) are documented - The private cache (`--cache-dir`) and the manual install recipe are documented in
in [Engine and pre-push](ops/README.md#experimental-pre-push-integration). [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 <head> 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:<lang>:<changed>:<total>` | language not covered: `<lang>` (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 |
| `<path>:ambiguous_line_correspondence`, `<path>:line_correspondence_unavailable`, `<path>: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:<reason>` | 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:<reason>` | some files could not be materialised for analysis | see the reason (symlinks, submodules, LFS) |
| `<stage>_failed:<Error>`, `push_input_failed:<Error>` | 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
```
+9 -2
View File
@@ -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; this interface does not make the project rollout-ready. Advisory is the default;
blocking and strict incomplete-coverage handling are separate explicit choices. 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 The supported install is `ousast pre-push install [REPO] [--force] [--docker]`, shipped in
checkout, explicitly install into the desired repository: 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 ```sh
ops/install-pre-push /absolute/path/to/repository 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`), The installer resolves Git's effective hook directory (including `core.hooksPath`),
leaves that configuration untouched, and refuses to overwrite any existing hook. leaves that configuration untouched, and refuses to overwrite any existing hook.
For an existing hook, inspect it and explicitly move it aside **before** installation: For an existing hook, inspect it and explicitly move it aside **before** installation:
+61
View File
@@ -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"
+10 -2
View File
@@ -5,6 +5,11 @@
# A prior hook is chained only after it was moved to the sibling path "$0.before-ousast". # 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} artifact_dir=${OUSAST_ARTIFACT_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/openultrasast/push}
prior="$0.before-ousast" 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} command=${OUSAST_COMMAND:-ousast}
if ! command -v "$command" >/dev/null 2>&1; then if ! command -v "$command" >/dev/null 2>&1; then
echo "ousast: pre-push check did not run: '$command' is not on PATH." \ 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" \ set -- pre-push . --artifact "$artifact_dir/push-$$.json" \
--deadline "${OUSAST_PUSH_DEADLINE:-30}" \ --deadline "${OUSAST_PUSH_DEADLINE:-30}" \
--mode "${OUSAST_PUSH_MODE:-advisory}" \ --mode "${OUSAST_PUSH_MODE:-advisory}" \
--incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \ --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}"
--engine "${OUSAST_PUSH_ENGINE:-inline}" # 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 if [ -n "${OUSAST_COMPARISON_BASE:-}" ]; then
set -- "$@" --comparison-base "$OUSAST_COMPARISON_BASE" set -- "$@" --comparison-base "$OUSAST_COMPARISON_BASE"
fi fi
+95 -13
View File
@@ -132,28 +132,71 @@ def main(argv: list[str] | None = None) -> int:
def _main(argv: list[str] | 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() load_dotenv()
parser = argparse.ArgumentParser(prog="ousast") parser = argparse.ArgumentParser(prog="ousast")
subparsers = parser.add_subparsers(dest="command", required=True) 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 = subparsers.add_parser(
replay_parser.add_argument("path", type=Path) "pre-push",
replay_parser.add_argument("--base") help="check Git push input or replay local revisions (experimental, advisory)",
replay_parser.add_argument("--head") description="Check what a push changes: quick rules on the changed lines, then the Joern engine on head and base "
replay_parser.add_argument("--remote", nargs=2, metavar=("NAME", "URL")) "within one deadline. Advisory: exit 0 unless --mode blocking. Install the Git hook with "
replay_parser.add_argument("--comparison-base") "'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("--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("--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( replay_parser.add_argument(
"--cache-dir", type=Path, help="reuse compatible local artifacts in a private directory outside the repository" "--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(
replay_parser.add_argument("--cancellation-allowance", type=float, default=2.0) "--deadline",
replay_parser.add_argument("--max-regions", type=int, default=500) type=float,
replay_parser.add_argument("--mode", choices=("advisory", "blocking"), default="advisory") default=30.0,
replay_parser.add_argument("--incomplete-coverage", choices=("allow", "block"), default="allow") 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( replay_parser.add_argument(
"--engine", "--engine",
choices=("inline", "background", "off"), choices=("inline", "background", "off"),
@@ -421,6 +464,45 @@ def _main(argv: list[str] | None) -> int:
return 2 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: def _advisory_hook(argv: list[str]) -> bool:
"""A Git hook invocation (`--remote NAME URL`, stdin input) that did not opt into blocking.""" """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: if argv[:1] != ["pre-push"] or "--remote" not in argv:
+61
View File
@@ -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"
+38
View File
@@ -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"
+117
View File
@@ -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
+13
View File
@@ -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 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(): def test_unknown_reason_is_still_printed():
assert explain(("some_future_reason",)) == ["some future reason (see the details file)"] assert explain(("some_future_reason",)) == ["some future reason (see the details file)"]
+125
View File
@@ -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