From e99abc5f36f650d82949218e621bcc35396cacae Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 12 May 2026 13:33:36 +0100 Subject: [PATCH 1/4] fix(action): add audit-only mode to prevent install overwriting tamper (closes #1202) From 8dd6b8fbd35dfa28672b42aa38e83fb5d5f3b782 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 12 May 2026 13:47:42 +0100 Subject: [PATCH 2/4] fix(ci): use audit-only pattern to prevent install overwriting tamper (closes #1202) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 49 +++++++++---------- CHANGELOG.md | 1 + .../docs/enterprise/drift-detection.md | 34 +++++++++++++ .../content/docs/enterprise/enforce-in-ci.md | 48 ++++++++++++++++++ docs/src/content/docs/integrations/ci-cd.md | 21 +++++++- 5 files changed, 127 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac09a0268..4e8c83b69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,10 +127,12 @@ jobs: retention-days: 30 if-no-files-found: error - # Dogfood the two CI gates we ship and document to users: - # - Gate A (consumer-side): `apm audit --ci` -- lockfile / install fidelity. - # - Gate B (producer-side): regeneration drift -- did someone hand-edit - # a regenerated file under .github/ without updating canonical .apm/? + # Dogfood the audit-only CI gate we ship and document to users: + # - Gate A (consumer-side): `apm audit --ci --no-drift` -- lockfile / + # content-integrity check. setup-only: true keeps managed files + # untouched so the SHA-256 content-integrity check can detect tampered + # files. The install-replay (--no-drift) is skipped because there is + # no warm cache; content-integrity covers the same tamper signal. # See microsoft/apm#883 for context. Tier 1 (no secrets needed). apm-self-check: name: APM Self-Check @@ -140,32 +142,29 @@ jobs: steps: - uses: actions/checkout@v4 - # Installs the APM CLI (latest stable) and runs `apm install` against - # this repo's apm.yml. Auto-detects target from the existing .github/ - # directory and re-integrates local .apm/ content, regenerating - # .github/instructions/, .github/agents/, .github/skills/, etc. - # Adds `apm` to PATH for subsequent steps. + # Installs the APM CLI (latest stable) and adds `apm` to PATH. + # setup-only: true skips `apm install` so managed files on disk are + # not overwritten before the audit runs. This preserves the committed + # state so content-integrity can detect any tampered file hashes. - uses: microsoft/apm-action@v1 + with: + setup-only: true # Gate A: lockfile / install fidelity (consumer-side). # Verifies every file in lockfile.deployed_files exists, ref consistency # between apm.yml and apm.lock.yaml, no orphan packages, and - # content-integrity (hidden Unicode) on deployed package content. - # Does NOT verify deployed-file content vs lockfile (see #684). - - name: apm audit --ci - run: apm audit --ci - - # Gate B: regeneration drift (producer-side). - # NOTE: Once `apm-action` ships a CLI version that includes the - # default-on `apm audit` drift detection (issue #1071), this entire - # step becomes redundant -- Gate A above already catches the same - # divergence via install-replay. Keep this bash check until then as - # a defense-in-depth fallback. - # - # The action's `apm install` step re-integrated local .apm/ into - # .github/ via target auto-detection. If anything in the governed - # integration directories changed, someone edited the regenerated - # output without updating the canonical .apm/ source. + # content-integrity (SHA-256 hashes against deployed_file_hashes in the + # lockfile) on deployed package content. --no-drift skips the + # install-replay because there is no warm cache (setup-only did not + # run apm install); content-integrity still catches tampered files. + - name: apm audit --ci --no-drift + run: apm audit --ci --no-drift + + # Gate B: regeneration drift (producer-side) -- legacy bash fallback. + # With setup-only: true, apm install did not run and the working tree + # is unchanged, so this check trivially passes. It is kept so the + # pattern is visible; a full install+audit workflow would rely on this + # step to detect hand-edits to regenerated .github/ files. - name: Check APM integration drift (legacy bash fallback, see #1071) run: | if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index f16280684..5fe127d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- CI self-check job now uses `setup-only: true` + `apm audit --ci --no-drift` so managed files are not overwritten by `apm install` before `content-integrity` runs; documented the audit-only CI pattern and the install-before-audit blind spot in the enterprise and CI/CD guides. (#1291) - Pin `Path.home()` under unit tests via a session-scoped autouse conftest fixture, fixing 56 Windows runner failures on the new `windows-2025-vs2026` GitHub-hosted image where `USERPROFILE`/`HOMEDRIVE`+`HOMEPATH` are not seeded for pytest workers; also patch the `_check_and_notify_updates` import binding in the disabled-self-update test so it no longer races on the version-check cache. (#1270) - `apm install` now works on macOS git 2.53.0 (Homebrew): bare-cache commands switch to `--git-dir` to satisfy the `safe.bareRepository=explicit` default; fetched SHAs are pinned as synthetic refs so `git clone --local --shared` no longer silently omits them. (#1268) - Set the unit-test hermetic HOME at conftest import time so a single xdist worker on the `windows-2025-vs2026` runner can no longer race fixture setup and re-trigger the 53 `Path.home()` failures the session-scoped autouse fixture was supposed to prevent. (#1271) diff --git a/docs/src/content/docs/enterprise/drift-detection.md b/docs/src/content/docs/enterprise/drift-detection.md index b19673c70..eceb8ce2a 100644 --- a/docs/src/content/docs/enterprise/drift-detection.md +++ b/docs/src/content/docs/enterprise/drift-detection.md @@ -92,6 +92,40 @@ as either eviction-and-refetch (silent self-heal) or a hard failure when the cache cannot be repopulated -- never as wrong content under the right name. +## Install before audit and tamper detection + +Running `apm install` before `apm audit --ci` is the correct pattern when +the goal is detecting a developer who forgot to run `apm install` after +editing `apm.yml`. The install step regenerates deployed files so the +subsequent audit can compare them against the lockfile. + +That sequence has a blind spot: `apm install` overwrites every managed file +with a clean copy before the audit runs. If a deployed file was modified on +disk after the last install -- for example a hand-edit to +`.github/instructions/` -- the install step restores the original bytes. +The `content-integrity` check then compares the restored file against a +matching hash and reports no finding. + +To detect post-install modification, use `setup-only: true` on the action +so it only provides the CLI without running `apm install`, then audit with +`--no-drift`: + +```yaml +- uses: microsoft/apm-action@v1 + with: + setup-only: true +- run: apm audit --ci --no-drift +``` + +`--no-drift` skips the install-replay (which requires a warm cache that +`setup-only` does not populate). The `content-integrity` check verifies +SHA-256 hashes of every deployed file against `deployed_file_hashes` in +`apm.lock.yaml` without needing to replay the install. Any byte-level +change to a deployed file since the last install is caught by this check. + +See [Enforce in CI](../enforce-in-ci/#audit-only-ci-pattern) for the full +recipe and a comparison table of the two patterns. + ## Org-wide sweeps APM runs per repository. There is no built-in fleet console. The diff --git a/docs/src/content/docs/enterprise/enforce-in-ci.md b/docs/src/content/docs/enterprise/enforce-in-ci.md index ca616b215..0cc445e20 100644 --- a/docs/src/content/docs/enterprise/enforce-in-ci.md +++ b/docs/src/content/docs/enterprise/enforce-in-ci.md @@ -77,6 +77,54 @@ jobs: Make this job a required status check via [GitHub Rulesets](../github-rulesets/) and a violating PR cannot merge. +## Audit-only CI pattern + +The default `microsoft/apm-action@v1` runs `apm install` before any +subsequent steps. That is the right default for most workflows: it ensures +the lockfile and deployed files are present before the audit reads them. + +However, `apm install` overwrites every managed file with a fresh copy +before `apm audit --ci` runs. If a managed file was modified on disk after +the last install -- its bytes changed without updating the lockfile hash -- +the install step silently restores the clean copy. The `content-integrity` +check then compares the freshly restored file against a hash that matches, +and the tampering goes undetected. + +To detect post-install file modification, run the action in setup-only mode +so it only adds the CLI to `PATH` without touching deployed files: + +```yaml +jobs: + audit: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: microsoft/apm-action@v1 + with: + setup-only: true # CLI only; does not run apm install + - run: apm audit --ci --no-drift + env: + GITHUB_APM_PAT: ${{ secrets.APM_PAT }} +``` + +`setup-only: true` leaves every deployed file exactly as checked out. +`--no-drift` skips the install-replay check because no warm cache exists; +the `content-integrity` check still verifies that every deployed file's +SHA-256 hash matches the `deployed_file_hashes` recorded in `apm.lock.yaml`. +Any file whose bytes were changed after the last install fails this check. + +The two patterns serve different goals: + +| Pattern | Use when | +|---|---| +| Full install then audit | Catching developers who skipped `apm install` after editing `apm.yml`; ensuring deployed files are present on a fresh runner | +| Audit-only (`setup-only: true`) | Detecting modification of deployed files after install; committed files and lockfile are the ground truth | + +Both patterns enforce policy and the eight baseline lockfile checks. The +difference is only in whether content-integrity can see tampered bytes. + ## Recipe: SARIF for GitHub Code Scanning Emit SARIF and upload it so each violation appears inline on the PR diff --git a/docs/src/content/docs/integrations/ci-cd.md b/docs/src/content/docs/integrations/ci-cd.md index 2a91a710d..22583b0e7 100644 --- a/docs/src/content/docs/integrations/ci-cd.md +++ b/docs/src/content/docs/integrations/ci-cd.md @@ -75,8 +75,27 @@ runs, hand-edited deployed files, and orphaned files. See the [Drift Detection guide](../../guides/drift-detection/) for details and opt-out (`--no-drift`). +For tamper detection -- catching deployed files modified after the last +install -- use the audit-only pattern instead. `apm install` overwrites +managed files before audit runs, which erases any tampered bytes before +`content-integrity` can see them. Pass `setup-only: true` to the action so +it only provides the CLI, then audit with `--no-drift`: + +```yaml + - uses: microsoft/apm-action@v1 + with: + setup-only: true + - name: Audit (audit-only, tamper detection) + run: apm audit --ci --no-drift +``` + +`content-integrity` verifies the SHA-256 hash of every deployed file +against `deployed_file_hashes` in `apm.lock.yaml` without replaying the +install. See [Audit-only CI pattern](../../enterprise/enforce-in-ci/#audit-only-ci-pattern) +for the full recipe and when to use each approach. + :::tip[We dogfood this] -APM's own repo uses the `APM Self-Check` job in [`microsoft/apm`'s `ci.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/ci.yml) as a reference implementation for installing APM and running `apm audit --ci`. Use it as a practical example when wiring these checks into your own workflow. +APM's own repo uses the `APM Self-Check` job in [`microsoft/apm`'s `ci.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/ci.yml) as a reference implementation of the audit-only CI pattern: `setup-only: true` keeps deployed files untouched so `content-integrity` can detect tampered bytes, and `--no-drift` skips the replay that requires a warm cache. Use it as a practical example when wiring the audit-only check into your own workflow. ::: ## Azure Pipelines From a7bc2e2a449b40eb689f00a4abc58ef3b7509d07 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 12 May 2026 14:10:34 +0100 Subject: [PATCH 3/4] fix(ci): make Gate B no-op status explicit in comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e8c83b69..73cb02578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,10 +161,11 @@ jobs: run: apm audit --ci --no-drift # Gate B: regeneration drift (producer-side) -- legacy bash fallback. - # With setup-only: true, apm install did not run and the working tree - # is unchanged, so this check trivially passes. It is kept so the - # pattern is visible; a full install+audit workflow would rely on this - # step to detect hand-edits to regenerated .github/ files. + # NOTE: With setup-only: true this step is a guaranteed no-op. + # apm install did not run and the working tree is unchanged, so the + # git status check always finds nothing. It is kept so the pattern is + # visible; a full install+audit workflow would rely on this step to + # detect hand-edits to regenerated .github/ files. - name: Check APM integration drift (legacy bash fallback, see #1071) run: | if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then From 9987a7cbdf98879f367c8317ab474ac5faa00660 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 12 May 2026 14:30:48 +0100 Subject: [PATCH 4/4] fix(ci): clarify --no-drift skips the drift check, not names it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73cb02578..55fcf5dff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,8 +131,9 @@ jobs: # - Gate A (consumer-side): `apm audit --ci --no-drift` -- lockfile / # content-integrity check. setup-only: true keeps managed files # untouched so the SHA-256 content-integrity check can detect tampered - # files. The install-replay (--no-drift) is skipped because there is - # no warm cache; content-integrity covers the same tamper signal. + # files. The drift check (install-replay comparison) is skipped + # via --no-drift because there is no warm cache in a setup-only + # run; content-integrity covers the same tamper signal. # See microsoft/apm#883 for context. Tier 1 (no secrets needed). apm-self-check: name: APM Self-Check