Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions .github/workflows/repo-sentinel-gate.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
68 changes: 37 additions & 31 deletions docs/repo-sentinel-baseline-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 |
| --- | ---: | --- | --- |
Expand Down Expand Up @@ -66,60 +71,61 @@ 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 |
| Relocated | 7 |
| 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.
86 changes: 86 additions & 0 deletions scripts/test_repo_sentinel_integration.py
Original file line number Diff line number Diff line change
@@ -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(
"<redacted:sha256:"
):
raise AssertionError("redaction contract did not emit a redacted token")
print("redaction: passed")


if __name__ == "__main__":
main()
Loading