diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b2d2bed..a28a417 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,2 +1,13 @@
-#
-* @microsoft/sharepoint-embedded
+# Code owners for microsoft/SharePoint-Embedded-MCP-Server.
+#
+# Entries must resolve to accounts or teams with write access to this
+# repository, otherwise GitHub silently drops the rule and no review is ever
+# requested. The previous `@microsoft/sharepoint-embedded` team handle did not
+# resolve, so CODEOWNERS was effectively inert; these are direct collaborators.
+* @dluces @marcwindle @pemtaira-msft
+
+# Security-sensitive surfaces: workflows, audit tooling and control docs.
+/.github/ @dluces @marcwindle @pemtaira-msft
+/scripts/security-audit/ @dluces @marcwindle @pemtaira-msft
+/docs/SECURITY-CONTROLS.md @dluces @marcwindle @pemtaira-msft
+/docs/SECURITY-AUDIT.md @dluces @marcwindle @pemtaira-msft
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index a1d1727..b68c031 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -15,3 +15,14 @@ updates:
open-pull-requests-limit: 5
commit-message:
prefix: ci
+
+ # Pinned GitHub Copilot CLI used by the model-assisted security audit job.
+ # Kept out of the root manifest so it is never installed for normal builds
+ # and never published (root package.json "files" excludes tools/).
+ - package-ecosystem: npm
+ directory: /tools/copilot-cli
+ schedule:
+ interval: weekly
+ open-pull-requests-limit: 5
+ commit-message:
+ prefix: deps
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b4c693..db2290c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,8 +21,11 @@ jobs:
- 24.x
- 26.x
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
@@ -31,3 +34,5 @@ jobs:
- run: npm run typecheck
- run: npm run build
- run: npm test
+ - name: Validate repository contracts
+ run: npm run --silent security:audit:ci
diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml
new file mode 100644
index 0000000..cc9ccb1
--- /dev/null
+++ b/.github/workflows/security-audit.yml
@@ -0,0 +1,710 @@
+name: Private security audit (inactive)
+
+# Deliberately scheduled + trusted repository dispatch only.
+#
+# There is NO `pull_request` and NO `pull_request_target` trigger. The
+# model-assisted job reads repository source as untrusted input; running it on
+# fork-controlled refs would let a contributor choose the audited content and
+# steer the prompt. `repository_dispatch` loads this workflow from the default
+# branch, so an operator cannot select a modified controller workflow.
+# `security.yml` remains the per-PR security gate.
+#
+# IMPORTANT: every job in this public-repository workflow is hard-disabled.
+# GitHub Actions exposes job, step, and workflow conclusions publicly, so an
+# active audit cannot both fail closed and keep finding existence plus private
+# report submission state confidential. Activation requires a separate reviewed
+# design whose public surface is invariant. Repository variables alone must
+# never enable this workflow.
+on:
+ schedule:
+ # Mondays, 06:17 UTC. Offset from the hour to avoid the scheduler stampede.
+ - cron: '17 6 * * 1'
+ repository_dispatch:
+ # A repository dispatch executes only the workflow present on the default
+ # branch. The fixed event type is the trusted operator entrypoint; every
+ # client_payload value is still treated as untrusted and validated below.
+ types:
+ - security-audit
+
+# Deny by default. Every job re-declares only what it needs.
+permissions: {}
+
+concurrency:
+ group: security-audit-${{ github.ref }}
+ cancel-in-progress: false
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ NODE_VERSION: '24.x'
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # Validates and normalises every operator-supplied input before it reaches a
+ # job that checks out code. Rejects anything that is not an allowlisted value
+ # or a 40-hex SHA reachable from main.
+ # ---------------------------------------------------------------------------
+ validate-inputs:
+ name: Private audit inactive
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ target_sha: ${{ steps.validate.outputs.target_sha }}
+ # The protected branch the target must be reachable from. Emitted as a
+ # constant by validate-target.mjs so it cannot be influenced by inputs.
+ target_ref: ${{ steps.validate.outputs.target_ref }}
+ # 'true' only when the validated target is the *current* origin/main tip.
+ # Findings are never published to code scanning, so nothing is gated on
+ # this value: private reports name the audited commit in their own summary
+ # and therefore cannot be mis-attributed. Retained as an audit signal.
+ is_main_tip: ${{ steps.validate.outputs.is_main_tip }}
+ # The validated origin/main tip. Every job that runs audit helper scripts
+ # pins its *controller* checkout to this commit, so the trusted code is
+ # always the protected-main version and never the target commit (an older
+ # reachable ancestor may not contain the helpers at all).
+ controller_sha: ${{ steps.validate.outputs.controller_sha }}
+ model: ${{ steps.validate.outputs.model }}
+ scope: ${{ steps.validate.outputs.scope }}
+ dry_run: ${{ steps.validate.outputs.dry_run }}
+ steps:
+ # This job resolves `controller_sha`, so it cannot consume it. The
+ # protected branch name is used instead: actions/checkout resolves it
+ # server-side to the current main tip, which is the same commit the
+ # validator then reports as `controller_sha`. The supported triggers load
+ # this workflow from the protected default branch.
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: main
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ # Inputs are passed through `env`, never interpolated into the command
+ # line, so a crafted value cannot break out into the shell.
+ #
+ # `schedule` supplies no inputs at all, so INPUT_REF is empty on the
+ # weekly run. validate-target.mjs resolves an empty ref to the current
+ # origin/main tip and then applies the *same* full-SHA and
+ # reachable-from-main checks to it — a default, never a bypass.
+ #
+ # The script also refuses to run when GITHUB_EVENT_NAME is set and
+ # GITHUB_REF is not refs/heads/main. Both supported events are default-
+ # branch events; this is defence in depth around the platform guarantee.
+ # The validator below remains the sole parameter gate.
+ - name: Validate target ref and options
+ id: validate
+ env:
+ INPUT_REF: ${{ github.event.client_payload.ref }}
+ INPUT_SCOPE: ${{ github.event.client_payload.scope }}
+ INPUT_MODEL: ${{ github.event.client_payload.model }}
+ INPUT_DRY_RUN: ${{ github.event.client_payload.dry_run }}
+ run: |
+ set -euo pipefail
+ node scripts/security-audit/validate-target.mjs \
+ --ref "${INPUT_REF:-}" \
+ --scope "${INPUT_SCOPE:-server-core}" \
+ --model "${INPUT_MODEL:-claude-opus-5}" \
+ --dry-run "${INPUT_DRY_RUN:-false}"
+
+ # ---------------------------------------------------------------------------
+ # Deterministic check 1: dependency audit.
+ #
+ # `npm audit --json` embeds the dependency graph and every advisory URL names
+ # a vulnerable package and version range, so neither the raw report nor the
+ # advisory list is ever published. The sanitizer reduces the report to
+ # severity counts, which are consumed only by the fail gate in this job.
+ # Nothing is echoed, summarised or uploaded: a maintainer reproduces the
+ # finding locally (see docs/SECURITY-AUDIT.md).
+ # ---------------------------------------------------------------------------
+ dependency-audit:
+ name: Private audit inactive
+ needs: validate-inputs
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ # Controller checkout: trusted helper scripts pinned to `controller_sha`,
+ # the commit validate-inputs resolved for the protected default branch —
+ # never the event-selected ref and never the audited commit. Must come
+ # first — actions/checkout runs `git clean -ffdx` in its destination, so a
+ # root checkout performed after a `target/` checkout would delete it.
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.controller_sha }}
+ persist-credentials: false
+
+ - name: Checkout target commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.target_sha }}
+ path: target
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ # Copy only the package manifests into a runner-owned directory. This
+ # prevents an audited `.npmrc` or unrelated project content from affecting
+ # either npm command. Empty runner-owned user/global config files plus an
+ # explicit public registry close the remaining npm configuration inputs,
+ # while the trusted preflight validator rejects unsupported source forms
+ # before npm runs. The audit itself stays lockfile-only and never installs
+ # target-controlled tarballs in this privileged job.
+ - name: Prepare isolated dependency workspace
+ env:
+ AUDIT_NPM_DIR: ${{ runner.temp }}/security-audit-npm
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-npm/user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-npm/global.npmrc
+ run: |
+ set -euo pipefail
+ rm -rf "${AUDIT_NPM_DIR}"
+ mkdir -p "${AUDIT_NPM_DIR}"
+ cp target/package.json target/package-lock.json "${AUDIT_NPM_DIR}/"
+ : > "${NPM_USER_CONFIG}"
+ : > "${NPM_GLOBAL_CONFIG}"
+
+ # The report is written to the controller-owned workspace root so the
+ # sanitizer never reads from, or writes into, the audited tree. `--json`
+ # output is redirected to a file and never to the console: the raw report
+ # names vulnerable packages and versions. The exit status is captured by
+ # a local status. A policy finding is normalized so it cannot change a
+ # public step or job conclusion if this dormant scaffold is redesigned.
+ - name: Run npm audit
+ id: audit
+ working-directory: ${{ runner.temp }}/security-audit-npm
+ env:
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-npm/user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-npm/global.npmrc
+ run: |
+ set -uo pipefail
+ mkdir -p "${GITHUB_WORKSPACE}/.security-audit"
+ validate_status=0
+ node "${GITHUB_WORKSPACE}/scripts/security-audit/validate-npm-audit-inputs.mjs" \
+ --dir . \
+ > "${GITHUB_WORKSPACE}/.security-audit/npm-audit-inputs.log" 2>&1 ||
+ validate_status=$?
+ rm -f "${GITHUB_WORKSPACE}/.security-audit/npm-audit-inputs.log"
+ [ "${validate_status}" -eq 0 ] || exit 1
+ set +e
+ npm audit \
+ --package-lock-only \
+ --audit-level=high \
+ --json \
+ --registry=https://registry.npmjs.org/ \
+ --userconfig="${NPM_USER_CONFIG}" \
+ --globalconfig="${NPM_GLOBAL_CONFIG}" \
+ > "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json" 2>/dev/null
+ status=$?
+ set -e
+ case "${status}" in
+ 0|1) exit 0 ;;
+ *) exit 1 ;;
+ esac
+
+ - name: Discard dependency audit workspace
+ if: ${{ always() }}
+ env:
+ AUDIT_NPM_DIR: ${{ runner.temp }}/security-audit-npm
+ run: |
+ rm -rf "${AUDIT_NPM_DIR}"
+ rm -f .security-audit/npm-audit.json
+
+ # ---------------------------------------------------------------------------
+ # Deterministic check 2: secret scanning that actually runs.
+ #
+ # Replaces the previous licence-gated, continue-on-error step which could
+ # report green without scanning. The Gitleaks CLI is MIT licensed and needs no
+ # licence key; only the marketplace action does. The binary is pinned by
+ # version and verified by SHA-256 before execution.
+ # ---------------------------------------------------------------------------
+ secret-scan:
+ name: Private audit inactive
+ needs: validate-inputs
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ env:
+ GITLEAKS_VERSION: '8.30.1'
+ # Provenance: taken from the upstream release artifact
+ # gitleaks_8.30.1_checksums.txt published at
+ # https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt
+ # (goreleaser-generated, published alongside the binaries). Re-verify this
+ # value against that file whenever GITLEAKS_VERSION is bumped.
+ GITLEAKS_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb'
+ steps:
+ # Controller checkout first (see the dependency-audit job for the
+ # `git clean -ffdx` ordering constraint).
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.controller_sha }}
+ persist-credentials: false
+
+ - name: Checkout target commit with full history
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.target_sha }}
+ path: target
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Download and verify gitleaks
+ run: |
+ set -euo pipefail
+ asset="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
+ url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${asset}"
+ curl --fail --silent --show-error --location --retry 3 --output "$asset" "$url"
+ echo "${GITLEAKS_SHA256} ${asset}" | sha256sum --check --strict
+ tar -xzf "$asset" gitleaks
+ chmod +x gitleaks
+ ./gitleaks version > /dev/null 2>&1
+
+ # `--exit-code 2` distinguishes "leaks found" from an operational failure,
+ # so a crashed scanner is never mistaken for a clean scan. The exit code is
+ # re-raised as the step status; it is not swallowed by a trailing command.
+ # The scan target is the separate `target/` checkout; the binary and the
+ # report both live in the controller-owned workspace root.
+ # Console output is redirected and discarded unread: gitleaks prints one
+ # block per finding carrying file path, line, commit, author and e-mail.
+ # `--redact` masks only the secret value, not that metadata, and Actions
+ # logs are world-readable on a public repository.
+ - name: Scan repository history
+ id: scan
+ run: |
+ set -uo pipefail
+ mkdir -p .security-audit
+ # Gitleaks otherwise auto-loads configuration, ignore fingerprints,
+ # and inline allow comments from the scan target. Remove target-owned
+ # policy files and force the protected controller policy explicitly.
+ rm -rf -- target/.gitleaks.toml target/.gitleaksignore
+ ./gitleaks git target \
+ --config scripts/security-audit/gitleaks-controller.toml \
+ --gitleaks-ignore-path scripts/security-audit/gitleaks-controller-ignore \
+ --ignore-gitleaks-allow \
+ --report-format json \
+ --report-path .security-audit/gitleaks.json \
+ --redact \
+ --exit-code 2 \
+ --no-banner \
+ > .security-audit/gitleaks-console.log 2>&1
+ status=$?
+ rm -f .security-audit/gitleaks-console.log
+ rm -f .security-audit/gitleaks.json
+ case "${status}" in
+ 0|2) exit 0 ;;
+ *) exit 1 ;;
+ esac
+
+ # ---------------------------------------------------------------------------
+ # Deterministic check 3: every action in every workflow is pinned to a
+ # 40-character commit SHA.
+ # ---------------------------------------------------------------------------
+ action-pins:
+ name: Private audit inactive
+ needs: validate-inputs
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ # Controller checkout first (see the dependency-audit job for the
+ # `git clean -ffdx` ordering constraint).
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.controller_sha }}
+ persist-credentials: false
+
+ - name: Checkout target commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.target_sha }}
+ path: target
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ # The YAML-aware pin checker is a trusted controller dependency installed
+ # from the protected-main lockfile. Lifecycle scripts, npm's implicit audit,
+ # funding output, project/user config, and registry discovery are disabled.
+ - name: Install audit helper dependencies
+ env:
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-controller-user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-controller-global.npmrc
+ run: |
+ set -euo pipefail
+ : > "${NPM_USER_CONFIG}"
+ : > "${NPM_GLOBAL_CONFIG}"
+ npm ci \
+ --ignore-scripts \
+ --no-audit \
+ --fund=false \
+ --registry=https://registry.npmjs.org/ \
+ --userconfig="${NPM_USER_CONFIG}" \
+ --globalconfig="${NPM_GLOBAL_CONFIG}"
+
+ # The checker itself comes from the controller checkout; only the scanned
+ # workflow and local-action trees come from the audited commit. Its
+ # local diagnostics name paths, lines, actions and reasons, so workflow
+ # execution captures and deletes them unread before re-raising the status.
+ - name: Verify action pinning
+ id: pin-check
+ run: |
+ set -uo pipefail
+ mkdir -p .security-audit
+ set +e
+ node scripts/security-audit/check-action-pins.mjs \
+ --dir target/.github/workflows \
+ --root target \
+ > .security-audit/action-pins-diagnostics.log 2>&1
+ status=$?
+ set -e
+ rm -f .security-audit/action-pins-diagnostics.log
+ case "${status}" in
+ 0|2) exit 0 ;;
+ *) exit 1 ;;
+ esac
+
+ # ---------------------------------------------------------------------------
+ # Model-assisted advisory pass (credentialed, disabled scaffold).
+ #
+ # This job is intentionally hard-disabled by the leading `false &&` guard.
+ # The proposed Copilot CLI version is not available from the public npm
+ # registry, its package/license approvals remain open, and no reproducible
+ # lockfile is committed. Repository variables alone therefore cannot activate
+ # the model path. Enabling it requires a reviewed code change that selects an
+ # approved, publicly reproducible dependency and commits its verified lockfile.
+ #
+ # The `secrets` context is not readable from a job-level `if`, but `vars` is —
+ # and gating on the variables means the `security-audit-private-report`
+ # environment is never referenced until an administrator has created and
+ # protected it. Referencing an environment that does not exist would cause
+ # GitHub to create it implicitly and unprotected, which is why the guard is
+ # required.
+ #
+ # Disclosure policy: model findings are NEVER published. This job uploads no
+ # artifact, writes no job summary, emits no SARIF, opens no issue and holds no
+ # `security-events` permission. The single egress for a validated finding is
+ # `POST /repos/{owner}/{repo}/security-advisories/reports` — GitHub Private
+ # Vulnerability Reporting — which is visible to repository maintainers only.
+ # Submission happens inside this job, immediately after the tool-less model
+ # process has exited and the response has been validated and redacted, because
+ # findings must not cross a job boundary through artifacts or job outputs.
+ #
+ # The deterministic jobs and synthetic dry run do not use this dependency and
+ # remain separately testable. See docs/SECURITY-AUDIT.md.
+ # ---------------------------------------------------------------------------
+ model-audit:
+ name: Private audit inactive
+ # `secret-scan` is a hard predecessor, not an ordering preference: it is the
+ # gate that must pass before any repository source leaves the runner. If the
+ # tree still contains a live credential, sending the corpus to the model
+ # provider would export that credential to a third party. A failed or
+ # cancelled secret scan therefore skips this job entirely.
+ needs:
+ - validate-inputs
+ - secret-scan
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ environment: security-audit-private-report
+ # No `security-events: write`: model findings never reach code scanning.
+ # No `issues`, `pull-requests` or `contents: write`: there is no public
+ # disclosure surface and no fallback channel of any kind.
+ permissions:
+ contents: read
+ steps:
+ # Controller checkout first (see the dependency-audit job for the
+ # `git clean -ffdx` ordering constraint).
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.controller_sha }}
+ persist-credentials: false
+
+ - name: Checkout target commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.target_sha }}
+ path: target
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ # Fail closed: an environment that exists but holds no credential must not
+ # silently degrade into a green run. Both credentials are checked here,
+ # before any repository source is assembled, because a run that could
+ # produce findings it cannot report privately must not start at all.
+ # Presence only is tested; neither value is printed.
+ - name: Require credentials
+ env:
+ HAS_COPILOT_PAT: ${{ secrets.COPILOT_PAT != '' }}
+ HAS_SECURITY_ADVISORY_TOKEN: ${{ secrets.SECURITY_ADVISORY_TOKEN != '' }}
+ run: |
+ set -euo pipefail
+ if [ "${HAS_COPILOT_PAT}" != "true" ]; then
+ echo "COPILOT_PAT is not set in the security-audit-private-report environment" >&2
+ exit 1
+ fi
+ if [ "${HAS_SECURITY_ADVISORY_TOKEN}" != "true" ]; then
+ echo "SECURITY_ADVISORY_TOKEN is not set in the security-audit-private-report environment" >&2
+ exit 1
+ fi
+
+ # Runner debug logging (`ACTIONS_STEP_DEBUG` / `ACTIONS_RUNNER_DEBUG`, or
+ # the "Enable debug logging" re-run toggle that sets `runner.debug`) makes
+ # the runner echo step inputs, environment and command output verbatim.
+ # That would flush the assembled corpus, the prompt and the raw model
+ # response into the Actions log, which is world-readable on a public
+ # repository. There is no way to opt a single job out of that behaviour,
+ # so the job refuses to run instead. This guard sits before corpus
+ # collection so nothing is even assembled under debug logging, and it
+ # tests the flags rather than printing them.
+ - name: Refuse to run under debug logging
+ env:
+ STEP_DEBUG: ${{ secrets.ACTIONS_STEP_DEBUG }}
+ RUNNER_DEBUG_SECRET: ${{ secrets.ACTIONS_RUNNER_DEBUG }}
+ RUNNER_DEBUG_CONTEXT: ${{ runner.debug }}
+ run: |
+ set -euo pipefail
+ for flag in "${STEP_DEBUG:-}" "${RUNNER_DEBUG_SECRET:-}" "${RUNNER_DEBUG_CONTEXT:-}" "${RUNNER_DEBUG:-}"; do
+ case "$(printf '%s' "${flag}" | tr '[:upper:]' '[:lower:]')" in
+ true|1|yes|on)
+ echo "::error::Debug logging is enabled; refusing to send repository source to the model." >&2
+ exit 1
+ ;;
+ esac
+ done
+ echo "Debug logging is off; continuing."
+
+ # Dormant supply-chain gate for the disabled scaffold. The current proposed
+ # version cannot be resolved from registry.npmjs.org and has no approved
+ # lockfile, so the job-level `false &&` guard above must remain in place.
+ # If a later reviewed change removes that guard, this step still fails
+ # before repository source is assembled unless an approved public-registry
+ # lockfile has been committed.
+ - name: Install approved Copilot CLI runtime
+ env:
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-copilot-user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-copilot-global.npmrc
+ run: |
+ set -euo pipefail
+ if [ ! -f tools/copilot-cli/package-lock.json ]; then
+ echo "Security audit: FAIL" >&2
+ exit 1
+ fi
+ : > "${NPM_USER_CONFIG}"
+ : > "${NPM_GLOBAL_CONFIG}"
+ (
+ cd tools/copilot-cli
+ npm ci \
+ --ignore-scripts \
+ --no-audit \
+ --fund=false \
+ --registry=https://registry.npmjs.org/ \
+ --userconfig="${NPM_USER_CONFIG}" \
+ --globalconfig="${NPM_GLOBAL_CONFIG}"
+ )
+ test -x tools/copilot-cli/node_modules/.bin/copilot
+
+ # Collects an allowlisted, size-capped corpus. Every file body is wrapped
+ # in per-run, nonce-bearing untrusted-content delimiters so instructions
+ # embedded in repository source are presented as data, not as directives.
+ # Any occurrence of the static sentinel inside a file body is neutralised,
+ # and collection aborts outright if a body ever contains the run nonce.
+ - name: Collect corpus
+ env:
+ AUDIT_SCOPE: ${{ needs.validate-inputs.outputs.scope }}
+ run: |
+ set -euo pipefail
+ node scripts/security-audit/collect-corpus.mjs \
+ --scope "${AUDIT_SCOPE}" \
+ --repo-root target \
+ --out .security-audit/model
+
+ # actions/ai-inference concatenates the system prompt and the user prompt
+ # into a single Copilot CLI invocation, so `system-prompt-file` is NOT a
+ # separate privileged channel. build-prompt.mjs therefore renders the
+ # instructions twice: once as a preamble (system.txt) and once as a
+ # trusted suffix appended *after* the corpus (inside prompt.txt), so the
+ # immutable output contract is the last thing the model reads. It also
+ # injects the run nonce and the schema vocabulary from lib/constants.mjs,
+ # making prompt/validator drift structurally impossible.
+ #
+ # None of this is a security boundary on its own: validate-response.mjs
+ # is the enforceable boundary. See docs/SECURITY-AUDIT.md.
+ - name: Build prompt
+ run: |
+ set -euo pipefail
+ node scripts/security-audit/build-prompt.mjs \
+ --corpus .security-audit/model \
+ --out .security-audit/model
+
+ # `copilot-allow-tools` is deliberately unset: the action passes no
+ # `--allow-tool` flags when it is empty, so the model runs tool-less with
+ # no MCP servers, no shell and no write access.
+ - name: Run model-assisted review
+ id: inference
+ uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
+ with:
+ model: ${{ needs.validate-inputs.outputs.model }}
+ system-prompt-file: .security-audit/model/system.txt
+ prompt-file: .security-audit/model/prompt.txt
+ copilot-cli-path: tools/copilot-cli/node_modules/.bin/copilot
+ env:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_PAT }}
+
+ # The response is read from a file by path. It is never interpolated into
+ # a `run:` block, because `${{ }}` substitution happens before the shell
+ # sees the script and would allow command injection from model output.
+ - name: Validate and redact response
+ id: validate
+ continue-on-error: true
+ env:
+ RESPONSE_FILE: ${{ steps.inference.outputs.response-file }}
+ run: |
+ set -uo pipefail
+ rm -f .security-audit/model/report.json
+ rm -f .security-audit/model/validation-diagnostics.log
+ set +e
+ node scripts/security-audit/validate-response.mjs \
+ --response "${RESPONSE_FILE}" \
+ --manifest .security-audit/model/corpus-manifest.json \
+ --out .security-audit/model/report.json \
+ > .security-audit/model/validation-diagnostics.log 2>&1
+ status=$?
+ set -e
+ rm -f .security-audit/model/validation-diagnostics.log
+ exit "${status}"
+
+ # The single egress for a validated model finding: GitHub Private
+ # Vulnerability Reporting. One aggregate report per audited commit, whose
+ # summary carries the first twelve hex characters of the target SHA so
+ # historical audits are self-attributing and cannot be confused with an
+ # audit of the current tip. Reports are visible to repository maintainers
+ # only; there is no artifact, no SARIF, no job summary, no issue and no
+ # external tracker in this path, and no fallback if it fails.
+ #
+ # The advisory credential is scoped to THIS STEP ONLY. It is deliberately
+ # absent from the inference step above, so the model provider never sees a
+ # token that can write to the repository's security advisories.
+ #
+ # The standalone script prints one opaque result token. This workflow
+ # discards both output streams so neither can reveal finding or prior-
+ # report state in a public Actions log; the exit status remains.
+ - name: Submit private vulnerability report
+ id: submit
+ if: ${{ always() && hashFiles('.security-audit/model/report.json') != '' }}
+ env:
+ SECURITY_ADVISORY_TOKEN: ${{ secrets.SECURITY_ADVISORY_TOKEN }}
+ TARGET_SHA: ${{ needs.validate-inputs.outputs.target_sha }}
+ run: |
+ set -euo pipefail
+ node scripts/security-audit/submit-report.mjs \
+ --report .security-audit/model/report.json \
+ --sha "${TARGET_SHA}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ > /dev/null 2>&1
+
+ # The sanitized report and raw response are runner-local only. Deleting
+ # them explicitly prevents a later step from accidentally replaying either
+ # into a log or attaching them to an artifact.
+ - name: Discard model response and report
+ if: ${{ always() }}
+ env:
+ RESPONSE_FILE: ${{ steps.inference.outputs.response-file }}
+ run: |
+ set -euo pipefail
+ rm -f .security-audit/model/report.json
+ rm -f .security-audit/model/validation-diagnostics.log
+ if [ -n "${RESPONSE_FILE:-}" ]; then
+ rm -f -- "${RESPONSE_FILE}"
+ fi
+
+ # A partial validation writes a report containing only accepted, sanitized
+ # findings, allowing the submit step above to use the sole private egress.
+ # The job must still fail after that attempt. A malformed response writes
+ # no report, skips submission, and reaches the same fixed public failure.
+ - name: Fail closed if response validation did not complete
+ if: ${{ always() && (steps.validate.outcome == 'failure' || steps.submit.outcome == 'skipped') }}
+ run: |
+ echo "Security audit: FAIL" >&2
+ exit 1
+
+ # ---------------------------------------------------------------------------
+ # Credential-free rehearsal of the untrusted-output path.
+ #
+ # Exercises corpus collection, schema validation, rejection and redaction
+ # against a synthetic response. No environment, no secret, no network egress,
+ # and no publication of any kind — a synthetic result must never be mistaken
+ # for a real one, and the dry run has no private reporting path either.
+ # ---------------------------------------------------------------------------
+ model-audit-dry-run:
+ name: Private audit inactive
+ needs: validate-inputs
+ if: ${{ false }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ steps:
+ # Controller checkout first (see the dependency-audit job for the
+ # `git clean -ffdx` ordering constraint).
+ - name: Checkout controller scripts from main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.controller_sha }}
+ persist-credentials: false
+
+ - name: Checkout target commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ needs.validate-inputs.outputs.target_sha }}
+ path: target
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+
+ - name: Run synthetic dry run
+ env:
+ AUDIT_SCOPE: ${{ needs.validate-inputs.outputs.scope }}
+ run: |
+ set -euo pipefail
+ node scripts/security-audit/dry-run.mjs \
+ --scope "${AUDIT_SCOPE}" \
+ --repo-root target
+
+ - name: Run script test suite
+ run: npm run security:audit:test
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index f8905fa..c638508 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -1,7 +1,14 @@
name: Security
on:
- pull_request:
+ # This event loads the workflow definition from the protected base branch.
+ # The pull-request checkout below is data-only: no helper or project script is
+ # ever executed from it.
+ pull_request_target:
+ types:
+ - opened
+ - reopened
+ - synchronize
push:
branches:
- main
@@ -14,32 +21,201 @@ permissions:
jobs:
audit:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v7
+ - name: Checkout controller scripts from protected main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+
+ - name: Checkout audited commit as data
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ path: target
+ persist-credentials: false
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x
- cache: npm
- - run: npm ci
- - run: npm audit --audit-level=high
+ # The audited checkout controls `.npmrc` and every non-manifest project
+ # file. Copy only the package manifests to a runner-owned directory and
+ # use empty runner-owned config files plus an explicit registry. The
+ # trusted validator rejects unsupported source forms before npm runs, and
+ # the audit stays lockfile-only: no target-controlled tarball is ever
+ # installed in this privileged job.
+ - name: Prepare isolated dependency workspace
+ env:
+ AUDIT_NPM_DIR: ${{ runner.temp }}/security-audit-npm
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-npm/user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-npm/global.npmrc
+ run: |
+ set -euo pipefail
+ rm -rf "${AUDIT_NPM_DIR}"
+ mkdir -p "${AUDIT_NPM_DIR}"
+ cp target/package.json target/package-lock.json "${AUDIT_NPM_DIR}/"
+ : > "${NPM_USER_CONFIG}"
+ : > "${NPM_GLOBAL_CONFIG}"
+
+ # `npm audit` prints advisory titles, severities, package names, versions
+ # and GHSA advisory URLs. On a public repository the Actions log and check
+ # result are world-readable. The trusted sanitizer validates the JSON and
+ # the reports are deleted unread. Exit 0 and 1 are then normalized to the
+ # same public result: npm uses 1 for findings, and exposing that result would
+ # disclose advisory existence. Invalid output and unexpected scanner exits
+ # still fail as operational errors.
+ - name: Run npm audit
+ shell: bash
+ working-directory: ${{ runner.temp }}/security-audit-npm
+ env:
+ NPM_USER_CONFIG: ${{ runner.temp }}/security-audit-npm/user.npmrc
+ NPM_GLOBAL_CONFIG: ${{ runner.temp }}/security-audit-npm/global.npmrc
+ run: |
+ set -uo pipefail
+ mkdir -p "${GITHUB_WORKSPACE}/.security-audit"
+ validate_status=0
+ node "${GITHUB_WORKSPACE}/scripts/security-audit/validate-npm-audit-inputs.mjs" \
+ --dir . \
+ > "${GITHUB_WORKSPACE}/.security-audit/npm-audit-inputs.log" 2>&1 ||
+ validate_status=$?
+ rm -f "${GITHUB_WORKSPACE}/.security-audit/npm-audit-inputs.log"
+ [ "${validate_status}" -eq 0 ] || exit 1
+ npm audit \
+ --package-lock-only \
+ --audit-level=high \
+ --json \
+ --registry=https://registry.npmjs.org/ \
+ --userconfig="${NPM_USER_CONFIG}" \
+ --globalconfig="${NPM_GLOBAL_CONFIG}" \
+ > "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json" 2>/dev/null
+ status=$?
+ [ -f "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json" ] ||
+ echo '{}' > "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json"
+ sanitize_status=0
+ node "${GITHUB_WORKSPACE}/scripts/security-audit/sanitize-findings.mjs" \
+ --kind npm-audit \
+ --in "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json" \
+ --out "${GITHUB_WORKSPACE}/.security-audit/npm-audit-summary.json" ||
+ sanitize_status=$?
+ rm -f \
+ "${GITHUB_WORKSPACE}/.security-audit/npm-audit.json" \
+ "${GITHUB_WORKSPACE}/.security-audit/npm-audit-summary.json"
+ [ "${sanitize_status}" -eq 0 ] || exit 1
+ if [ "${status}" -ne 0 ] && [ "${status}" -ne 1 ]; then
+ exit 1
+ fi
+ exit 0
+
+ - name: Discard dependency audit workspace
+ if: ${{ always() }}
+ env:
+ AUDIT_NPM_DIR: ${{ runner.temp }}/security-audit-npm
+ run: |
+ rm -rf "${AUDIT_NPM_DIR}"
+ rm -f .security-audit/npm-audit.json .security-audit/npm-audit-summary.json
+
+ # Secret scanning that actually scans.
+ #
+ # The previous implementation used gitleaks/gitleaks-action gated on a
+ # GITLEAKS_LICENSE secret that was never provisioned, and additionally set
+ # continue-on-error, so the job could only ever report green without scanning
+ # anything. The Gitleaks CLI itself is MIT licensed and needs no licence key —
+ # only the marketplace action does — so the CLI is used directly, pinned by
+ # version and verified by SHA-256 before it is executed.
secrets:
runs-on: ubuntu-latest
+ timeout-minutes: 20
env:
- GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
+ GITLEAKS_VERSION: '8.30.1'
+ # Provenance: taken from the upstream release artifact
+ # gitleaks_8.30.1_checksums.txt published at
+ # https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_checksums.txt
+ # (goreleaser-generated, published alongside the binaries). Re-verify this
+ # value against that file whenever GITLEAKS_VERSION is bumped.
+ GITLEAKS_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb'
steps:
- - uses: actions/checkout@v7
+ - name: Checkout controller scripts from protected main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
+
+ - name: Checkout audited commit as data with full history
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ path: target
fetch-depth: 0
- # gitleaks-action@v2 requires a GITLEAKS_LICENSE when run under a GitHub
- # organization (free only for personal accounts). Until the owner
- # provisions the secret, this step is skipped so the workflow stays green;
- # it is also continue-on-error as a belt-and-suspenders. Owner action:
- # add GITLEAKS_LICENSE (or switch to GitHub Advanced Security secret
- # scanning, which is available org-wide) — see SECURITY.md.
+ persist-credentials: false
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24.x
+
+ - name: Download and verify gitleaks
+ shell: bash
+ run: |
+ set -euo pipefail
+ asset="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
+ url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${asset}"
+ curl --fail --silent --show-error --location --retry 3 --output "$asset" "$url"
+ echo "${GITLEAKS_SHA256} ${asset}" | sha256sum --check --strict
+ tar -xzf "$asset" gitleaks
+ chmod +x gitleaks
+ ./gitleaks version
+
+ # `--exit-code 2` separates "leaks found" from an operational failure. A
+ # trusted sanitizer validates the report and all report files are deleted
+ # unread. Exit 0 and 2 are normalized to the same public result so the check
+ # cannot disclose finding existence; any other exit remains an operational
+ # failure.
+ # Console output is redirected and discarded unread: gitleaks prints one
+ # block per finding carrying file path, line, commit, author and e-mail.
+ # `--redact` masks only the secret value, not that metadata, and Actions
+ # logs are world-readable on a public repository.
- name: Scan for secrets (gitleaks)
- if: ${{ env.GITLEAKS_LICENSE != '' }}
- continue-on-error: true
- uses: gitleaks/gitleaks-action@v3
- env:
- GITLEAKS_ENABLE_COMMENTS: "false"
+ shell: bash
+ run: |
+ set -uo pipefail
+ mkdir -p .security-audit
+ # Gitleaks otherwise auto-loads configuration, ignore fingerprints,
+ # and inline allow comments from the scan target. Remove target-owned
+ # policy files and force the protected controller policy explicitly.
+ rm -rf -- target/.gitleaks.toml target/.gitleaksignore
+ ./gitleaks git target \
+ --config scripts/security-audit/gitleaks-controller.toml \
+ --gitleaks-ignore-path scripts/security-audit/gitleaks-controller-ignore \
+ --ignore-gitleaks-allow \
+ --report-format json \
+ --report-path .security-audit/gitleaks.json \
+ --redact \
+ --exit-code 2 \
+ --no-banner \
+ > .security-audit/gitleaks-console.log 2>&1
+ status=$?
+ rm -f .security-audit/gitleaks-console.log
+ [ -f .security-audit/gitleaks.json ] || echo '[]' > .security-audit/gitleaks.json
+ sanitize_status=0
+ node scripts/security-audit/sanitize-findings.mjs \
+ --kind gitleaks \
+ --in .security-audit/gitleaks.json \
+ --out .security-audit/gitleaks-summary.json ||
+ sanitize_status=$?
+ rm -f .security-audit/gitleaks.json .security-audit/gitleaks-summary.json
+ [ "${sanitize_status}" -eq 0 ] || exit 1
+ case "${status}" in
+ 0|2) exit 0 ;;
+ *) exit 1 ;;
+ esac
+
+ - name: Discard secret scan reports
+ if: ${{ always() }}
+ run: |
+ rm -f \
+ .security-audit/gitleaks.json \
+ .security-audit/gitleaks-summary.json \
+ .security-audit/gitleaks-console.log
diff --git a/.gitignore b/.gitignore
index 23b41a0..047b46b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,9 @@ dist/
coverage/
*.tgz
+# Security audit run outputs (corpus, model report, scanner reports) — never committed
+.security-audit/
+
# Sample app build outputs (the sample SOURCES under samples/ are committed)
samples/**/bin/
samples/**/obj/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ea1b6c0..2ac645f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,52 @@ For more information see the
[opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or
comments.
+## Optional model-assisted security analysis
+
+**This feature is disabled by default.** The complete weekly workflow is hard-disabled in code and
+is not activation-ready. It is documented here so contributors know what a future, separately
+reviewed version could do.
+
+The repository contains security-audit workflow scaffolding with a declared weekly schedule; there
+is no claim that a production schedule is active. No workflow job can run.
+A future approved implementation may send a bounded selection of **already-public,
+git-tracked source files from `main`** to **GitHub Copilot**, which relays them to a
+**third-party model provider** for advisory security analysis.
+
+What this stage does and does not do:
+
+- **Only public, tracked source.** The corpus is limited to an allowlist of source file extensions
+ from committed files on `main`, under a hard file-count and byte cap. Untracked files, local
+ working-tree changes, build output and dependencies are never included.
+- **No separate repository or activity data.** The corpus does not query issues, pull requests,
+ discussions, commit messages, author records, CI logs or the runner environment. It does include
+ each selected file's repository-relative path, line count and public source content, which may
+ itself contain names, identifiers, credential-shaped strings or environment-variable references.
+- **No tools, no writes.** The model runs without tools, without MCP servers, without shell access
+ and without any write permission. It cannot open issues, comment, push, or change settings.
+- **Advisory and redacted.** Output is schema-validated and redacted before use, is advisory only,
+ and is never a required check for merging a pull request.
+- **Never published or signaled.** Validated findings have exactly one designed egress:
+ **GitHub Private Vulnerability Reporting**, where they are visible to repository maintainers
+ alone. Finding existence, scanner identity, counts, paths, private-submission outcome, and exploit
+ detail never appear in or influence public job/step names, conclusions, logs, artifacts, summaries,
+ pull request annotations, code scanning / SARIF, public issues, Azure DevOps, or IcM. There is no
+ fallback surface.
+- **Never triggered by contributions.** The workflow has no `pull_request` or
+ `pull_request_target` trigger. Opening or updating a pull request never sends anything anywhere.
+
+The complete weekly workflow is intentionally inactive. Every job has a literal `false` activation
+guard and the same generic public display name; it produces no audit summary. Repository variables,
+secrets, or dispatch payloads cannot activate it. A future activation requires a reviewed code
+change plus Private Vulnerability Reporting, a private operational-failure channel, public outcome
+invariance, a protected environment, managed credentials, and all legal/privacy approvals.
+
+The full design, boundaries and blocked prerequisites are documented in
+[docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md).
+
+If you have concerns about this feature as it relates to your contribution, please open a GitHub
+discussion or a non-security issue and a maintainer will discuss it with you.
+
## Reporting security issues
Please report security issues privately as described in [SECURITY.md](SECURITY.md). Do
diff --git a/README.md b/README.md
index c584ad6..95500c1 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ Prefer the command line? Run `claude mcp add spe -- npx -y @microsoft/spe-mcp st
- **Get started on Microsoft Learn:** [SharePoint Embedded MCP server](https://learn.microsoft.com/sharepoint/dev/embedded/getting-started/spe-mcp-server)
- **SharePoint Embedded product docs:**
-- **In this repo:** [Available Tools](#available-tools) · [Configuration](#configuration) · [Security controls](docs/SECURITY-CONTROLS.md) · [Troubleshooting](docs/TROUBLESHOOTING.md)
+- **In this repo:** [Available Tools](#available-tools) · [Configuration](#configuration) · [Security controls](docs/SECURITY-CONTROLS.md) · [Security audit](docs/SECURITY-AUDIT.md) · [Troubleshooting](docs/TROUBLESHOOTING.md)
## Available Tools
@@ -547,6 +547,15 @@ Microsoft takes security seriously. If you believe you have found a security
vulnerability, please report it privately as described in [SECURITY.md](SECURITY.md) —
**do not** file a public GitHub issue.
+This repository includes security-audit workflow scaffolding and credential-free local checks.
+The complete public weekly workflow is hard-disabled and is **not activation-ready**: every job
+has a literal `false` guard and one generic public display name, and there is no result summary.
+Its proposed model runtime package is not yet approved or reproducible from the public npm registry,
+so no lockfile is committed. Repository variables, secrets, and payloads cannot enable it. There is
+no claim that a production weekly audit is active. See
+[docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) for safe local validation and the prerequisites
+that remain open.
+
## Important notices
The MCP-specific notices and disclaimers for this project are consolidated in
diff --git a/SECURITY.md b/SECURITY.md
index e751608..4443fc9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,4 +11,39 @@ For security reporting information, locations, contact information, and policies
please review the latest guidance for Microsoft repositories at
[https://aka.ms/SECURITY.md](https://aka.ms/SECURITY.md).
-
\ No newline at end of file
+
+
+## Private reporting on this repository
+
+This repository uses **GitHub Private Vulnerability Reporting (PVR)**. Reports submitted through
+PVR are visible only to repository maintainers — never in public issues, pull request comments,
+job logs, workflow artifacts, or the public code scanning surface.
+
+To report a vulnerability you found yourself, use **Security → Report a vulnerability** on this
+repository, or follow the Microsoft guidance linked above. Do not open a public issue.
+
+### Automated audit reports
+
+The repository's optional model-assisted security audit
+(see [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md)) submits its validated findings through the
+**same** PVR endpoint, and through no other channel. Specifically:
+
+- Automated findings, finding existence, scanner identity, private-submission outcome, and exploit
+ detail are **never** written to or signaled through public job/step names, conclusions, logs,
+ workflow artifacts, job summaries, pull request annotations, code scanning / SARIF, public
+ issues, Azure DevOps, or IcM. There is no fallback surface.
+- Each audited commit produces at most **one aggregate report**, titled
+ `SPE automated security audit — `.
+- Submission is de-duplicated against existing reports in the `triage`, `draft`, `published`, and
+ `closed` states by exact title match, so re-running the audit for the same commit does not create
+ a duplicate report.
+- Reports are drafted as repository security advisories in the private reporting queue and are
+ therefore visible only to maintainers. They are advisory input for human triage; they are not
+ published advisories and they never gate a pull request.
+
+The complete weekly workflow is intentionally inactive: every job has a literal `false` guard,
+the same generic public display name, and no result summary. Variables, secrets, and dispatch
+payloads cannot activate it. Any future activation must keep public behavior invariant with respect
+to findings and private-report submission and must privately report operational problems while
+failing closed. See
+[docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) for the full activation prerequisites.
\ No newline at end of file
diff --git a/docs/SECURITY-AUDIT.md b/docs/SECURITY-AUDIT.md
new file mode 100644
index 0000000..8052b9e
--- /dev/null
+++ b/docs/SECURITY-AUDIT.md
@@ -0,0 +1,413 @@
+# Repository security audit scaffolding
+
+This repository contains an **inactive** security-audit workflow
+([`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)) with a Monday
+cadence and a fixed `security-audit` repository-dispatch event. Every job has a literal `false`
+activation guard. Scheduled and dispatched runs therefore execute no audit code and expose only
+the same skipped state. Repository variables, secrets, or payload values cannot activate it.
+
+The audit has two layers:
+
+| Layer | Jobs | Gating |
+| --- | --- | --- |
+| **Deterministic scaffold** | dependency audit, secret scan, action pinning | Hard-disabled |
+| **Model-assisted scaffold** | `model-audit` / `model-audit-dry-run` | Hard-disabled |
+
+> [!IMPORTANT]
+> The complete workflow is **non-activatable scaffolding**, not an activation-ready feature.
+> Every job expression is exactly `${{ false }}`, so repository variables and secrets cannot
+> start deterministic, synthetic, or model-assisted execution.
+> The proposed Copilot CLI version is unavailable from `registry.npmjs.org`, no reproducible
+> lockfile is committed, and package/license/CELA/Privacy approvals remain open. A future reviewed
+> code change must resolve every item under
+> [Blocked prerequisites](#model-assisted-scaffold-blocked-prerequisites) before removing that
+> hard-disable.
+
+> [!CAUTION]
+> **Disclosure policy — absolute.** Finding existence itself is private. No finding, scanner,
+> path, rule identifier, advisory URL, count, exploit detail, or private-submission outcome may
+> influence or appear in a public job name, step name, conclusion, log, artifact, pull-request
+> annotation, job summary, code-scanning result, public issue, Azure DevOps item, or IcM incident.
+> Validated model findings have exactly one designed egress:
+> a **private security advisory report** created through GitHub Private Vulnerability Reporting
+> (PVR) and visible only to repository maintainers. There is no fallback channel. Because a public
+> Actions success/failure conclusion would itself disclose private state, activation stays disabled
+> until operational failure and submission handling can satisfy the same invariant. The workflow
+> publishes no pass/fail summary.
+
+## What is present but inactive
+
+### Deterministic jobs
+
+| Internal job | Dormant design | Notes |
+| --- | --- | --- |
+| `validate-inputs` | Normalizes and validates dispatch payload | Hard-disabled |
+| `dependency-audit` | Lockfile-only dependency analysis | Hard-disabled; finding exit is normalized and raw output remains runner-local |
+| `secret-scan` | Repository-history secret analysis | Hard-disabled; finding exit is normalized and raw output remains runner-local |
+| `action-pins` | Immutable action/image policy | Hard-disabled; diagnostics remain runner-local. Dockerfile `ADD` accepts only literal local sources; remote or dynamic/expanded sources fail closed |
+| `model-audit` | Model-assisted analysis and PVR submission | Hard-disabled pending all activation prerequisites |
+| `model-audit-dry-run` | Synthetic rehearsal | Hard-disabled in public Actions; run locally only |
+
+All public job display names are the same generic inactive label. There is no summary job and no
+job-result aggregation. This keeps job, step, and workflow outcomes invariant with respect to
+findings and private-report submission state.
+
+The pin contract still runs in normal CI through `scripts/security-audit/ci-contracts.mjs`. That
+wrapper captures the complete pin/invariant test output and emits only one generic error if a
+repository contract regresses. It never forwards synthetic fixtures, paths, action references, or
+scanner output into the public log.
+
+In the dormant design, dependency validation and audit run from a runner-owned directory containing only
+`package.json` and `package-lock.json`. The trusted preflight validator rejects unsupported source
+forms and workspace-like expansion before npm runs, and `npm audit --package-lock-only` uses empty
+runner-owned user/global configuration files plus the explicit public npm registry. A target
+`.npmrc`, unrelated project content, and dependency tarballs therefore do not execute or install in
+the audit job.
+
+### Code scanning is not part of this workflow
+
+This workflow does **not** run CodeQL and does **not** hold `security-events: write` in any job.
+On a public repository, code scanning alerts are publicly visible, so uploading SARIF would
+publish vulnerability locations — the exact outcome the disclosure policy forbids. Scanning and
+then silently discarding the results would be worse: it would burn the analysis while pretending
+a control exists. So the custom CodeQL job was removed outright.
+
+**Model-discovered findings never reach CodeQL or code scanning.** There is no SARIF conversion
+step, no SARIF artifact, and no upload path anywhere in `security-audit.yml`.
+
+If the organization wants continuous static analysis, enable GitHub's **default setup** for code
+scanning at the repository or organization level and treat it as a separately-owned platform
+control with its own visibility model. It is independent of this workflow and receives nothing
+from it.
+
+### Dormant controller/target design
+
+If a future activation satisfies every prerequisite, any commit reachable from `main` can be
+audited, including commits from before this workflow existed. The design never runs code from the
+commit it is auditing:
+
+- **Controller** — the validation job checks out protected `main` and resolves its tip to
+ `controller_sha`. Every downstream controller checkout pins that exact SHA at the workspace
+ root, independent of the event-selected ref or audited target. This is where
+ `scripts/security-audit/**`, `package.json` and the workflow itself come from.
+- **Target** — checked out into `target/` at the validated SHA. It is **data**, never an
+ executable surface.
+
+Every helper is invoked from the controller checkout and pointed at the target explicitly
+(`collect-corpus.mjs --repo-root target`, `check-action-pins.mjs --dir target/.github/workflows
+--root target`, target package manifests copied into a runner-owned npm directory, and
+`gitleaks git target`).
+Tests assert that no `node scripts/security-audit/...` invocation ever resolves out of `target/`.
+
+> **Ordering constraint.** `actions/checkout` runs `git clean -ffdx` in its destination, so a
+> root checkout performed *after* a `target/` checkout would delete the target. The controller
+> checkout must always come **first**; a test enforces the ordering.
+
+Auditing an ancestor such as `819431d` — a commit with no `scripts/security-audit/` directory at
+all — is a supported case and is covered by a regression test.
+
+### Dormant result attribution design
+
+If activated, attribution would be carried entirely inside the private report. Each report's
+summary line embeds the audited commit:
+
+```
+SPE automated security audit —
+```
+
+That makes a historical audit unambiguous: the report describes the commit named in its own title,
+never "the current tip". Auditing an ancestor is therefore a fully supported case and needs no
+suppression rule — earlier revisions of this workflow suppressed historical uploads precisely
+because code scanning defines `sha` as *the head of the supplied ref* and cannot describe an
+ancestor truthfully. Private reports have no such constraint, so that gate has been removed along
+with the upload path it protected.
+
+Findings are never published as a downloadable artifact. On a public repository that would
+disclose unfixed vulnerabilities, so it is not offered in any form, for any target.
+
+### Model-assisted job scaffold
+
+`model-audit` is hard-disabled in this PR. If a future reviewed activation change satisfies the
+blocked prerequisites, the scaffold is designed to send a **bounded, allowlisted corpus** to a
+model and validate every finding before anything is retained:
+
+- Corpus caps: 128 files, 96 KiB per file, 1 MiB total (`scripts/security-audit/lib/constants.mjs`).
+- Instruction surfaces (`AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, `.github/instructions/`,
+ `.github/agents/`, `.copilot/`, `Skills/*/SKILL.md`, …) are denied from the corpus outright, so
+ agent-directed text can never be re-presented to the auditing model as repository content.
+- Every file is wrapped in **per-run nonce delimiters** — see
+ [Prompt-injection containment](#prompt-injection-containment) below.
+- The job is tool-less: no MCP servers, no shell, no repository write. `copilot-allow-tools`
+ is deliberately left unset (empty means no tools).
+- Model output is **never** interpolated into a shell command — only file paths are passed
+ through `env:`.
+- Findings are rejected outright if they carry tokens, GUIDs, absolute paths, or weaponized
+ payloads; e-mail addresses, query strings, and long hex blobs are redacted.
+- Findings must anchor to a corpus file and a line inside that file, and must cite a control
+ from [`SECURITY-CONTROLS.md`](SECURITY-CONTROLS.md) (or the literal `UNMAPPED`).
+
+### Private vulnerability reporting
+
+After a future approved activation, validated findings are designed to leave the runner through
+exactly one channel:
+`POST /repos/{owner}/{repo}/security-advisories/reports` — the REST endpoint behind GitHub
+**Private Vulnerability Reporting**. `scripts/security-audit/submit-report.mjs` runs inside the
+same protected `model-audit` job, *after* the tool-less model process has exited and
+`validate-response.mjs` has sanitized the response. If a response contains both accepted and
+rejected findings, accepted findings are submitted privately first and the model job then fails
+closed. A malformed response writes no report and makes no submission.
+
+| Property | Behaviour |
+| --- | --- |
+| Cardinality | **One aggregate report per audited commit.** Every accepted finding for that SHA becomes a section of a single report's markdown description — not one report per finding |
+| Title | `SPE automated security audit — ` |
+| Severity | The maximum severity across the accepted findings |
+| `vulnerabilities` | Deliberately **omitted** — these are source findings, not package advisories |
+| `start_private_fork` | `false` |
+| Deduplication | Before submitting, the script follows GitHub's cursor `Link` headers through existing `triage`, `draft`, `published` and `closed` reports and matches the exact summary string. Every continuation must remain on `api.github.com` and the same repository advisory endpoint. A re-run for the same SHA is a no-op |
+| Visibility | Repository **maintainers only**. A report is not an advisory and is not published; maintainers triage it in the Security tab |
+| Retry | Idempotent `GET` requests retry `5xx` at most twice, fixed 5 s apart. A report `POST` is attempted exactly once because a `5xx` can be ambiguous after persistence; retrying could create a duplicate. Every other error **fails the job immediately with no fallback** |
+| Output | The standalone CLI writes exactly one result token to stdout and no private metadata. No outcome token or finding-dependent process result may reach public Actions |
+
+If the private channel cannot be used — PVR not enabled, credential missing, endpoint rejecting —
+processing must halt fail closed. Findings are not written anywhere else, retried through another
+surface, or retained after the job ends. There is no ADO work item, IcM incident, GitHub issue, or
+artifact fallback. Activation remains prohibited until that operational state can be conveyed
+privately without changing public behavior or conclusion.
+
+Nothing else from the model layer is published: no issues, no comments, no raw-finding artifacts,
+no code scanning alerts, no job-summary detail.
+
+### Prompt-injection containment
+
+The corpus is untrusted by construction: it is repository source, and anyone who can land a
+commit can write text into it. Containment is layered, and only the last layer is trusted.
+
+1. **Per-run nonce fences.** `collect-corpus.mjs` generates a 24-byte random nonce for every run
+ and wraps each file in `<<>>>` /
+ `…_END:>>>`. A static delimiter is forgeable — the literal sentinel already appears in
+ this repository's own `constants.mjs` — so any occurrence of the sentinel inside a file body
+ is rewritten to a neutral marker before fencing, and a body that somehow contains the live
+ nonce aborts the run. After emission the collector re-counts fences and fails unless the
+ begin/end counts both equal the file count, so a corpus that can close its own fence never
+ reaches the model.
+2. **Nonce conveyance.** The nonce is recorded in `corpus-manifest.json`, and `build-prompt.mjs`
+ renders it into both prompt files. The model is told the exact fence to expect, so a forged
+ fence carrying a different (or no) nonce is visibly not the real boundary.
+3. **Trusted suffix, not a privileged role.** `actions/ai-inference` concatenates the system
+ prompt and the prompt, so `system-prompt-file` is *not* a separate privileged channel — text
+ later in the payload is not inherently less authoritative. The output contract is therefore
+ re-asserted **after** the corpus, from `prompt-suffix.md`, as the last thing the model reads.
+4. **`validate-response.mjs` is the enforceable boundary.** Everything above is defence in depth
+ and none of it is a security control on its own: prompt text cannot be enforced. The schema
+ validator is the control. It re-derives the allowlists from `constants.mjs`, requires every
+ finding to anchor to a real corpus file and a line that exists in it, rejects secrets/GUIDs/
+ absolute paths/weaponized payloads, redacts the rest, and **exits non-zero if anything was
+ rejected** (fail-closed). If the model ignores every instruction it was given, the run fails;
+ it does not silently emit attacker-shaped output.
+
+## Running it locally
+
+No credentials and no runtime dependencies are needed for the offline path.
+
+```bash
+# End-to-end synthetic run: corpus → validation → redaction → report schema check
+npm run security:audit:dry-run
+
+# The script test suite (schema, redaction, injection, workflow invariants)
+npm run security:audit:test
+
+# Fail if any workflow action is not pinned to a commit SHA
+npm run security:audit:pins
+```
+
+Both `security:audit:dry-run` and `collect-corpus.mjs` accept `--repo-root `, which is how
+the workflow points the controller's helpers at the `target/` checkout. It defaults to `.`, so
+local runs audit the working tree and need no extra flag. Manifest keys stay repository-relative
+regardless of the root, so a finding reported against `src/server.ts` reads the same locally and
+in CI.
+
+`security:audit:dry-run` writes to `.security-audit/dry-run/` (git-ignored):
+
+| File | Contents |
+| --- | --- |
+| `corpus-manifest.json` | Complete eligible file inventory, byte/line counts, and the run nonce |
+| `system.txt` | Rendered auditor preamble (vocabulary injected from `constants.mjs`) |
+| `prompt.txt` | Nonce-fenced corpus followed by the trusted output-contract suffix |
+| `model-report.json` | Accepted findings and rejected finding indexes with reason codes |
+
+The dry run validates that `model-report.json` matches the schema `submit-report.mjs` consumes,
+then reports success generically. It never contacts GitHub, never builds a report body from real
+findings and never prints finding detail.
+
+Individual stages can be run directly — see
+[`scripts/security-audit/README.md`](../scripts/security-audit/README.md).
+
+## Dispatch behavior while inactive
+
+The fixed `security-audit` repository-dispatch event remains declared for future design work, but a
+dispatch cannot run an audit. Every job is independently blocked by a literal `false` condition, so
+scheduled and dispatched runs have the same all-skipped public shape. Payload values, repository
+variables, secrets, and environment configuration cannot bypass those gates.
+
+## Local validation and private triage
+
+The normal-CI contract wrapper is the only active automated integration. Maintainers investigating a
+generic contract failure should reproduce it on a controlled local workstation:
+
+- `npm run security:audit:test` validates the security-audit contract suite.
+- `npm run security:audit:pins` validates immutable external action and Docker references.
+- `npm run security:audit:ci` executes both while suppressing their detailed output, matching CI.
+
+The local pin checker exits `0` when compliant, `2` for policy violations, and `1` for an
+operational or parsing failure. Local diagnostics can identify paths and references and therefore
+must not be copied to public PRs, issues, CI logs, Azure DevOps, or IcM.
+
+Report real vulnerabilities privately per [`SECURITY.md`](../SECURITY.md), using GitHub Private
+Vulnerability Reporting. Never open a public issue for an unfixed vulnerability and never record
+finding existence or private-submission outcome in an external tracker.
+
+## Model-assisted scaffold: blocked prerequisites
+
+This section is **not an activation procedure**. Every workflow job is hard-disabled by a literal
+`false` in its job condition. Repository administrators cannot enable it with variables, secrets,
+payloads, or environment configuration. Nothing in this repository stores, references, or reuses a
+credential.
+
+The runtime currently recorded in `tools/copilot-cli/package.json` is a proposal only:
+
+- `@github/copilot@1.0.80-1` is not available from `https://registry.npmjs.org/`;
+- the corporate registry resolves it through an internal feed, which a public GitHub-hosted runner
+ cannot use and which must not be committed into a public lockfile;
+- `tools/copilot-cli/package-lock.json` is therefore intentionally absent; and
+- package licensing plus CELA and Privacy determinations remain open.
+
+Do **not** generate or commit a lockfile from an internal mirror, and do not replace the package
+version merely to make installation pass. Either action would silently choose an unapproved
+runtime or make the public workflow dependent on an unavailable private feed.
+
+A future activation requires a separate reviewed change. Before that change may remove the
+literal hard-disable, it must provide evidence for all of the following:
+
+1. **Approved, publicly reproducible runtime.** Select a Copilot CLI version approved for this use,
+ available directly from `registry.npmjs.org`, and compatible with the pinned
+ `actions/ai-inference` revision. Generate the lockfile with the existing npm client and verify
+ every `resolved` URL and `sha512-…` integrity value. A clean `npm ci --ignore-scripts` must pass
+ with empty user/global npm configuration and the public registry explicitly selected.
+2. **CELA and Privacy sign-off.** The model layer sends repository source to a third-party
+ inference provider. Every determination below must be recorded before egress is authorized.
+3. **Private reporting channel.** Enable GitHub Private Vulnerability Reporting and prove that the
+ submission credential can create a private report without any public fallback.
+4. **Protected environment and managed credentials.** Create
+ `security-audit-private-report` with required reviewers and a `main` deployment rule. Provision
+ the team-owned credentials described below; individual-maintainer credentials are not
+ acceptable.
+5. **Provider/model compatibility.** Validate the allowlisted model and subprocessor chain without
+ publishing prompts, responses, findings, counts or submission outcomes.
+6. **Private operational handling.** Processing or submission failure must be reported only through
+ a maintainer-private channel. Public job, step, and workflow status must remain invariant with
+ respect to findings and submission outcome. If that cannot be implemented fail closed without a
+ public signal, activation is prohibited.
+7. **Code-reviewed enablement.** Only after items 1–6 are approved may a code change remove any
+ literal hard-disable. That future redesign must add independently reviewed defence-in-depth
+ enablement controls; no repository variable currently exists as an activation mechanism.
+
+No activation may turn a missing credential, processing error, unavailable reporting channel, or
+rejected submission into either a success-shaped fallback or a distinguishable public failure.
+Operational problems must be conveyed privately and the audit must fail closed. If both conditions
+cannot be met, leave every job disabled. No step may write findings anywhere else.
+
+### `COPILOT_PAT` governance requirements
+
+These are prerequisites for step 4, not suggestions. If any cannot be met, leave the entire
+workflow disabled.
+
+| Requirement | Obligation |
+| --- | --- |
+| Account | The token must be issued from a **team-owned managed service (machine) GitHub account**, provisioned through the organization's standard process and recorded in the team's asset inventory. A token issued from an individual maintainer's account is disqualifying: it silently inherits that person's entitlements and dies with their offboarding. |
+| Seat | The service account must hold a **Copilot Business or Copilot Enterprise** seat. **Individual/Pro seats are disallowed pending CELA review** — their terms, retention and training posture differ from the business/enterprise agreements. |
+| Named owners | Record **at least two named human owners** (primary and backup) for the service account and the token, alongside the environment. A machine account with no named owner is unmaintainable. |
+| Scope | Copilot Requests only. Any `repo`, `workflow`, `write:*` or `admin:*` scope is disqualifying. |
+| Expiry | Set an **explicit expiry**. Tokens configured with "no expiration" are disqualifying. |
+| Rotation | Rotate on a fixed cadence no longer than the organization's standard for CI credentials, and immediately on any suspected exposure. |
+| Offboarding | Add the token to the team's **offboarding checklist**. Revoke and reissue whenever a named owner changes role or leaves, and whenever the service account changes hands. |
+| Cost centre | Copilot premium requests are metered and billed against the service account's entitlement. Record the **cost centre** that would absorb them before a future enablement; repeated full-corpus runs are not free. |
+| Debug logs | The `model-audit` job **fails closed** before any corpus is collected when `ACTIONS_STEP_DEBUG` or `ACTIONS_RUNNER_DEBUG` is set, or when the run was started with "Enable debug logging". Debug logging can flush prompt and response content into logs that are world-readable on a public repository, so the job refuses to run rather than relying on an operator instruction. Disable debug logging and re-run. |
+
+There is **no** alternative credential mechanism implemented. If a different provider or an
+OIDC-based flow is adopted later, it must be implemented and reviewed on its own merits — do not
+assume it is available.
+
+### Future activation determinations (to be completed by CELA/Privacy)
+
+Nothing in this table is answered, agreed or approved. These are **open questions** that CELA and
+Privacy must determine and record before any model enablement change is approved. This repository
+makes no claim about any of them; the rows exist so that activation cannot proceed on assumption.
+
+| Determination | Question to be answered | Status |
+| --- | --- | --- |
+| Prompt/completion retention | How long does the provider retain the prompt (repository source) and the completion, and where is that retention documented? | ☐ Not determined |
+| Data residency | In which regions are prompts processed and stored, and is that acceptable for this repository's content? | ☐ Not determined |
+| Provider terms and AUP | Do the applicable terms of service and acceptable-use policy permit automated source analysis of this repository under the seat type in use? | ☐ Not determined |
+| Model training/improvement | Are prompts or completions used for model training, fine-tuning or product improvement, and can that be disabled? | ☐ Not determined |
+| Telemetry and provider-side logging | What request metadata and content is logged provider-side, who can access it, and for how long? | ☐ Not determined |
+| Contributor disclosure sufficiency | Is the disclosure in [`../CONTRIBUTING.md`](../CONTRIBUTING.md) sufficient notice to external contributors? | ☐ Not determined |
+| Export/third-party review | Are there export-control or third-party-review obligations triggered by sending this source to the provider? | ☐ Not determined |
+
+If any row is unresolved, every literal hard-disable must remain.
+
+Related administrative follow-ups (independent of the model layer):
+
+- Enable **Private Vulnerability Reporting** on the repository. This is a hard prerequisite for
+ any future model-assisted layer (see item 3 above) and is also the channel external researchers
+ use.
+- Enable **native secret scanning** and **push protection** on the repository.
+- Keep the generic normal-CI repository-contract check required. Do not require or activate any
+ dormant audit job: its public conclusion could reveal finding or submission state.
+
+### Assumption: audited commits are reachable from `main`
+
+`validate-target.mjs` requires the target SHA to be an ancestor of `refs/remotes/origin/main`.
+That is the point of the check — it stops a repository-dispatch payload from pointing the audit at
+an arbitrary unreviewed commit — but it interacts with the repository's merge settings.
+
+At the time of writing the repository allows **all three** merge methods (merge commit, squash,
+rebase). Squash and rebase merges rewrite commits, so a pull request's original head SHA is
+**not** reachable from `main` after the merge, and passing it here is rejected by design. Audit
+the resulting commit on `main` instead — that is the code that actually ships. Administrators who
+want auditing by a PR's original head to work must standardize on merge commits; the audit does
+not relax the reachability rule to accommodate rewritten history.
+
+While the workflow is inactive, scheduled runs resolve no target and execute no audit step.
+
+Reachability does **not** imply the commit contains this workflow. Older ancestors are audited
+using the controller/target split described above. Auditing a historical commit needs no special
+handling: nothing is published, so there is no code-scanning alert to misattribute, and the
+private report names the audited commit explicitly — see [Result attribution](#result-attribution).
+
+## Design constraints
+
+- The workflow has **no** `pull_request` or `pull_request_target` trigger, so untrusted forks
+ can never reach the audit path or its secrets.
+- Workflow-level permissions are `{}` (deny-all); each job re-grants only what it needs.
+- Every external action is pinned to a 40-hex commit SHA with the version in a trailing comment.
+ The pin checker recursively resolves every workflow-referenced local action and reusable workflow,
+ even under broad discovery exclusions such as `dist`, `coverage`, `node_modules`, and
+ `.security-audit`. It validates nested `uses:` and `runs.image` references, workflow job and
+ service containers, Dockerfiles, version comments, path containment, metadata ambiguity, cycles,
+ and symlinks fail closed. Dockerfile image references use an adjacent
+ `# pin-version: ` comment.
+- Dependency audit stays lockfile-only: the workflow validates copied manifests first and never
+ installs target-controlled packages in the privileged audit job.
+- Checkouts use `persist-credentials: false`.
+- Audit logic always executes from the protected `main` controller checkout; the audited commit is
+ mounted at `target/` and treated as data.
+- Model findings have exactly one egress path: a private vulnerability report visible only to
+ maintainers. There is no artifact, job summary, code-scanning, issue or external-tracker
+ fallback, and the audited commit is named inside the report itself.
+- Public job names, step names, conclusions, and summaries do not vary with finding existence,
+ scanner identity, private-report submission, or operational outcome because all audit jobs are
+ inactive and there is no result aggregator.
+- Dormant deterministic finding exit codes are normalized before any later control flow. Operational
+ errors remain fail closed, but activation is prohibited until those errors can be handled without
+ a distinguishable public outcome.
diff --git a/package-lock.json b/package-lock.json
index 1e4094a..0e4c23b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,8 @@
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"typescript": "^5.3.0",
- "vitest": "^4.1.10"
+ "vitest": "^4.1.10",
+ "yaml": "^2.9.0"
},
"engines": {
"node": "^22.0.0 || ^24.0.0 || ^26.0.0"
@@ -4074,6 +4075,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 7db0ffd..a4059da 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,10 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"notices": "node scripts/generate-third-party-notices.mjs",
+ "security:audit:dry-run": "node scripts/security-audit/dry-run.mjs",
+ "security:audit:ci": "node scripts/security-audit/ci-contracts.mjs",
+ "security:audit:test": "node --test \"scripts/security-audit/tests/*.test.mjs\"",
+ "security:audit:pins": "node scripts/security-audit/check-action-pins.mjs",
"prepublishOnly": "npm run build",
"ci": "npm run typecheck && npm run build && npm run test"
},
@@ -70,7 +74,8 @@
"@vitest/coverage-v8": "^4.1.10",
"eslint": "^10.7.0",
"typescript": "^5.3.0",
- "vitest": "^4.1.10"
+ "vitest": "^4.1.10",
+ "yaml": "^2.9.0"
},
"engines": {
"node": "^22.0.0 || ^24.0.0 || ^26.0.0"
diff --git a/scripts/security-audit/README.md b/scripts/security-audit/README.md
new file mode 100644
index 0000000..3403872
--- /dev/null
+++ b/scripts/security-audit/README.md
@@ -0,0 +1,191 @@
+# `scripts/security-audit`
+
+Node ESM helpers behind
+[`.github/workflows/security-audit.yml`](../../.github/workflows/security-audit.yml).
+Most use only the Node standard library. `check-action-pins.mjs` uses the committed `yaml`
+development dependency so all valid YAML encodings are parsed consistently; run `npm ci` before
+the pin check.
+
+The public weekly workflow is intentionally inactive: every job has a literal `false` condition,
+uses the same generic public display name, and produces no result summary. The repository-contract
+tests and pin checker run in normal CI through `ci-contracts.mjs`, which captures all child output
+and emits only a fixed generic failure.
+
+Operator-facing documentation lives in [`docs/SECURITY-AUDIT.md`](../../docs/SECURITY-AUDIT.md).
+
+## Controller vs target
+
+The dormant audit design runs these scripts from a checkout of protected `main` (the *controller*), while the
+commit being audited is checked out separately into `target/` and treated purely as data. Scripts
+that read repository content therefore accept `--repo-root` (default `.`) so the controller can
+point them at the target without ever executing code from it. Locally the default is what you
+want — the working tree is both controller and target.
+
+## Scripts
+
+| Script | Purpose | Exit codes |
+| --- | --- | --- |
+| `validate-target.mjs` | Validates repository-dispatch payload values: 40-hex SHA reachable from `main`, allowlisted scope/model, strict boolean dry-run. An empty/absent ref (scheduled runs or omitted payload) resolves to the `origin/main` tip and is then held to the same rules. Also publishes `target_ref` and `is_main_tip` for provenance | `0` ok, `1` rejected |
+| `validate-npm-audit-inputs.mjs` | Validates `package.json` / `package-lock.json` before `npm audit --package-lock-only`: only the public npm registry is allowed, workspace-like expansion is rejected, and unsupported source forms fail closed | `0` ok, `1` rejected |
+| `collect-corpus.mjs` | Collects the complete allowlisted corpus from `--repo-root` within hard caps, wraps each file in per-run nonce fences, and writes a manifest with repository-relative keys. Any eligible omitted or unreadable file fails the run | `0` ok, `1` error |
+| `build-prompt.mjs` | Renders `system.txt` (preamble) and `prompt.txt` (corpus + trusted suffix) from the manifest nonce | `0` ok, `1` error |
+| `validate-response.mjs` | Parses, schema-checks, rejects, and redacts the model response. A partial rejection writes only accepted findings to the sanitized report before failing, so CI can attempt the sole private egress | `0` ok, `1` malformed/no report, `3` rejected (sanitized report written; fail closed) |
+| `submit-report.mjs` | Submits the validated report as **one** private vulnerability report per audited SHA, de-duplicated by title. Prints only `report: submitted\|existing\|none\|failed` | `0` ok, `1` failed (fail closed) |
+| `sanitize-findings.mjs` | Reduces `npm audit` / gitleaks reports to counts only — no paths, rules, advisory URLs, GHSA or CVE identifiers | `0` ok, `1` error |
+| `check-action-pins.mjs` | Parses workflow and local-action YAML plus referenced local-action Dockerfiles. It recursively resolves every explicit local action or reusable workflow, including references under broad discovery exclusions. External actions require a 40-hex commit pin plus version comment. Workflow, action, and Dockerfile images require digests plus version comments; Dockerfiles use an adjacent `# pin-version: ` comment. Dockerfile `ADD` accepts only literal local sources. Cycles, path escapes, symlinks, missing/ambiguous metadata, remote or dynamic sources, and dynamic references fail closed | `0` clean, `2` policy violations, `1` operational/parse failure |
+| `ci-contracts.mjs` | Runs the invariant suite and pin checker with child output captured; prints only a fixed generic error to public CI | `0` contracts pass, `1` generic contract failure |
+| `dry-run.mjs` | Offline end-to-end run against a synthetic response, honouring `--repo-root` | `0` ok, non-zero on failure |
+
+## Disclosure policy
+
+These scripts operate under an absolute non-disclosure rule: **automated security findings, their
+existence, scanner identity, private-submission outcome, and exploit detail never become public.**
+Nothing here writes or signals them through job/step names, conclusions, logs, workflow artifacts,
+job summaries, pull request annotations, code scanning / SARIF, public issues, Azure DevOps, or IcM.
+There is no SARIF converter and no artifact upload anywhere in the audit path.
+
+The only egress for a validated model finding is `submit-report.mjs`, which posts to
+`POST /repos/{owner}/{repo}/security-advisories/reports` — GitHub Private Vulnerability Reporting,
+visible to repository maintainers only. There is no fallback. Public workflow execution remains
+disabled because exposing success or failure from processing or submission would itself disclose
+private state. Activation is prohibited until operational problems can be conveyed privately while
+public behavior remains invariant.
+
+### `submit-report.mjs` contract
+
+- One aggregate report per audited commit, titled
+ `SPE automated security audit — `.
+- De-duplicated by exact title across cursor-paginated `triage`, `draft`, `published` and `closed`
+ reports. Every `Link` continuation must remain on `api.github.com` and the same repository
+ advisory endpoint.
+- Body is built in process: `summary`, markdown `description`, `severity` (the maximum severity
+ across findings), `start_private_fork: false`. `vulnerabilities` is deliberately omitted — the
+ findings are source-level, not package-level.
+- When invoked locally, stdout is exactly one line: `report: submitted`, `report: existing`, `report: none` or
+ `report: failed`. Response bodies, status codes, GHSA identifiers and URLs are never printed.
+ These outcome tokens must never be forwarded to a public workflow.
+- Idempotent `GET` requests retry `5xx` at most twice with a fixed 5s delay. Report `POST` requests
+ are attempted exactly once because an ambiguous persisted POST must not create duplicates.
+ Every error fails closed.
+- Credentials come only from `SECURITY_ADVISORY_TOKEN`; configuration only from environment and
+ path arguments. `SECURITY_AUDIT_API_BASE` exists for tests and accepts an `http:` loopback origin
+ only, so a workflow cannot redirect submissions to a non-GitHub host.
+
+## Prompt assembly
+
+`actions/ai-inference` **concatenates** the system prompt and the prompt, so `system-prompt-file`
+is not a privileged channel. The payload is therefore assembled deliberately:
+
+1. `collect-corpus.mjs` generates a 24-byte run nonce, rewrites any occurrence of the static
+ delimiter sentinel inside a file body to a neutral marker, fences every file with
+ `<<>>>` / `…_END:>>>`, then re-counts the fences
+ and fails unless both counts equal the file count. It records the nonce in
+ `corpus-manifest.json`.
+2. `build-prompt.mjs` reads that manifest, re-verifies the nonce shape and fence integrity, and
+ renders two templates — injecting the nonce, the fences, and the category/severity/confidence
+ vocabularies straight from `lib/constants.mjs`, so the prompt can never drift from the
+ validator. Unresolved `{{TOKEN}}` placeholders are a hard error.
+3. `prompt.txt` = fenced corpus + `prompt-suffix.md`. The output contract is re-asserted **after**
+ the untrusted content, as the last thing the model reads.
+
+None of this is a security control on its own. `validate-response.mjs` is the enforceable
+boundary: it re-derives the allowlists from `constants.mjs`, requires each finding to anchor to a
+real corpus file and line, rejects credentials/GUIDs/absolute paths/weaponized payloads, and exits
+non-zero if anything was rejected.
+
+`prompt.md` and `prompt-suffix.md` are templates, not literal payloads — read the rendered
+`system.txt` / `prompt.txt` from a dry run to see what is actually sent.
+
+## Layout
+
+```
+lib/constants.mjs single source of truth: caps, allowlists, nonce API, statuses
+lib/mini-yaml.mjs fail-closed YAML-subset parser used by the tests
+lib/controls.mjs parses docs/SECURITY-CONTROLS.md into a code set
+lib/redaction.mjs reject/redact pattern sets
+check-action-pins.mjs YAML-aware action-reference extractor and pin policy
+ci-contracts.mjs non-disclosing normal-CI contract wrapper
+prompt.md auditor preamble template -> rendered to system.txt
+prompt-suffix.md trusted output contract -> appended after the corpus
+fixtures/ synthetic, malformed, unsafe, injection and delimiter fixtures
+tests/ node:test suites (no vitest, no coverage thresholds)
+```
+
+## Common invocations
+
+```bash
+node scripts/security-audit/validate-target.mjs --ref <40-hex-sha> --scope server-core
+node scripts/security-audit/validate-target.mjs --scope server-core # empty ref -> origin/main tip
+node scripts/security-audit/validate-npm-audit-inputs.mjs
+node scripts/security-audit/collect-corpus.mjs --scope server-core --out .security-audit
+node scripts/security-audit/build-prompt.mjs --corpus .security-audit --out .security-audit
+node scripts/security-audit/validate-response.mjs \
+ --response .security-audit/response.txt \
+ --manifest .security-audit/corpus-manifest.json \
+ --out .security-audit/model-report.json
+node scripts/security-audit/check-action-pins.mjs
+```
+
+The CI shape, where the audited commit lives under `target/`:
+
+```bash
+node scripts/security-audit/validate-npm-audit-inputs.mjs --dir target
+node scripts/security-audit/collect-corpus.mjs \
+ --scope server-core --out .security-audit --repo-root target
+node scripts/security-audit/check-action-pins.mjs \
+ --dir target/.github/workflows --root target
+```
+
+`validate-target.mjs` needs `refs/remotes/origin/main` to exist locally (the workflow checks out
+with `fetch-depth: 0`). `SECURITY_AUDIT_TEST_MODE=1` skips only the reachability check and is used
+by the test suite; no workflow sets it, and a test asserts that.
+
+Or via npm: `security:audit:dry-run`, `security:audit:test`, `security:audit:pins`,
+`security:audit:ci`.
+
+## Tests
+
+```bash
+npm run security:audit:test
+```
+
+- `pipeline.test.mjs` — target validation (scheduled/empty ref resolves to the `origin/main` tip,
+ branch names and short SHAs refused, unreachable SHAs refused, scope/model allowlists), corpus
+ caps, per-run nonce fences (two runs never share a nonce, a malformed nonce throws, the
+ repository's own `constants.mjs` is neutralized, and a forged-delimiter fixture cannot close the
+ fence), prompt assembly (the nonce reaches both rendered files, no `{{PLACEHOLDER}}` survives,
+ the trusted suffix follows the last corpus fence, and the vocabulary is injected from
+ `constants.mjs` so it cannot drift), schema validation, every rejection reason,
+ credential/shell smuggling, prompt injection, findings-cap overflow, redaction, sanitizers
+ reducing to counts with no advisory URLs, recursive local-action and reusable-workflow pin
+ coverage (including excluded trees, cycles, escapes, metadata ambiguity, missing targets, and
+ symlinks), the offline dry run, `--repo-root` isolation (the corpus
+ reads the audited tree, not the controller cwd, and keys stay repository-relative), the
+ historical-ancestor regression using a commit that predates these scripts, and a repo walk
+ proving no script creates issues, comments, or repository writes.
+- `submit-report.test.mjs` — the private reporting path, driven entirely against a loopback
+ `node:http` stub via `SECURITY_AUDIT_API_BASE`; **no test contacts GitHub.** Covers the request
+ body shape (no `vulnerabilities`, `start_private_fork: false`, maximum severity, title prefix and
+ length caps), `201` submission, Link-cursor de-duplication across `triage`, `draft`, `published`
+ and `closed` states, unsafe continuation rejection and the bounded-pagination fail-closed gate,
+ empty findings performing no HTTP at all, `403`/`404`/`422` failing closed, idempotent GET `5xx`
+ retries, ambiguous POST `5xx` responses never being retried, network errors failing closed, the
+ absence of a credential preventing any POST, and stdout being restricted to the four allowed
+ `report:` tokens with no status codes, bodies, GHSA identifiers or URLs.
+- `workflow-invariants.test.mjs` — parses the real workflow YAML and asserts: no PR triggers,
+ weekly Monday schedule, fixed default-branch repository dispatch, every audit job hard-disabled
+ behind a literal false gate with one generic public name, no result aggregator, deny-all workflow permissions,
+ **no write permission of any kind** (no `security-events: write` anywhere — model findings never
+ reach code scanning), per-job timeouts and concurrency, validated payload wiring, YAML-aware
+ action/digest pins, no shell interpolation of model output,
+ the model job is exactly and unconditionally disabled, environment-protected and tool-less, the
+ advisory credential is exposed only to the submit step and never to inference, no job uploads
+ artifacts, no job writes to `$GITHUB_STEP_SUMMARY`,
+ `--ignore-scripts` in the audit path, no workflow sets `SECURITY_AUDIT_TEST_MODE` (the
+ reachability escape hatch stays unreachable from CI), `persist-credentials: false`, dormant
+ finding exit codes are normalized without exposing their state, the controller checkout precedes the `target/` checkout in
+ every job that runs a helper, audited npm manifests are isolated from `target/`, the submit step
+ carries the audited SHA, and the legacy no-op gitleaks gate is gone.
+
+Fixtures never contain a literal credential; token-shaped strings are constructed at runtime so
+the repository's own secret scanner does not flag its test data.
diff --git a/scripts/security-audit/build-prompt.mjs b/scripts/security-audit/build-prompt.mjs
new file mode 100644
index 0000000..423df7a
--- /dev/null
+++ b/scripts/security-audit/build-prompt.mjs
@@ -0,0 +1,189 @@
+#!/usr/bin/env node
+/**
+ * Render the model prompt from the corpus manifest.
+ *
+ * Why this exists
+ * ---------------
+ * `actions/ai-inference` concatenates its system prompt and user prompt into a
+ * single Copilot CLI invocation. There is no separate privileged system role
+ * that untrusted content cannot reach, so instructions placed only *before* the
+ * corpus can be attacked with "ignore your earlier instructions" framing.
+ *
+ * This script therefore produces two artefacts:
+ *
+ * system.txt = rendered `prompt.md` (preamble, before the corpus)
+ * prompt.txt = corpus + rendered `prompt-suffix.md` (trusted suffix, last word)
+ *
+ * The effective concatenation is `[preamble][corpus][suffix]`, so the immutable
+ * output contract is asserted both before and after untrusted content.
+ *
+ * It also resolves the per-run delimiter nonce into both templates, so the model
+ * is told the exact fence it must trust, and injects the finding vocabulary
+ * straight from `lib/constants.mjs` so the prompt cannot drift away from
+ * `validate-response.mjs`.
+ *
+ * Neither the preamble nor the suffix is a security control. The enforceable
+ * boundary is `scripts/security-audit/validate-response.mjs`.
+ *
+ * Usage:
+ * node scripts/security-audit/build-prompt.mjs --corpus --out
+ */
+
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+import {
+ CATEGORIES,
+ CONFIDENCES,
+ MAX_FIELD_CHARS,
+ MAX_FINDINGS,
+ SEVERITIES,
+ corpusDelimiters,
+} from './lib/constants.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const PREAMBLE_TEMPLATE = path.join(HERE, 'prompt.md');
+const SUFFIX_TEMPLATE = path.join(HERE, 'prompt-suffix.md');
+
+/** Matches an HTML comment block, used to strip template documentation. */
+const HTML_COMMENT_RE = /\n?/g;
+
+/** Matches any unresolved `{{TOKEN}}` placeholder. */
+const PLACEHOLDER_RE = /\{\{[A-Z_]+\}\}/g;
+
+function parseArgs(argv) {
+ const args = { corpus: '', out: '' };
+ for (let i = 0; i < argv.length; i += 1) {
+ const key = argv[i];
+ const value = argv[i + 1];
+ if (key === '--corpus') {
+ args.corpus = value ?? '';
+ i += 1;
+ } else if (key === '--out') {
+ args.out = value ?? '';
+ i += 1;
+ }
+ }
+ return args;
+}
+
+function fail(message) {
+ process.stderr.write(`build-prompt: ${message}\n`);
+ process.exit(2);
+}
+
+/**
+ * Substitute template placeholders and strip template documentation comments.
+ *
+ * @param {string} template Raw template text.
+ * @param {Record} values Placeholder values, keyed without braces.
+ * @returns {string}
+ */
+export function renderTemplate(template, values) {
+ const stripped = String(template).replace(HTML_COMMENT_RE, '');
+ const rendered = stripped.replace(/\{\{([A-Z_]+)\}\}/g, (match, token) => {
+ if (!Object.hasOwn(values, token)) {
+ throw new Error(`unknown template placeholder: ${match}`);
+ }
+ return values[token];
+ });
+ const leftover = rendered.match(PLACEHOLDER_RE);
+ if (leftover) {
+ throw new Error(`unresolved template placeholders: ${leftover.join(', ')}`);
+ }
+ return rendered.trimStart();
+}
+
+/**
+ * Build placeholder values for a run.
+ *
+ * @param {string} nonce Hex nonce recorded in the corpus manifest.
+ * @returns {Record}
+ */
+export function templateValues(nonce) {
+ const delimiters = corpusDelimiters(nonce);
+ return {
+ CORPUS_NONCE: delimiters.nonce,
+ FENCE_BEGIN: delimiters.begin,
+ FENCE_END: delimiters.end,
+ CATEGORIES: CATEGORIES.map((entry) => `\`${entry}\``).join(', '),
+ SEVERITIES: SEVERITIES.map((entry) => `\`${entry}\``).join(', '),
+ CONFIDENCES: CONFIDENCES.map((entry) => `\`${entry}\``).join(', '),
+ MAX_FINDINGS: String(MAX_FINDINGS),
+ MAX_FIELD_CHARS: String(MAX_FIELD_CHARS),
+ };
+}
+
+function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.corpus) {
+ fail('--corpus is required');
+ }
+ if (!args.out) {
+ fail('--out is required');
+ }
+
+ const manifestPath = path.join(args.corpus, 'corpus-manifest.json');
+ const corpusPath = path.join(args.corpus, 'corpus.txt');
+
+ let manifest;
+ try {
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
+ } catch (error) {
+ fail(`unable to read corpus manifest: ${error.message}`);
+ }
+
+ const nonce = manifest?.nonce;
+ if (typeof nonce !== 'string' || !/^[0-9a-f]{16,}$/.test(nonce)) {
+ fail('corpus manifest does not contain a usable delimiter nonce');
+ }
+
+ let corpus;
+ try {
+ corpus = readFileSync(corpusPath, 'utf8');
+ } catch (error) {
+ fail(`unable to read corpus: ${error.message}`);
+ }
+
+ const delimiters = corpusDelimiters(nonce);
+ const expected = Number(manifest.fileCount ?? 0);
+ const beginCount = corpus.split(delimiters.begin).length - 1;
+ const endCount = corpus.split(delimiters.end).length - 1;
+ if (beginCount !== expected || endCount !== expected) {
+ fail(
+ `corpus fence integrity check failed: expected ${expected} begin/end pairs, ` +
+ `found ${beginCount}/${endCount}`,
+ );
+ }
+
+ let system;
+ let suffix;
+ try {
+ const values = templateValues(nonce);
+ system = renderTemplate(readFileSync(PREAMBLE_TEMPLATE, 'utf8'), values);
+ suffix = renderTemplate(readFileSync(SUFFIX_TEMPLATE, 'utf8'), values);
+ } catch (error) {
+ fail(error.message);
+ }
+
+ mkdirSync(args.out, { recursive: true });
+ const systemPath = path.join(args.out, 'system.txt');
+ const promptPath = path.join(args.out, 'prompt.txt');
+
+ writeFileSync(systemPath, system, 'utf8');
+ writeFileSync(promptPath, `${corpus.replace(/\s*$/, '')}\n\n${suffix}`, 'utf8');
+
+ if (process.env.GITHUB_ACTIONS !== 'true') {
+ process.stdout.write(
+ `build-prompt: system=${systemPath} prompt=${promptPath} files=${expected} ` +
+ `systemBytes=${Buffer.byteLength(system)} promptBytes=${Buffer.byteLength(
+ readFileSync(promptPath, 'utf8'),
+ )}\n`,
+ );
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main();
+}
diff --git a/scripts/security-audit/check-action-pins.mjs b/scripts/security-audit/check-action-pins.mjs
new file mode 100644
index 0000000..9a8665a
--- /dev/null
+++ b/scripts/security-audit/check-action-pins.mjs
@@ -0,0 +1,1297 @@
+#!/usr/bin/env node
+/**
+ * Verifies that every external action reference is immutable and carries a
+ * human-readable version comment. This covers `uses:` values, external
+ * `runs.image: docker://...` values, workflow job/service container images,
+ * and every external base/frontend image used by a Dockerfile-backed local
+ * action.
+ *
+ * A floating tag (`@v4`) is mutable: whoever controls the tag controls what runs
+ * inside the workflow, including in the job that holds the advisory credential
+ * used to file a private vulnerability report.
+ * Local (`./…`) `uses:` references are resolved from the repository root and
+ * traversed recursively, even when they live in directories excluded from broad
+ * discovery. Docker references must use an
+ * immutable `sha256` digest. A local action that names a Dockerfile causes that
+ * exact file to be parsed; dynamic, missing, escaping, or ambiguous image
+ * references fail closed, and `ADD` sources must be provably literal local
+ * paths.
+ *
+ * The check parses YAML before inspecting executable references. Unsupported
+ * or ambiguous constructs fail closed rather than being ignored.
+ *
+ * Both surfaces are scanned:
+ * - every `*.yml` / `*.yaml` under the workflow directory, recursively; and
+ * - every local action (`action.yml` / `action.yaml`) anywhere under the
+ * repository root. Composite actions can contain nested `uses:` values, and
+ * Docker actions can name a registry image in `runs.image`; both execute with
+ * the calling workflow's trust and are invisible to a workflow-only scan.
+ *
+ * Usage:
+ * node scripts/security-audit/check-action-pins.mjs [--dir .github/workflows] [--root .]
+ */
+
+import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import {
+ LineCounter,
+ isAlias,
+ isMap,
+ isPair,
+ isScalar,
+ isSeq,
+ parseAllDocuments,
+} from 'yaml';
+
+const SHA_RE = /^[0-9a-f]{40}$/;
+const STATIC_IMAGE_PREFIX = '[A-Za-z0-9][A-Za-z0-9._:/-]*';
+const DOCKER_DIGEST_RE = new RegExp(
+ `^docker://${STATIC_IMAGE_PREFIX}@sha256:[0-9a-fA-F]{64}$`,
+);
+const CONTAINER_IMAGE_DIGEST_RE = new RegExp(
+ `^${STATIC_IMAGE_PREFIX}@sha256:[0-9a-fA-F]{64}$`,
+);
+const REMOTE_SOURCE_RE = /^(?:[A-Za-z][A-Za-z0-9+.-]*:|git@)/u;
+const DYNAMIC_ADD_SOURCE_RE = /\$/u;
+const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage', '.security-audit']);
+const ACTION_METADATA_NAMES = new Set(['action.yml', 'action.yaml']);
+
+/**
+ * Returns the YAML comment on a physical line, ignoring `#` characters inside
+ * quoted scalars.
+ *
+ * @param {string} line
+ * @returns {string}
+ */
+function lineComment(line) {
+ let inSingle = false;
+ let inDouble = false;
+ for (let index = 0; index < line.length; index += 1) {
+ const character = line[index];
+ if (character === "'" && !inDouble) {
+ if (inSingle && line[index + 1] === "'") {
+ index += 1;
+ continue;
+ }
+ inSingle = !inSingle;
+ continue;
+ }
+ if (character === '"' && !inSingle) {
+ let escaped = false;
+ for (let cursor = index - 1; cursor >= 0 && line[cursor] === '\\'; cursor -= 1) {
+ escaped = !escaped;
+ }
+ if (!escaped) inDouble = !inDouble;
+ continue;
+ }
+ if (
+ character === '#' &&
+ !inSingle &&
+ !inDouble &&
+ (index === 0 || /\s/u.test(line[index - 1]))
+ ) {
+ return line.slice(index + 1).trim();
+ }
+ }
+ return '';
+}
+
+/**
+ * Reads a scalar string pair and records its physical line and trailing comment.
+ *
+ * @param {import('yaml').Pair} pair
+ * @param {string} keyName
+ * @param {string} file
+ * @param {LineCounter} lineCounter
+ * @param {string[]} lines
+ * @returns {{ line: number, value: string, comment: string }}
+ */
+function stringPairRecord(pair, keyName, file, lineCounter, lines) {
+ if (
+ !isScalar(pair.value) ||
+ typeof pair.value.value !== 'string' ||
+ !pair.key.range ||
+ !pair.value.range
+ ) {
+ throw new Error(`${file}: every ${keyName} value must be a scalar string`);
+ }
+
+ const keyPosition = lineCounter.linePos(pair.key.range[0]);
+ const valueStart = lineCounter.linePos(pair.value.range[0]);
+ const valueEnd = lineCounter.linePos(pair.value.range[1]);
+ if (keyPosition.line !== valueStart.line || valueStart.line !== valueEnd.line) {
+ throw new Error(`${file}:${keyPosition.line}: multi-line ${keyName} values are not supported`);
+ }
+
+ return {
+ line: keyPosition.line,
+ value: pair.value.value,
+ comment: lineComment(lines[keyPosition.line - 1] ?? ''),
+ };
+}
+
+/**
+ * Parses a workflow or local action and returns its document plus every `uses`
+ * mapping.
+ *
+ * The traversal is intentionally schema-agnostic and conservative: a `uses`
+ * key in any mapping is checked. Aliases, anchors, tags, merge keys, complex
+ * keys, multi-document streams, and multi-line `uses` values are rejected
+ * because they can make the executable reference ambiguous.
+ *
+ * @param {string} text
+ * @param {string} file
+ * @returns {{
+ * document: import('yaml').Document,
+ * lineCounter: LineCounter,
+ * lines: string[],
+ * references: Array<{ file: string, line: number, uses: string, comment: string }>
+ * }}
+ */
+function parsePolicySource(text, file) {
+ const lineCounter = new LineCounter();
+ const documents = parseAllDocuments(text, {
+ lineCounter,
+ prettyErrors: false,
+ strict: true,
+ uniqueKeys: true,
+ });
+
+ if (documents.length !== 1) {
+ throw new Error(`${file}: expected exactly one YAML document`);
+ }
+
+ const document = documents[0];
+ const problems = [...document.errors, ...document.warnings];
+ if (problems.length > 0) {
+ throw new Error(`${file}: invalid YAML: ${problems[0].message}`);
+ }
+
+ const lines = text.split(/\r?\n/);
+ /** @type {Array<{ file: string, line: number, uses: string, comment: string }>} */
+ const references = [];
+
+ /** @param {unknown} node */
+ function walk(node) {
+ if (node === null || node === undefined) return;
+ if (isAlias(node)) {
+ throw new Error(`${file}: YAML aliases are not supported by the pin policy`);
+ }
+ if (
+ typeof node === 'object' &&
+ node !== null &&
+ ('anchor' in node || 'tag' in node) &&
+ (node.anchor || node.tag)
+ ) {
+ throw new Error(`${file}: YAML anchors and tags are not supported by the pin policy`);
+ }
+ if (isSeq(node)) {
+ for (const item of node.items) walk(item);
+ return;
+ }
+ if (!isMap(node)) return;
+
+ for (const pair of node.items) {
+ if (!isPair(pair) || !isScalar(pair.key) || typeof pair.key.value !== 'string') {
+ throw new Error(`${file}: complex YAML mapping keys are not supported by the pin policy`);
+ }
+ if (pair.key.value === '<<') {
+ throw new Error(`${file}: YAML merge keys are not supported by the pin policy`);
+ }
+
+ if (pair.key.value === 'uses') {
+ const record = stringPairRecord(pair, 'uses', file, lineCounter, lines);
+
+ references.push({
+ file,
+ line: record.line,
+ uses: record.value,
+ comment: record.comment,
+ });
+ }
+
+ walk(pair.value);
+ }
+ }
+
+ walk(document.contents);
+ return { document, lineCounter, lines, references };
+}
+
+/**
+ * Parses a workflow or local action and returns every `uses` mapping.
+ *
+ * @param {string} text
+ * @param {string} file
+ * @returns {Array<{ file: string, line: number, uses: string, comment: string }>}
+ */
+export function extractUses(text, file) {
+ return parsePolicySource(text, file).references;
+}
+
+/**
+ * Returns the image declared by a local Docker action.
+ *
+ * Repository Dockerfiles are local code and therefore do not use the
+ * `docker://` registry-reference form. External images do, and must pass the
+ * same digest/comment policy as a Docker `uses:` value.
+ *
+ * @param {ReturnType} parsed
+ * @param {string} file
+ * @returns {{ file: string, line: number, uses: string, comment: string } | null}
+ */
+function extractLocalDockerImage(parsed, file) {
+ const root = parsed.document.contents;
+ if (!isMap(root)) return null;
+
+ const runsPair = root.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'runs',
+ );
+ if (!runsPair) return null;
+ if (!isMap(runsPair.value)) {
+ throw new Error(`${file}: runs must be a YAML mapping`);
+ }
+
+ const usingPair = runsPair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'using',
+ );
+ if (!usingPair) return null;
+ const using = stringPairRecord(
+ usingPair,
+ 'runs.using',
+ file,
+ parsed.lineCounter,
+ parsed.lines,
+ );
+ if (using.value.toLowerCase() !== 'docker') return null;
+
+ const imagePair = runsPair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'image',
+ );
+ if (!imagePair) {
+ throw new Error(`${file}: a Docker action must declare runs.image`);
+ }
+ const image = stringPairRecord(
+ imagePair,
+ 'runs.image',
+ file,
+ parsed.lineCounter,
+ parsed.lines,
+ );
+ return {
+ file,
+ line: image.line,
+ uses: image.value,
+ comment: image.comment,
+ };
+}
+
+/**
+ * Returns every job-level and service container image in a workflow.
+ *
+ * GitHub accepts both `container: image` and `container: { image: image }`.
+ * Service images use `services..image`. All execute registry content
+ * before repository steps, so they use the same digest and version-comment
+ * policy as `docker://` action references.
+ *
+ * @param {ReturnType} parsed
+ * @param {string} file
+ * @returns {Array<{ file: string, line: number, uses: string, comment: string }>}
+ */
+function extractWorkflowContainerImages(parsed, file) {
+ const root = parsed.document.contents;
+ if (!isMap(root)) return [];
+
+ const jobsPair = root.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'jobs',
+ );
+ if (!jobsPair) return [];
+ if (!isMap(jobsPair.value)) {
+ throw new Error(`${file}: jobs must be a YAML mapping`);
+ }
+
+ /** @type {Array<{ file: string, line: number, uses: string, comment: string }>} */
+ const images = [];
+
+ /**
+ * @param {import('yaml').Pair} pair
+ * @param {string} keyName
+ */
+ function addImage(pair, keyName) {
+ const record = stringPairRecord(
+ pair,
+ keyName,
+ file,
+ parsed.lineCounter,
+ parsed.lines,
+ );
+ images.push({
+ file,
+ line: record.line,
+ uses: `docker://${record.value}`,
+ comment: record.comment,
+ });
+ }
+
+ for (const jobPair of jobsPair.value.items) {
+ if (!isPair(jobPair) || !isScalar(jobPair.key) || typeof jobPair.key.value !== 'string') {
+ throw new Error(`${file}: job names must be scalar strings`);
+ }
+ if (!isMap(jobPair.value)) {
+ throw new Error(`${file}: jobs.${jobPair.key.value} must be a YAML mapping`);
+ }
+
+ const containerPair = jobPair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'container',
+ );
+ if (containerPair) {
+ if (isScalar(containerPair.value)) {
+ addImage(containerPair, `jobs.${jobPair.key.value}.container`);
+ } else if (isMap(containerPair.value)) {
+ const imagePair = containerPair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'image',
+ );
+ if (!imagePair) {
+ throw new Error(`${file}: jobs.${jobPair.key.value}.container must declare image`);
+ }
+ addImage(imagePair, `jobs.${jobPair.key.value}.container.image`);
+ } else {
+ throw new Error(`${file}: jobs.${jobPair.key.value}.container is unsupported`);
+ }
+ }
+
+ const servicesPair = jobPair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'services',
+ );
+ if (!servicesPair) continue;
+ if (!isMap(servicesPair.value)) {
+ throw new Error(`${file}: jobs.${jobPair.key.value}.services must be a YAML mapping`);
+ }
+ for (const servicePair of servicesPair.value.items) {
+ if (
+ !isPair(servicePair) ||
+ !isScalar(servicePair.key) ||
+ typeof servicePair.key.value !== 'string'
+ ) {
+ throw new Error(`${file}: service names must be scalar strings`);
+ }
+ if (!isMap(servicePair.value)) {
+ throw new Error(
+ `${file}: jobs.${jobPair.key.value}.services.${servicePair.key.value} must be a YAML mapping`,
+ );
+ }
+ const imagePair = servicePair.value.items.find(
+ (pair) => isPair(pair) && isScalar(pair.key) && pair.key.value === 'image',
+ );
+ if (!imagePair) {
+ throw new Error(
+ `${file}: jobs.${jobPair.key.value}.services.${servicePair.key.value} must declare image`,
+ );
+ }
+ addImage(
+ imagePair,
+ `jobs.${jobPair.key.value}.services.${servicePair.key.value}.image`,
+ );
+ }
+ }
+
+ return images;
+}
+
+/**
+ * Split an instruction shell fragment into tokens.
+ *
+ * Supports the quoting needed by the repository's Dockerfiles and rejects
+ * unterminated quotes or dangling escapes fail-closed.
+ *
+ * @param {string} text
+ * @param {string} file
+ * @param {number} line
+ * @returns {string[]}
+ */
+function splitShellWords(text, file, line) {
+ /** @type {string[]} */
+ const tokens = [];
+ let current = '';
+ let quote = '';
+ let escaped = false;
+
+ for (const character of text) {
+ if (escaped) {
+ current += character;
+ escaped = false;
+ continue;
+ }
+ if (quote === "'") {
+ if (character === "'") {
+ quote = '';
+ } else {
+ current += character;
+ }
+ continue;
+ }
+ if (quote === '"') {
+ if (character === '"') {
+ quote = '';
+ } else if (character === '\\') {
+ escaped = true;
+ } else {
+ current += character;
+ }
+ continue;
+ }
+ if (/\s/u.test(character)) {
+ if (current !== '') {
+ tokens.push(current);
+ current = '';
+ }
+ continue;
+ }
+ if (character === "'" || character === '"') {
+ quote = character;
+ continue;
+ }
+ if (character === '\\') {
+ escaped = true;
+ continue;
+ }
+ current += character;
+ }
+
+ if (escaped || quote !== '') {
+ throw new Error(`${file}:${line}: unsupported Dockerfile quoting or escaping`);
+ }
+ if (current !== '') tokens.push(current);
+ return tokens;
+}
+
+/**
+ * @param {string} text
+ * @param {string} file
+ * @param {number} line
+ * @returns {{ options: string[], remainder: string }}
+ */
+function consumeInstructionOptions(text, file, line) {
+ const options = [];
+ let remainder = text.trim();
+ while (remainder.startsWith('--')) {
+ const match = /^(--[A-Za-z][A-Za-z0-9-]*(?:=[^\s]+)?)(?:\s+|$)/u.exec(remainder);
+ if (!match) {
+ throw new Error(`${file}:${line}: unsupported Dockerfile option syntax`);
+ }
+ options.push(match[1]);
+ remainder = remainder.slice(match[0].length).trimStart();
+ }
+ return { options, remainder };
+}
+
+/**
+ * @param {string} text
+ * @param {string} file
+ * @param {number} line
+ * @param {string} instruction
+ * @returns {string[]}
+ */
+function parseJsonStringArray(text, file, line, instruction) {
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ throw new Error(`${file}:${line}: unsupported ${instruction} JSON-array syntax`);
+ }
+ if (
+ !Array.isArray(parsed) ||
+ parsed.length < 2 ||
+ parsed.some((value) => typeof value !== 'string')
+ ) {
+ throw new Error(`${file}:${line}: unsupported ${instruction} JSON-array syntax`);
+ }
+ return parsed;
+}
+
+/**
+ * @param {string} text
+ * @param {string} file
+ * @param {number} line
+ * @param {'ADD' | 'COPY'} instruction
+ * @returns {{ options: string[], sources: string[] }}
+ */
+function parseCopyLikeInstruction(text, file, line, instruction) {
+ const { options, remainder } = consumeInstructionOptions(text, file, line);
+ if (remainder === '') {
+ throw new Error(`${file}:${line}: ${instruction} sources are missing`);
+ }
+ if (remainder.startsWith('[')) {
+ const values = parseJsonStringArray(remainder, file, line, instruction);
+ return { options, sources: values.slice(0, -1) };
+ }
+
+ const tokens = splitShellWords(remainder, file, line);
+ if (tokens.length < 2) {
+ throw new Error(`${file}:${line}: ${instruction} sources are missing`);
+ }
+ return { options, sources: tokens.slice(0, -1) };
+}
+
+/**
+ * @param {string} option
+ * @param {string} key
+ * @returns {string | null}
+ */
+function optionValue(option, key) {
+ return option.startsWith(`--${key}=`) ? option.slice(key.length + 3) : null;
+}
+
+/**
+ * @param {string} value
+ * @param {string} file
+ * @param {number} line
+ * @returns {Map}
+ */
+function parseMountSpec(value, file, line) {
+ const options = new Map();
+ for (const fragment of value.split(',')) {
+ if (fragment === '') {
+ throw new Error(`${file}:${line}: unsupported Dockerfile mount syntax`);
+ }
+ const separator = fragment.indexOf('=');
+ if (separator === -1) {
+ options.set(fragment, '');
+ continue;
+ }
+ const key = fragment.slice(0, separator);
+ const setting = fragment.slice(separator + 1);
+ if (key === '' || setting === '') {
+ throw new Error(`${file}:${line}: unsupported Dockerfile mount syntax`);
+ }
+ options.set(key, setting);
+ }
+ return options;
+}
+
+/**
+ * @param {string} reference
+ * @param {string} file
+ * @param {number} line
+ * @param {Set} stageAliases
+ * @param {number} stageCount
+ * @param {string} versionComment
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+function validateDockerImageReference(
+ reference,
+ file,
+ line,
+ stageAliases,
+ stageCount,
+ versionComment,
+) {
+ const normalized = reference.toLowerCase();
+ if (normalized === 'scratch') return [];
+ if (/^\d+$/u.test(reference)) {
+ if (Number(reference) >= stageCount) {
+ throw new Error(`${file}:${line}: unsupported Docker stage reference`);
+ }
+ return [];
+ }
+ if (stageAliases.has(normalized)) return [];
+ if (CONTAINER_IMAGE_DIGEST_RE.test(reference)) {
+ return versionComment === ''
+ ? [{ file, line, uses: reference, reason: 'missing-version-comment' }]
+ : [];
+ }
+ return [{ file, line, uses: reference, reason: 'not-digest-pinned' }];
+}
+
+/**
+ * `ADD` can import either local build-context paths or remote URLs. The pin
+ * policy accepts only sources that remain provably local after this parser has
+ * resolved quoting/JSON-array syntax; any `$`-driven interpolation is
+ * unsupported because the parser cannot prove what concrete source Docker will
+ * fetch after environment replacement.
+ *
+ * @param {string} source
+ * @param {string} file
+ * @param {number} line
+ * @returns {{ file: string, line: number, uses: string, reason: string } | null}
+ */
+function validateAddSource(source, file, line) {
+ if (DYNAMIC_ADD_SOURCE_RE.test(source)) {
+ return { file, line, uses: source, reason: 'unsupported-dynamic-source' };
+ }
+ if (REMOTE_SOURCE_RE.test(source)) {
+ return { file, line, uses: source, reason: 'unsupported-remote-source' };
+ }
+ return null;
+}
+
+/**
+ * Parses a Dockerfile conservatively and checks every explicit external
+ * frontend/image reference plus every Dockerfile construct that can import
+ * external content declaratively (`ADD`, `COPY --from`, `RUN --mount=from`).
+ * Local stage aliases, prior numeric stage indexes, and `scratch` are treated
+ * as in-repository / in-file references rather than registry inputs. `ADD`
+ * sources must stay literal local paths after parsing; remote or dynamic
+ * sources are rejected.
+ *
+ * @param {string} text
+ * @param {string} file
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+export function checkDockerfileSource(text, file) {
+ /** @type {Array<{ file: string, line: number, uses: string, reason: string }>} */
+ const violations = [];
+ /** @type {Array<{ line: number, value: string, versionComment: string }>} */
+ const instructions = [];
+ const stageAliases = new Set();
+ let stageCount = 0;
+ const lines = text.split(/\r?\n/);
+ let pending = '';
+ let pendingLine = 0;
+ let pendingVersionComment = '';
+ let instructionVersionComment = '';
+
+ for (let index = 0; index < lines.length; index += 1) {
+ const raw = lines[index];
+ const line = index + 1;
+
+ if (pending === '' && /^\s*#\s*syntax\b/i.test(raw)) {
+ const directive = /^\s*#\s*syntax\s*=\s*(\S+)\s*$/i.exec(raw);
+ if (!directive) {
+ throw new Error(`${file}:${line}: unsupported Dockerfile syntax directive`);
+ }
+ const image = directive[1];
+ if (!CONTAINER_IMAGE_DIGEST_RE.test(image)) {
+ violations.push({ file, line, uses: image, reason: 'not-digest-pinned' });
+ } else {
+ const version = /^\s*#\s*pin-version\s*:\s*(\S.*)\s*$/i.exec(lines[index + 1] ?? '');
+ if (!version) {
+ violations.push({ file, line, uses: image, reason: 'missing-version-comment' });
+ } else {
+ index += 1;
+ }
+ }
+ continue;
+ }
+
+ if (pending === '' && /^\s*#\s*escape\b/i.test(raw)) {
+ const directive = /^\s*#\s*escape\s*=\s*(\S+)\s*$/i.exec(raw);
+ if (!directive || directive[1] !== '\\') {
+ throw new Error(`${file}:${line}: unsupported Dockerfile escape directive`);
+ }
+ continue;
+ }
+
+ if (/^\s*$/.test(raw)) {
+ if (pending !== '') {
+ throw new Error(`${file}:${line}: comments inside continued instructions are not supported`);
+ }
+ pendingVersionComment = '';
+ continue;
+ }
+
+ if (/^\s*#/.test(raw)) {
+ if (pending !== '') {
+ throw new Error(`${file}:${line}: comments inside continued instructions are not supported`);
+ }
+ const version = /^\s*#\s*pin-version\s*:\s*(\S.*)\s*$/i.exec(raw);
+ pendingVersionComment = version ? version[1] : '';
+ continue;
+ }
+
+ const trimmed = raw.trimEnd();
+ const continues = /\\$/.test(trimmed);
+ const fragment = continues ? trimmed.slice(0, -1) : trimmed;
+ if (pending === '') {
+ pendingLine = line;
+ instructionVersionComment = pendingVersionComment;
+ pendingVersionComment = '';
+ }
+ pending += `${fragment.trim()} `;
+ if (continues) continue;
+
+ instructions.push({
+ line: pendingLine,
+ value: pending.trim(),
+ versionComment: instructionVersionComment,
+ });
+ pending = '';
+ pendingLine = 0;
+ instructionVersionComment = '';
+ }
+
+ if (pending !== '') {
+ throw new Error(`${file}:${pendingLine}: unterminated Dockerfile line continuation`);
+ }
+
+ for (const instruction of instructions) {
+ // Dockerfile heredocs introduce an embedded language whose body may contain
+ // text that looks like a top-level FROM instruction. Until this parser tracks
+ // heredoc delimiters explicitly, accepting one could let body text create a
+ // fake stage alias that masks a later floating external image.
+ if (/<<-?\s*['"]?[A-Za-z0-9_.-]+/.test(instruction.value)) {
+ throw new Error(`${file}:${instruction.line}: Dockerfile heredocs are not supported`);
+ }
+
+ const match = /^([A-Za-z]+)\s+(.+)$/u.exec(instruction.value);
+ if (!match) {
+ throw new Error(`${file}:${instruction.line}: unsupported Dockerfile instruction`);
+ }
+
+ const keyword = match[1].toUpperCase();
+ const body = match[2].trim();
+ if (keyword === 'FROM') {
+ const tokens = splitShellWords(body, file, instruction.line);
+ while (tokens[0]?.startsWith('--')) {
+ const option = tokens.shift();
+ if (!/^--[A-Za-z][A-Za-z0-9-]*=\S+$/u.test(option)) {
+ throw new Error(`${file}:${instruction.line}: unsupported Dockerfile FROM option`);
+ }
+ }
+ const image = tokens.shift();
+ if (!image) {
+ throw new Error(`${file}:${instruction.line}: Dockerfile FROM image is missing`);
+ }
+
+ let alias = '';
+ if (tokens.length > 0) {
+ if (
+ tokens.length !== 2 ||
+ tokens[0].toLowerCase() !== 'as' ||
+ !/^[A-Za-z0-9_.-]+$/u.test(tokens[1])
+ ) {
+ throw new Error(`${file}:${instruction.line}: unsupported Dockerfile FROM syntax`);
+ }
+ alias = tokens[1].toLowerCase();
+ }
+
+ violations.push(
+ ...validateDockerImageReference(
+ image,
+ file,
+ instruction.line,
+ stageAliases,
+ stageCount,
+ instruction.versionComment,
+ ),
+ );
+
+ if (alias !== '') stageAliases.add(alias);
+ stageCount += 1;
+ continue;
+ }
+
+ if (keyword === 'ADD' || keyword === 'COPY') {
+ const parsed = parseCopyLikeInstruction(body, file, instruction.line, keyword);
+ for (const option of parsed.options) {
+ const from = optionValue(option, 'from');
+ if (from !== null) {
+ violations.push(
+ ...validateDockerImageReference(
+ from,
+ file,
+ instruction.line,
+ stageAliases,
+ stageCount,
+ instruction.versionComment,
+ ),
+ );
+ }
+ }
+ if (keyword === 'ADD') {
+ for (const source of parsed.sources) {
+ const violation = validateAddSource(source, file, instruction.line);
+ if (violation) violations.push(violation);
+ }
+ }
+ continue;
+ }
+
+ if (keyword === 'RUN') {
+ const { options } = consumeInstructionOptions(body, file, instruction.line);
+ for (const option of options) {
+ const mount = optionValue(option, 'mount');
+ if (mount === null) continue;
+ const settings = parseMountSpec(mount, file, instruction.line);
+ if (!settings.has('from')) continue;
+ violations.push(
+ ...validateDockerImageReference(
+ settings.get('from'),
+ file,
+ instruction.line,
+ stageAliases,
+ stageCount,
+ instruction.versionComment,
+ ),
+ );
+ }
+ }
+ }
+
+ return violations;
+}
+
+/**
+ * Resolves and checks a Dockerfile referenced by local action metadata.
+ *
+ * @param {{ file: string, line: number, uses: string }} image
+ * @param {string} rootReal
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+function checkReferencedDockerfile(image, rootReal) {
+ const reference = image.uses;
+ if (
+ reference === '' ||
+ reference.trim() !== reference ||
+ reference.includes('\\') ||
+ reference.includes('\0') ||
+ reference.includes('$') ||
+ isAbsolute(reference)
+ ) {
+ throw new Error(`${image.file}:${image.line}: unprovable local Dockerfile reference`);
+ }
+
+ const absolute = resolve(dirname(image.file), reference);
+ const lexical = relative(rootReal, absolute);
+ if (lexical !== '' && (lexical.startsWith('..') || isAbsolute(lexical))) {
+ throw new Error(`${image.file}:${image.line}: local Dockerfile escapes the scan root`);
+ }
+
+ const stats = inspectRepositoryPath(rootReal, absolute, image.file, image.line);
+ if (!stats.isFile()) {
+ throw new Error(`${image.file}:${image.line}: local Dockerfile is not a regular file`);
+ }
+
+ return checkDockerfileSource(
+ readFileSync(absolute, 'utf8'),
+ absolute.split('\\').join('/'),
+ );
+}
+
+/**
+ * @param {Array<{ file: string, line: number, uses: string, comment: string }>} references
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+function checkReferences(references) {
+ /** @type {Array<{ file: string, line: number, uses: string, reason: string }>} */
+ const violations = [];
+
+ for (const { file, line, uses: reference, comment } of references) {
+ if (reference.startsWith('./')) continue;
+ const at = reference.lastIndexOf('@');
+ const record = { file, line, uses: reference };
+
+ if (reference.startsWith('docker://')) {
+ if (!DOCKER_DIGEST_RE.test(reference)) {
+ violations.push({ ...record, reason: 'not-digest-pinned' });
+ continue;
+ }
+ if (comment === '') {
+ violations.push({ ...record, reason: 'missing-version-comment' });
+ }
+ continue;
+ }
+
+ if (at === -1) {
+ violations.push({ ...record, reason: 'missing-ref' });
+ continue;
+ }
+
+ const ref = reference.slice(at + 1);
+ if (!SHA_RE.test(ref)) {
+ violations.push({ ...record, reason: 'not-sha-pinned' });
+ continue;
+ }
+
+ if (comment === '') {
+ violations.push({ ...record, reason: 'missing-version-comment' });
+ }
+ }
+
+ return violations;
+}
+
+/**
+ * @param {string} text
+ * @param {string} file
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+export function checkWorkflowSource(text, file) {
+ const parsed = parsePolicySource(text, file);
+ return checkReferences([
+ ...parsed.references,
+ ...extractWorkflowContainerImages(parsed, file),
+ ]);
+}
+
+/**
+ * Checks nested `uses:` values and `runs.image` metadata in a local action.
+ * Dockerfile-backed actions require `rootReal` so their external images cannot
+ * hide behind an uninspected local path.
+ *
+ * @param {string} text
+ * @param {string} file
+ * @param {string} [rootReal]
+ * @returns {Array<{ file: string, line: number, uses: string, reason: string }>}
+ */
+export function checkLocalActionSource(text, file, rootReal) {
+ const parsed = parsePolicySource(text, file);
+ const image = extractLocalDockerImage(parsed, file);
+ const references = [
+ ...parsed.references,
+ ...(image?.uses.startsWith('docker://') ? [image] : []),
+ ];
+ const violations = checkReferences(references);
+
+ if (image && !image.uses.startsWith('docker://')) {
+ if (!rootReal) {
+ throw new Error(`${file}:${image.line}: local Dockerfile requires filesystem context`);
+ }
+ violations.push(...checkReferencedDockerfile(image, rootReal));
+ }
+
+ return violations;
+}
+
+/**
+ * Resolves `absolute` and asserts the real path stays inside `rootReal`.
+ *
+ * The walk refuses to follow symlinks, but a caller can still point `--root` or
+ * `--dir` at a path whose *ancestors* are links. Re-checking containment on every
+ * visited entry keeps the scan confined to a single real directory tree even when
+ * the entry point itself was reached through a link.
+ *
+ * @param {string} rootReal Canonical (already realpath-resolved) scan root.
+ * @param {string} absolute Path to verify.
+ * @returns {string} The canonical path of `absolute`.
+ */
+function assertWithinRoot(rootReal, absolute) {
+ let real;
+ try {
+ real = realpathSync.native(absolute);
+ } catch (error) {
+ throw new Error(
+ `security-audit: cannot resolve ${absolute}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ const rel = relative(rootReal, real);
+ if (rel !== '' && (rel.startsWith('..') || isAbsolute(rel))) {
+ throw new Error(`security-audit: path escapes the scan root: ${absolute} -> ${real}`);
+ }
+ return real;
+}
+
+/**
+ * Checks every path component without following a symbolic link.
+ *
+ * `realpath` containment alone is insufficient for a reference such as
+ * `./dist/action`: a link can remain inside the repository while still hiding
+ * the reviewed action metadata behind different content.
+ *
+ * @param {string} rootReal
+ * @param {string} absolute
+ * @param {string} sourceFile
+ * @param {number} sourceLine
+ * @returns {import('node:fs').Stats}
+ */
+function inspectRepositoryPath(rootReal, absolute, sourceFile, sourceLine) {
+ const lexical = relative(rootReal, absolute);
+ if (lexical !== '' && (lexical.startsWith('..') || isAbsolute(lexical))) {
+ throw new Error(`${sourceFile}:${sourceLine}: local reference escapes the scan root`);
+ }
+
+ let current = rootReal;
+ let stats = lstatSync(rootReal);
+ for (const segment of lexical.split(/[\\/]/u).filter(Boolean)) {
+ current = join(current, segment);
+ try {
+ stats = lstatSync(current);
+ } catch {
+ throw new Error(`${sourceFile}:${sourceLine}: local reference could not be read`);
+ }
+ if (stats.isSymbolicLink()) {
+ throw new Error(`${sourceFile}:${sourceLine}: local reference must not contain a symlink`);
+ }
+ }
+ assertWithinRoot(rootReal, absolute);
+ return stats;
+}
+
+/**
+ * Resolves one repository-local `uses:` value to exact executable policy
+ * metadata. Directories must contain exactly one action metadata file. Files
+ * must be reusable-workflow YAML.
+ *
+ * @param {{ file: string, line: number, uses: string }} reference
+ * @param {string} rootReal
+ * @returns {{ file: string, kind: 'action' | 'workflow' }}
+ */
+function resolveLocalReference(reference, rootReal) {
+ const value = reference.uses;
+ if (
+ !value.startsWith('./') ||
+ value === './' ||
+ value.trim() !== value ||
+ value.includes('\\') ||
+ value.includes('\0') ||
+ value.includes('$') ||
+ value.includes('?') ||
+ value.includes('#') ||
+ isAbsolute(value)
+ ) {
+ throw new Error(`${reference.file}:${reference.line}: unprovable local action reference`);
+ }
+
+ const absolute = resolve(rootReal, value.slice(2));
+ const stats = inspectRepositoryPath(rootReal, absolute, reference.file, reference.line);
+ if (stats.isFile()) {
+ if (!/\.ya?ml$/u.test(absolute)) {
+ throw new Error(
+ `${reference.file}:${reference.line}: local workflow reference must name YAML`,
+ );
+ }
+ return { file: absolute.split('\\').join('/'), kind: 'workflow' };
+ }
+ if (!stats.isDirectory()) {
+ throw new Error(
+ `${reference.file}:${reference.line}: local action reference is not a file or directory`,
+ );
+ }
+
+ const candidates = [...ACTION_METADATA_NAMES]
+ .map((name) => join(absolute, name))
+ .filter((candidate) => existsSync(candidate));
+ if (candidates.length === 0) {
+ throw new Error(`${reference.file}:${reference.line}: local action metadata is missing`);
+ }
+ if (candidates.length !== 1) {
+ throw new Error(`${reference.file}:${reference.line}: local action metadata is ambiguous`);
+ }
+
+ const metadata = candidates[0];
+ const metadataStats = inspectRepositoryPath(
+ rootReal,
+ metadata,
+ reference.file,
+ reference.line,
+ );
+ if (!metadataStats.isFile()) {
+ throw new Error(`${reference.file}:${reference.line}: local action metadata is not a file`);
+ }
+ return { file: metadata.split('\\').join('/'), kind: 'action' };
+}
+
+/**
+ * Scans the transitive executable-reference graph rooted at workflow and local
+ * action metadata files.
+ *
+ * @param {string} root
+ * @param {Array<{ file: string, kind: 'action' | 'workflow' }>} seeds
+ */
+function checkPolicyGraph(root, seeds) {
+ const rootReal = realpathSync.native(root);
+ /** @type {Set} */
+ const visited = new Set();
+ /** @type {Set} */
+ const active = new Set();
+ /** @type {Array<{ file: string, line: number, uses: string, reason: string }>} */
+ const violations = [];
+
+ /**
+ * @param {{ file: string, kind: 'action' | 'workflow' }} policy
+ */
+ function visit(policy) {
+ const absolute = resolve(policy.file);
+ const stats = inspectRepositoryPath(rootReal, absolute, policy.file, 1);
+ if (!stats.isFile()) {
+ throw new Error(`${policy.file}: policy input is not a regular file`);
+ }
+ const canonical = assertWithinRoot(rootReal, absolute);
+ const visitedKey = `${policy.kind}:${canonical}`;
+ if (active.has(canonical)) {
+ throw new Error(`${policy.file}: local action reference cycle detected`);
+ }
+ if (visited.has(visitedKey)) return;
+
+ active.add(canonical);
+ const displayFile = absolute.split('\\').join('/');
+ const parsed = parsePolicySource(readFileSync(absolute, 'utf8'), displayFile);
+ const image =
+ policy.kind === 'action' ? extractLocalDockerImage(parsed, displayFile) : null;
+ const workflowImages =
+ policy.kind === 'workflow' ? extractWorkflowContainerImages(parsed, displayFile) : [];
+ const executableReferences = [
+ ...parsed.references,
+ ...(image?.uses.startsWith('docker://') ? [image] : []),
+ ...workflowImages,
+ ];
+ violations.push(...checkReferences(executableReferences));
+
+ if (image && !image.uses.startsWith('docker://')) {
+ violations.push(...checkReferencedDockerfile(image, rootReal));
+ }
+
+ for (const reference of parsed.references) {
+ if (!reference.uses.startsWith('./')) continue;
+ visit(resolveLocalReference(reference, rootReal));
+ }
+
+ active.delete(canonical);
+ visited.add(visitedKey);
+ }
+
+ for (const seed of seeds.toSorted((left, right) => left.file.localeCompare(right.file))) {
+ visit(seed);
+ }
+
+ return { violations, scanned: visited.size };
+}
+
+/**
+ * Recursively lists files under `dir` that satisfy `predicate`.
+ *
+ * Symlinks are rejected outright — both symlinked files and symlinked directories
+ * cause a fail-closed throw rather than a skip. A repository that ships a link
+ * into `/etc`, into another checkout, or back into itself would otherwise let the
+ * pin scanner read (or loop over) content outside the audited tree, and a link
+ * that shadows a local action could hide an unpinned executable reference.
+ * Refusing to follow links also makes filesystem cycles unreachable; the `seen`
+ * set below is belt-and-braces for hard-linked or bind-mounted directories.
+ *
+ * @param {string} dir
+ * @param {(name: string) => boolean} predicate
+ * @returns {string[]} POSIX-style paths, sorted for deterministic output.
+ * @throws {Error} When a symlink, an escaping path, or a directory cycle is found.
+ */
+export function collectFiles(dir, predicate) {
+ /** @type {string[]} */
+ const found = [];
+
+ let rootReal;
+ try {
+ rootReal = realpathSync.native(dir);
+ } catch (error) {
+ throw new Error(
+ `security-audit: cannot resolve the scan root ${dir}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ /** @type {Set} */
+ const seen = new Set([rootReal]);
+
+ /** @param {string} current */
+ function walk(current) {
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ )) {
+ const full = join(current, entry.name);
+ if (entry.isSymbolicLink()) {
+ throw new Error(`security-audit: refusing to follow symlink: ${full}`);
+ }
+ if (entry.isDirectory()) {
+ // Hidden directories are scanned unless explicitly named here. Local
+ // actions often live in `.actions/`; skipping them wholesale would let
+ // mutable nested `uses` or Docker image references bypass enforcement.
+ if (SKIP_DIRS.has(entry.name)) continue;
+ const real = assertWithinRoot(rootReal, full);
+ if (seen.has(real)) {
+ throw new Error(`security-audit: directory cycle detected at ${full}`);
+ }
+ seen.add(real);
+ walk(full);
+ continue;
+ }
+ // Sockets, FIFOs and device nodes are never audit inputs.
+ if (!entry.isFile()) continue;
+ if (predicate(entry.name)) {
+ assertWithinRoot(rootReal, full);
+ found.push(full.split('\\').join('/'));
+ }
+ }
+ }
+
+ walk(dir);
+ return found.sort();
+}
+
+/**
+ * Scans every YAML file under `dir`, recursively.
+ *
+ * @param {string} dir
+ */
+export function checkWorkflowDirectory(dir, root = resolve(dir, '..', '..')) {
+ const files = collectFiles(dir, (name) => name.endsWith('.yml') || name.endsWith('.yaml'));
+ return checkPolicyGraph(
+ root,
+ files.map((file) => ({ file, kind: 'workflow' })),
+ ).violations;
+}
+
+/**
+ * Scans local actions (`action.yml` / `action.yaml`) anywhere under `root`.
+ * Composite `uses:` references and external Docker `runs.image` references are
+ * both checked.
+ *
+ * @param {string} root
+ */
+export function checkLocalActions(root) {
+ const files = collectFiles(root, (name) => ACTION_METADATA_NAMES.has(name));
+ return checkPolicyGraph(
+ root,
+ files.map((file) => ({ file, kind: 'action' })),
+ ).violations;
+}
+
+function main() {
+ const argv = process.argv.slice(2);
+ const dirIndex = argv.indexOf('--dir');
+ const dir = dirIndex === -1 ? '.github/workflows' : argv[dirIndex + 1];
+ const rootIndex = argv.indexOf('--root');
+ const root = rootIndex === -1 ? '.' : argv[rootIndex + 1];
+
+ let violations;
+ let scanned;
+ try {
+ const workflowFiles = collectFiles(
+ dir,
+ (name) => name.endsWith('.yml') || name.endsWith('.yaml'),
+ );
+ const localActionFiles = collectFiles(root, (name) => ACTION_METADATA_NAMES.has(name));
+ const result = checkPolicyGraph(root, [
+ ...workflowFiles.map((file) => ({ file, kind: 'workflow' })),
+ ...localActionFiles.map((file) => ({ file, kind: 'action' })),
+ ]);
+ scanned = result.scanned;
+ violations = result.violations;
+ } catch (error) {
+ process.stderr.write(
+ `security-audit: unable to read ${dir}: ${error instanceof Error ? error.message : String(error)}\n`,
+ );
+ process.exit(1);
+ return;
+ }
+
+ if (violations.length === 0) {
+ if (process.env.GITHUB_ACTIONS !== 'true') {
+ process.stdout.write(
+ `security-audit: all external actions are immutably pinned across ${scanned} workflow/local-action file(s)\n`,
+ );
+ }
+ return;
+ }
+
+ for (const violation of violations) {
+ process.stderr.write(
+ `${violation.file}:${violation.line}: ${violation.reason}: ${violation.uses}\n`,
+ );
+ }
+ process.stderr.write(
+ `security-audit: ${violations.length} unpinned or undocumented action reference(s)\n`,
+ );
+ process.exit(2);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main();
+}
diff --git a/scripts/security-audit/ci-contracts.mjs b/scripts/security-audit/ci-contracts.mjs
new file mode 100644
index 0000000..57b46c3
--- /dev/null
+++ b/scripts/security-audit/ci-contracts.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+/**
+ * Runs the repository's security contract regressions without forwarding test
+ * names, synthetic fixtures, policy diagnostics, or finding state to a public
+ * CI log.
+ */
+
+import { readdirSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { resolve } from 'node:path';
+
+function fail() {
+ process.stderr.write('Repository contract validation failed.\n');
+ process.exit(1);
+}
+
+try {
+ const root = resolve(import.meta.dirname, '..', '..');
+ const testFiles = readdirSync(resolve(import.meta.dirname, 'tests'))
+ .filter((name) => name.endsWith('.test.mjs'))
+ .sort()
+ .map((name) => resolve(import.meta.dirname, 'tests', name));
+
+ const invocations = [
+ ['--test', ...testFiles],
+ [
+ resolve(import.meta.dirname, 'check-action-pins.mjs'),
+ '--dir',
+ resolve(root, '.github', 'workflows'),
+ '--root',
+ root,
+ ],
+ ];
+
+ let failed = false;
+ for (const arguments_ of invocations) {
+ const result = spawnSync(process.execPath, arguments_, {
+ cwd: root,
+ encoding: 'utf8',
+ env: { ...process.env, GITHUB_ACTIONS: 'true' },
+ maxBuffer: 32 * 1024 * 1024,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+ if (result.status !== 0) failed = true;
+ }
+
+ if (failed) fail();
+} catch {
+ fail();
+}
diff --git a/scripts/security-audit/collect-corpus.mjs b/scripts/security-audit/collect-corpus.mjs
new file mode 100644
index 0000000..ded1993
--- /dev/null
+++ b/scripts/security-audit/collect-corpus.mjs
@@ -0,0 +1,348 @@
+#!/usr/bin/env node
+/**
+ * Collects the bounded, allowlisted corpus that is sent to the model.
+ *
+ * Security properties:
+ * - Only files under the scope's directory prefixes are considered.
+ * - Only allowlisted extensions are read; deny patterns remove tests, build
+ * output and vendored code.
+ * - Hard caps on file count, per-file bytes and total bytes. Any eligible file
+ * that cannot be collected in full aborts the run, so a successful audit can
+ * never represent a partial eligible corpus.
+ * - Every file body is fenced with a PER-RUN CRYPTOGRAPHIC NONCE. A static fence
+ * is forgeable — this repository's own `lib/constants.mjs` contains the fence
+ * sentinel — so the nonce is generated fresh for every run and cannot appear
+ * in repository content. Any sentinel literal found inside a collected body is
+ * neutralized before emission, and a body that somehow contains the run nonce
+ * aborts the collection outright.
+ * - File discovery uses `git ls-files`, so untracked and ignored files (which
+ * may contain local secrets) are never collected.
+ * - `--repo-root` points at the *audited* checkout, which is separate from the
+ * trusted controller checkout this script is executed from. The controller
+ * never runs code from, and never sources helper scripts out of, the audited
+ * tree — so auditing a historical commit cannot change audit behaviour.
+ *
+ * Emits:
+ * - `/corpus.txt` delimiter-fenced file bodies
+ * - `/corpus-manifest.json` nonce + path -> { bytes, lines } used to
+ * validate that model findings reference real
+ * files and lines, and to render the prompt with
+ * the exact fence in use
+ *
+ * Usage:
+ * node scripts/security-audit/collect-corpus.mjs --scope --out [--repo-root ]
+ */
+
+import { execFileSync } from 'node:child_process';
+import {
+ appendFileSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ realpathSync,
+ writeFileSync,
+} from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+import {
+ ALLOWED_EXTENSIONS,
+ corpusDelimiters,
+ CORPUS_DENY_PATTERNS,
+ CORPUS_LIMITS,
+ DEFAULT_SCOPE,
+ generateCorpusNonce,
+ neutralizeDelimiters,
+ SCOPES,
+} from './lib/constants.mjs';
+import { gitExecutable } from './lib/git-executable.mjs';
+
+/**
+ * @param {string[]} argv
+ * @returns {Record}
+ */
+function parseArgs(argv) {
+ const args = {};
+ for (let i = 0; i < argv.length; i += 1) {
+ const token = argv[i];
+ if (!token.startsWith('--')) continue;
+ const key = token.slice(2);
+ const next = argv[i + 1];
+ if (next === undefined || next.startsWith('--')) {
+ args[key] = 'true';
+ } else {
+ args[key] = next;
+ i += 1;
+ }
+ }
+ return args;
+}
+
+/** @param {string} message */
+function fail(message) {
+ process.stderr.write(`security-audit: ${message}\n`);
+ process.exit(1);
+}
+
+/**
+ * Git records symbolic links as blobs with this file mode. A tracked symlink is
+ * the classic way to smuggle out-of-tree content into a bounded corpus: the
+ * blob holds a path such as `../../secrets.env`, and any collector that reads
+ * through the link exfiltrates a file the allowlist never approved. The mode is
+ * therefore checked at enumeration time, before the filesystem is touched.
+ */
+const GIT_SYMLINK_MODE = '120000';
+
+/**
+ * @param {string} repoRoot Directory of the checkout to enumerate.
+ * @returns {{ file: string, mode: string }[]} Repository-relative, POSIX-separated
+ * tracked paths paired with their git file mode.
+ */
+function listTrackedFiles(repoRoot) {
+ // `-s` prepends "