diff --git a/.github/workflows/repo-sentinel-gate.yml b/.github/workflows/repo-sentinel-gate.yml new file mode 100644 index 0000000..534230e --- /dev/null +++ b/.github/workflows/repo-sentinel-gate.yml @@ -0,0 +1,173 @@ +--- +name: Repo Sentinel Gate + +'on': + pull_request: + +permissions: + contents: read + +jobs: + changed-file-gate: + name: Changed-file error gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pinned scanner + run: | + python -m pip install --upgrade pip + python -m pip install repo-sentinel-lite==0.8.1 + + - name: Reject changed security policy files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + mapfile -d '' policy_files < <( + git diff --name-only --diff-filter=ACMRTD --no-renames -z \ + "$BASE_SHA" "$HEAD_SHA" + ) + for path in "${policy_files[@]}"; do + case "$path" in + .reposentinel.toml|.reposentinel-baseline.json) + echo "Protected security policy changed: $path" + echo "Require dedicated policy review before" + echo "this pull request can merge." + exit 1 + ;; + esac + done + + - name: Scan changed files + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + mapfile -d '' changed_files < <( + git diff --name-only --diff-filter=ACMRT --no-renames -z \ + "$BASE_SHA" "$HEAD_SHA" + ) + printf 'Changed files: %s\n' "${#changed_files[@]}" + if ((${#changed_files[@]} == 0)); then + echo "No changed files; the changed-file gate has nothing to scan." + exit 0 + fi + + trusted_baseline="$(mktemp)" + cleanup() { + rm -f "$trusted_baseline" + } + trap cleanup EXIT + + baseline_args=(--no-default-baseline) + if git cat-file -e \ + "${BASE_SHA}:.reposentinel-baseline.json" 2>/dev/null; then + git show \ + "${BASE_SHA}:.reposentinel-baseline.json" \ + > "$trusted_baseline" + baseline_args=(--baseline "$trusted_baseline") + echo "Using .reposentinel-baseline.json from BASE_SHA." + else + echo "No baseline at BASE_SHA;" + echo "PR-head default baseline is disabled." + fi + + set +e + python -m repo_sentinel scan \ + "${baseline_args[@]}" \ + --changed-files \ + --fail-on-severity error \ + --format text \ + --output repo-sentinel-changed.txt \ + . \ + -- \ + "${changed_files[@]}" + status=$? + set -e + + if [[ -f repo-sentinel-changed.txt ]]; then + cat repo-sentinel-changed.txt + fi + if ((status != 0)); then + echo "Changed-file error findings block this pull request." + exit "$status" + fi + echo "Changed-file error gate passed; warnings and coverage" + echo "skips are report-only." + + - name: Upload changed-file report + if: always() + uses: actions/upload-artifact@v4 + with: + name: repo-sentinel-changed-file-report + path: repo-sentinel-changed.txt + if-no-files-found: ignore + retention-days: 14 + + synthetic-contract: + name: Synthetic PASS/FAIL/redaction contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pinned scanner + run: | + python -m pip install --upgrade pip + python -m pip install repo-sentinel-lite==0.8.1 + + - name: Run synthetic contract + run: python scripts/test_repo_sentinel_integration.py + + baseline-audit: + name: Baseline audit (non-blocking) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pinned scanner + run: | + python -m pip install --upgrade pip + python -m pip install repo-sentinel-lite==0.8.1 + + - name: Audit baseline drift + shell: bash + run: | + if [[ ! -f .reposentinel-baseline.json ]]; then + { + echo "No .reposentinel-baseline.json;" + echo "baseline audit is not configured." + } | tee repo-sentinel-baseline-audit.txt + exit 0 + fi + python -m repo_sentinel baseline audit --format text . \ + | tee repo-sentinel-baseline-audit.txt + + - name: Upload baseline audit + if: always() + uses: actions/upload-artifact@v4 + with: + name: repo-sentinel-baseline-audit + path: repo-sentinel-baseline-audit.txt + if-no-files-found: ignore + retention-days: 14 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f25f4f..623db26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: hooks: - id: yamllint - repo: https://github.com/stacknil/repo-sentinel-lite - rev: v0.6.1 + rev: v0.8.1 hooks: - id: repo-sentinel-error stages: diff --git a/docs/repo-sentinel-baseline-review.md b/docs/repo-sentinel-baseline-review.md index d128e96..61a53f8 100644 --- a/docs/repo-sentinel-baseline-review.md +++ b/docs/repo-sentinel-baseline-review.md @@ -3,21 +3,22 @@ ## Status This is a classification record for the current consumer baseline. The -committed `.reposentinel-baseline.json` is unchanged, and no remote -`repo-sentinel` gate is enabled by this review. +committed `.reposentinel-baseline.json` is unchanged, and the consumer now +pins the reviewed `repo-sentinel-lite v0.8.1` release. The review keeps raw token values out of repository history, issues, and reviewer-facing output. ## Audit Scope -The consumer snapshot is `sec-writeups-public` `main` at `9a18c74`. The +The pre-integration consumer snapshot is `sec-writeups-public` `main` at +`7db916e`. The baseline is schema version `1`, generated at `2026-04-02T18:57:41Z`, and contains 306 entries across 127 files. -The candidate audit used `repo-sentinel-lite` commit `8a6e064` from the -v0.8 development line. It is recorded as an immutable audit input, not as a -released dependency or a claim that the remote gate is ready. +The formal consumer integration uses the production PyPI package +`repo-sentinel-lite==0.8.1`. It is intentionally not pinned to the provider +repository's development branch. Reproduction command: @@ -28,7 +29,11 @@ repo-sentinel baseline audit \ . ``` -## Classification +## Historical v0.8 Development Classification + +The following classification is retained as historical evidence from the +development-line audit. It is not the canonical consumer result for the +published `v0.8.1` integration. | Audit class | Count | Classification | Decision | | --- | ---: | --- | --- | @@ -66,22 +71,25 @@ The baseline change was targeted: it removed only the stale `repo.required_file_missing` entry for `LICENSE`. No unrelated suppression was regenerated. -A clean-worktree local validation used the installed `repo-sentinel 0.8.0` -CLI against that exact consumer commit. This is pre-release/local evidence, -not a formal remote dependency pin or a claim that the changed-file gate is -ready. +The targeted license resolution is retained as historical governance +evidence. The canonical current audit is recorded below against the pinned +production release. + +## v0.8.1 Integration Audit + +The exact pinned audit was rerun with `repo-sentinel-lite==0.8.1` after adding +the consumer gate and synthetic contract. The redacted output contained no +raw token values. Reproduction command: ```bash -repo-sentinel baseline audit \ +python -m repo_sentinel baseline audit \ --format json \ --baseline .reposentinel-baseline.json \ . ``` -Evidence summary: - | Result | Count | | --- | ---: | | Active `secret.high_entropy` | 272 | @@ -89,37 +97,35 @@ Evidence summary: | Changed | 0 | | Ambiguous | 26 | | Stale | 0 | -| Unmatched | 2,567 | +| Unmatched | 150 | | Active `repo.required_file_missing` | 0 | -The redacted audit artifact SHA-256 is -`ce5e84b0bff3c8825c51141a9278612837ae7cbf80baaafd52b1c1a71167fe9a`. +The exact consumer SHA, scanner version, command, artifact SHA-256, and remote +workflow results are kept together in the issue #5 closure record. ## Governance Decision -The current baseline is useful as a reviewed suppression record, but it is not -ready to become a blocking remote gate yet. +The current baseline remains a reviewed suppression record. Baseline drift is +reported by a non-blocking audit job, while the changed-file error gate is now +blocking for pull requests. 1. The missing `LICENSE` decision is resolved. Keep the targeted baseline cleanup and do not regenerate unrelated suppressions. 2. Keep baseline audit output non-blocking. The changed-file policy should fail on new error findings while baseline drift remains an independent review signal. -3. Consume a reviewed `repo-sentinel` release or pin a reviewed immutable - commit before enabling the remote job. -4. Add the synthetic pass/fail/redaction integration test in the consumer - workflow before making the check required. +3. Pin the reviewed production release `repo-sentinel-lite==0.8.1`; do not use + the provider's development branch as consumer proof. +4. Keep the synthetic pass/fail/redaction integration test in the consumer + workflow as a release contract. 5. Preserve the rollback path: remove the remote job while retaining the local pre-push hook. ## Relationship To Issue #5 This record advances [issue #5](https://github.com/stacknil/sec-writeups-public/issues/5) -without claiming that the acceptance criteria are complete. The historical -issue snapshot and this v0.8 candidate audit are not directly comparable: -scanner rule coverage and baseline identity semantics changed between the two -runs. Future comparisons should always record the exact `repo-sentinel` -release or commit used for the audit. The license condition is now resolved; -issue #5 remains open pending the formal v0.8.0 release, exact pinned consumer -audit, synthetic pass/fail/redaction tests, and changed-file remote-gate -rollout while baseline audit remains non-blocking. +without treating the historical development-line counts as current consumer +evidence. Future comparisons should always record the exact `repo-sentinel` +release or commit used for the audit. The license condition is resolved; issue +#5 can close after the exact v0.8.1 consumer audit, remote synthetic contract, +and changed-file gate have passed while baseline audit remains non-blocking. diff --git a/scripts/test_repo_sentinel_integration.py b/scripts/test_repo_sentinel_integration.py new file mode 100644 index 0000000..893e994 --- /dev/null +++ b/scripts/test_repo_sentinel_integration.py @@ -0,0 +1,86 @@ +"""Exercise the published repo-sentinel consumer contract with synthetic data.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import subprocess +import sys +import tempfile +from pathlib import Path + + +def _run_scan(repository: Path, report_path: Path, *extra: str) -> tuple[int, dict[str, object]]: + command = [ + sys.executable, + "-m", + "repo_sentinel", + "scan", + "--no-default-baseline", + "--format", + "json", + "--output", + str(report_path), + *extra, + str(repository), + ] + result = subprocess.run(command, check=False, capture_output=True, text=True) + report = json.loads(report_path.read_text(encoding="utf-8")) + return result.returncode, report + + +def _write_minimum_repository(repository: Path) -> None: + (repository / "README.md").write_text("Synthetic repository fixture.\n", encoding="utf-8") + (repository / "LICENSE").write_text("CC BY 4.0\n", encoding="utf-8") + (repository / ".gitignore").write_text("\n", encoding="utf-8") + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="repo-sentinel-contract-") as temporary: + repository = Path(temporary) + _write_minimum_repository(repository) + + pass_status, pass_report = _run_scan( + repository, + repository / "pass.json", + "--fail-on-severity", + "error", + ) + if pass_status != 0 or pass_report["findings"]: + raise AssertionError("synthetic PASS fixture produced an unexpected finding") + print("synthetic PASS: passed") + + token = base64.urlsafe_b64encode( + hashlib.sha256(b"repo-sentinel synthetic fixture").digest() + ).decode("ascii") + (repository / "synthetic.txt").write_text( + f"SYNTHETIC_VALUE={token}\n", + encoding="utf-8", + ) + + fail_status, fail_report = _run_scan( + repository, + repository / "fail.json", + "--fail-on-severity", + "error", + ) + findings = fail_report["findings"] + if fail_status == 0 or not findings: + raise AssertionError("synthetic FAIL fixture did not fail") + print("synthetic FAIL: passed") + + serialized = json.dumps(fail_report, sort_keys=True) + if token in serialized: + raise AssertionError("redaction contract exposed the synthetic token") + first_finding = findings[0] + redacted_token = first_finding["token"] + if not isinstance(redacted_token, str) or not redacted_token.startswith( + "