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
15 changes: 12 additions & 3 deletions .github/workflows/gitignore-baseline-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
# - scripts/lib/gitignore-baseline.sh
# - scripts/sync-gitignore-baseline.sh
# - scripts/seed-repo-template.sh
# - scripts/compliance-remediate.sh
# - test/scripts/lib/gitignore-baseline.bats
# - test/scripts/sync-gitignore-baseline/**
# - test/scripts/compliance-remediate/**
# - tests/test_seed_repo_template_gitignore.bats
#
# Gates (all must pass before merge):
# 1. shellcheck — static analysis of the lib + sync script + seed script
# 2. bats — unit tests for baseline extraction/upsert, sync, and seed behaviors
# 1. shellcheck — static analysis of the lib + sync + seed + remediate scripts
# 2. bats — unit tests for baseline extraction/upsert, sync, seed, and
# the compliance-remediate gitignore-baseline hook

name: Gitignore Baseline Tests

Expand All @@ -20,8 +23,10 @@ on:
- 'scripts/lib/gitignore-baseline.sh'
- 'scripts/sync-gitignore-baseline.sh'
- 'scripts/seed-repo-template.sh'
- 'scripts/compliance-remediate.sh'
- 'test/scripts/lib/gitignore-baseline.bats'
- 'test/scripts/sync-gitignore-baseline/**'
- 'test/scripts/compliance-remediate/**'
- 'tests/test_seed_repo_template_gitignore.bats'
- '.github/workflows/gitignore-baseline-tests.yml'
push:
Expand All @@ -31,8 +36,10 @@ on:
- 'scripts/lib/gitignore-baseline.sh'
- 'scripts/sync-gitignore-baseline.sh'
- 'scripts/seed-repo-template.sh'
- 'scripts/compliance-remediate.sh'
- 'test/scripts/lib/gitignore-baseline.bats'
- 'test/scripts/sync-gitignore-baseline/**'
- 'test/scripts/compliance-remediate/**'
- 'tests/test_seed_repo_template_gitignore.bats'
- '.github/workflows/gitignore-baseline-tests.yml'

Expand Down Expand Up @@ -66,11 +73,13 @@ jobs:
shellcheck --severity=warning -x \
scripts/lib/gitignore-baseline.sh \
scripts/sync-gitignore-baseline.sh \
scripts/seed-repo-template.sh
scripts/seed-repo-template.sh \
scripts/compliance-remediate.sh

- name: Run bats suite
run: |
bats --print-output-on-failure \
test/scripts/lib/gitignore-baseline.bats \
test/scripts/sync-gitignore-baseline/ \
test/scripts/compliance-remediate/ \
tests/test_seed_repo_template_gitignore.bats
131 changes: 129 additions & 2 deletions scripts/compliance-remediate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/gitignore-baseline.sh
source "$SCRIPT_DIR/lib/gitignore-baseline.sh"
# shellcheck source=scripts/lib/standards-deploy.sh
source "$SCRIPT_DIR/lib/standards-deploy.sh"

ORG="petry-projects"
FINDINGS_FILE="${FINDINGS_FILE:-}"
REPORT_DIR="${REPORT_DIR:-$(mktemp -d)}"
DRY_RUN="${DRY_RUN:-false}"
Comment thread
don-petry marked this conversation as resolved.

# Canonical source of the marker-wrapped org secrets baseline (L1). Overridable
# as a test seam, matching scripts/sync-gitignore-baseline.sh.
GITIGNORE_CANONICAL="${GITIGNORE_CANONICAL:-$(cd "$SCRIPT_DIR/.." && pwd)/.gitignore}"

REMEDIATION_REPORT="$REPORT_DIR/remediation-report.md"
SKIPPED_REPORT="$REPORT_DIR/skipped.md"

Expand Down Expand Up @@ -629,6 +639,124 @@ Ref: \`standards/ci-standards.md#action-pinning-policy\`
fi
}

# ---------------------------------------------------------------------------
# PR-based: place or refresh the org secrets baseline (L1) in .gitignore
#
# Uses the shared, idempotent upsert_gitignore_baseline() (scripts/lib/gitignore-
# baseline.sh) so a repo's per-repo L2 — every line below the END marker — is
# never touched. Ships the fix via sd_deploy_via_pr(): a PR, never a direct
# default-branch push (rejected by ruleset-protected repos). Idempotency is
# keyed on the `gitignore-baseline` label and shared with sync-gitignore-baseline.sh,
# so an already-open baseline PR (from either tool) is reused, not duplicated.
# ---------------------------------------------------------------------------
remediate_gitignore_baseline() {
local repo="$1" check="$2"

info "Remediating .gitignore secrets baseline for $ORG/$repo (finding: $check) ..."

# Extract the canonical marker-wrapped L1 block from the org /.gitignore. A
# missing/half-open block is a hard error — never ship a partial baseline.
local block_file
block_file="$(mktemp)"
Comment thread
don-petry marked this conversation as resolved.
if ! gib_extract_baseline_block "$GITIGNORE_CANONICAL" > "$block_file" 2>/dev/null; then
rm -f "$block_file"
err "Canonical baseline block unreadable at $GITIGNORE_CANONICAL"
report_fail "$repo" "$check" "canonical secrets-baseline block unreadable at $GITIGNORE_CANONICAL"
return 0
fi

# Fetch the repo's current .gitignore. A genuine 404 (file absent) is
# fine — continue with an empty file. Any other failure (auth, rate-limit,
# 5xx) means we cannot know what L2 content the file holds; fail fast rather
# than silently replacing it with a baseline-only file.
local existing_file api_out api_err
existing_file="$(mktemp)"
api_out="$(mktemp)"
api_err="$(mktemp)"
if gh api "repos/$ORG/$repo/contents/.gitignore" > "$api_out" 2>"$api_err"; then
jq -r '.content // empty' "$api_out" 2>/dev/null | b64_decode > "$existing_file" 2>/dev/null || true
rm -f "$api_out" "$api_err"
elif grep -qiF 'HTTP 404' "$api_err" 2>/dev/null || grep -qiF 'Not Found' "$api_err" 2>/dev/null; then
rm -f "$api_out" "$api_err"
else
local api_err_msg; api_err_msg="$(cat "$api_err")"
rm -f "$block_file" "$existing_file" "$api_out" "$api_err"
err "gh api failed fetching .gitignore for $ORG/$repo: $api_err_msg"
report_fail "$repo" "$check" "gh api error fetching .gitignore (auth/rate-limit/server error)"
return 0
fi

# Upsert: place/refresh the L1 block on top, preserving L2 below END verbatim.
# Compare as command-substituted strings (trailing-newline tolerant), matching
# sync-gitignore-baseline.sh's sync_repo().
local existing upserted
existing="$(cat "$existing_file")"
if ! upserted="$(upsert_gitignore_baseline "$block_file" "$existing_file" 2>/dev/null)"; then
rm -f "$block_file" "$existing_file"
err "upsert_gitignore_baseline failed for $ORG/$repo"
report_fail "$repo" "$check" "upsert failed (half-open markers in the repo's .gitignore?)"
return 0
fi

# Idempotency: identical content means the repo already carries the baseline.
if [ "$existing" = "$upserted" ]; then
rm -f "$block_file" "$existing_file"
info "$ORG/$repo already carries the current baseline — no-op"
report_skip "$repo" "$check" "\`.gitignore\` already carries the current org secrets-baseline block (no-op)"
return 0
fi

if [ "$DRY_RUN" = "true" ]; then
rm -f "$block_file" "$existing_file"
skip "[DRY] Would open .gitignore baseline PR in $ORG/$repo"
report_pr "$repo" "$check" "DRY: would open PR upserting the org secrets baseline into \`.gitignore\`"
return 0
fi

# Materialize the upserted content for the PR primitive.
local upserted_file
upserted_file="$(mktemp)"
Comment thread
don-petry marked this conversation as resolved.
printf '%s\n' "$upserted" > "$upserted_file"
rm -f "$block_file" "$existing_file"

local branch title body outcome
branch="gitignore-baseline/$(date -u +%Y%m%d)"
title="fix: sync .gitignore org secrets baseline (compliance remediation)"
body="## Summary

Upserts the marker-wrapped **org secrets baseline (L1)** into \`.gitignore\`, preserving this repo's per-repo L2 entries below the END marker.

**Finding resolved:** \`$check\`

## Why

The baseline is the first layer of the push-protection program — it keeps credential files (\`.env\`, cloud credentials, \`*.tfvars\`, kubeconfigs, key material, …) out of the working tree before any scanning layer runs. It must be present and byte-for-byte identical across every repo.

Ref: \`standards/gitignore-standard.md\`

---
*Auto-generated by \`scripts/compliance-remediate.sh\` to resolve \`gitignore_baseline\`.*"

outcome="$(sd_deploy_via_pr "$ORG/$repo" ".gitignore" "$upserted_file" \
"$branch" "gitignore-baseline" "$title" "$body")" || true
rm -f "$upserted_file"

case "$outcome" in
"OPENED "*)
ok "Opened .gitignore baseline PR in $ORG/$repo: ${outcome#OPENED }"
report_pr "$repo" "$check" "${outcome#OPENED }" ;;
"SKIP_PR_OPEN "*)
skip "$ORG/$repo — baseline PR #${outcome#SKIP_PR_OPEN } already open"
report_skip "$repo" "$check" "a \`.gitignore\` baseline PR (#${outcome#SKIP_PR_OPEN }) is already open" ;;
"FAILED "*)
err "$ORG/$repo — ${outcome#FAILED }"
report_fail "$repo" "$check" "sd_deploy_via_pr: ${outcome#FAILED }" ;;
*)
err "$ORG/$repo — unexpected deploy outcome: ${outcome:-<none>}"
report_fail "$repo" "$check" "unexpected sd_deploy_via_pr outcome: ${outcome:-<none>}" ;;
esac
}

# ---------------------------------------------------------------------------
# Dispatch a single finding to the correct remediation handler
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -734,8 +862,7 @@ remediate_finding() {
"Adding the \`secret-scan\` gitleaks job to \`.github/workflows/ci.yml\` requires \`workflow\` token scope — copy the CI secret-scan job from standards/push-protection.md using a PAT"
;;
push-protection/gitignore_baseline)
report_skip "$repo" "$check" \
"\`.gitignore\` secrets-baseline block is missing or drifted — copy the \`BEGIN … END petry-projects secrets baseline\` block verbatim from the org \`/.gitignore\` into the repo's root \`.gitignore\` (do not edit inside the markers; L2 entries stay below END)"
remediate_gitignore_baseline "$repo" "$check"
;;
push-protection/*)
report_skip "$repo" "$check" \
Expand Down
Loading
Loading