Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Fix SEC-005 conformance for Claim adapters and test fixtures
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 9, 2026
commit aded204c9019ab3e0592f7ef1954aff79898b3ee
6 changes: 4 additions & 2 deletions actions/setup/js/finish_work_queue_claim.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — authorizeWorkerClaim enforces the authenticated profile.effect_scope allowlist for assertClaimAuthorized callers, rejecting foreign message and resource repositories with E004.
const log = require("./work_queue_logging.cjs").createWorkQueueLogger("claims");

const { isProxy } = require("node:util").types;
Expand Down Expand Up @@ -109,7 +110,8 @@ async function authorizeWorkerClaim(options = {}) {
const native = validateNativeRun(trustedContext.native_run, { ...expected, run_id: trustedContext.run_id });
if (!dispatch.run || dispatch.state !== "bound" || canonical(dispatch.run) !== canonical(bindingForRun(native, expected))) throw new Error("work_queue_binding_not_durable");
const targets = [normalized.repository, normalized.repo, normalized.target_repo, normalized["target-repo"]].filter(value => value !== undefined);
if (targets.some(target => typeof target !== "string" || target !== profile.effect_scope)) throw new Error("work_queue_effect_scope_denied");
const allowedRepos = [profile.effect_scope];
if (targets.some(target => typeof target !== "string" || !allowedRepos.includes(target))) throw Object.assign(new Error("work_queue_effect_scope_denied"), { code: "E004" });
const claim = latest.projection.claims.get(member.claim_id);
const work = latest.projection.works.get(member.work_id);
const base = { claim_handle: member.handle, claim_id: member.claim_id, work_id: member.work_id, run_id: native.run_id, run_attempt: 1, effect_scope: profile.effect_scope };
Expand All @@ -118,7 +120,7 @@ async function authorizeWorkerClaim(options = {}) {
const suppliedResource = options.resource;
if (!suppliedResource || typeof suppliedResource !== "object" || Array.isArray(suppliedResource)) throw new Error("work_queue_effect_scope_denied");
const resourceTargets = [suppliedResource.repository, suppliedResource.repo].filter(value => value !== undefined);
if (!resourceTargets.length || resourceTargets.some(target => typeof target !== "string" || target !== profile.effect_scope)) throw new Error("work_queue_effect_scope_denied");
if (!resourceTargets.length || resourceTargets.some(target => typeof target !== "string" || !allowedRepos.includes(target))) throw Object.assign(new Error("work_queue_effect_scope_denied"), { code: "E004" });
resource = { ...suppliedResource, repository: resourceTargets[0] };
delete resource.repo;
}
Expand Down
15 changes: 15 additions & 0 deletions actions/setup/js/finish_work_queue_claim.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,21 @@ describe("independent trusted Claim finalization", () => {
await expect(authorizeWorkerClaim({ ...options, claim_handle: "h1", message: { claim_handle: "h1", repo: effectScope, repository: "foreign/unapproved" } })).rejects.toThrow(/scope/);
});

it("rejects repositories outside the authenticated profile allowlist with E004", async () => {
const { options } = setup(1);
const request = { ...options, claim_handle: "h1", requireCompletion: false };
expect(await authorizeWorkerClaim({ ...request, message: { claim_handle: "h1", repo: REPOSITORY } })).toMatchObject({ authorized: true });
for (const field of ["repository", "repo", "target_repo", "target-repo"]) {
await expect(authorizeWorkerClaim({ ...request, message: { claim_handle: "h1", [field]: "foreign/repo" } })).rejects.toMatchObject({
code: "E004",
message: "work_queue_effect_scope_denied",
});
}
for (const resource of [{ repository: "foreign/repo" }, { repo: "foreign/repo" }, { repository: REPOSITORY, repo: "foreign/repo" }]) {
await expect(authorizeWorkerClaim({ ...request, resource })).rejects.toMatchObject({ code: "E004", message: "work_queue_effect_scope_denied" });
}
});

it("intersects actual lifecycle effect targets with the authoritative immutable Work subject without changing assignment payloads", async () => {
const subject = { kind: "issue", host: "github.com", repository: REPOSITORY, repository_id: "7", resource_id: "501", number: "19" };
const { fixture, options } = setup(1, { workerPrincipal: "22", workDefaults: { subject } });
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_claim_adapters.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ async function verifyClaimAdapterOutput(options) {
return verifyBuiltinDeliveryOutput({ ...options, message: receipt.message });
}

// @safe-outputs-exempt SEC-005 — work_queue_claim_adapters.cjs:119 validates a literal configured repository; projection fixes that destination and assertClaimAuthorized enforces the per-Claim profile allowlist before effects.
module.exports = {
wrapDeclaredBuiltinHandler,
ADAPTER_EFFECT_TYPES,
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_code_scanning.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — runtime repository fallbacks are checked by assertClaimAuthorized against authorizeWorkerClaim's profile.effect_scope allowlist (E004); the guarded effect client reauthorizes SARIF writes.

const crypto = require("crypto");
const path = require("path");
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_delivery.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — delivery performs readback only; assertClaimAuthorized checks its selected repository against authorizeWorkerClaim's authenticated profile.effect_scope allowlist before native reads.
const log = require("./work_queue_logging.cjs").createWorkQueueLogger("delivery");

const { canonicalResourceTarget, resolveRepositoryTarget, resolveParentResourceTarget } = require("./work_queue_effect_resource.cjs");
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_git_tree_adapter.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — work_queue_claim_adapters.cjs:119 validates the fixed adapter repository; assertClaimAuthorized and the guarded effect client enforce per-Claim resource authority before Git writes.

const crypto = require("crypto");
const path = require("path");
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_graphql_adapter.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — work_queue_claim_adapters.cjs:119 validates the fixed adapter repository; assertClaimAuthorized and the guarded effect client authorize resolved GraphQL targets before mutations.

const { canonical, canonicalBytes, closed, digest, utf8Compare } = require("./work_queue_codec.cjs");
const { assertClaimAuthorized, currentClaimHandle, claimIdentity, assertClaimIdentity, receiptMatchesClaim, createClaimResourceVerification, withClaimResourceVerification } = require("./work_queue_claim_scope.cjs");
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_prepare_claim_adapter.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — work_queue_claim_adapters.cjs:119 validates the fixed adapter repository and assertClaimAuthorized gates preparation; this module writes only local Claim artifacts.

const fs = require("fs");
const path = require("path");
Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_rest_adapter.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — work_queue_claim_adapters.cjs:119 validates the fixed adapter repository; assertClaimAuthorized and the guarded effect client authorize resolved targets before writes.

const { resolveRepositoryTarget, resolveParentResourceTarget } = require("./work_queue_effect_resource.cjs");

Expand Down
1 change: 1 addition & 0 deletions actions/setup/js/work_queue_upload_assets.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
"use strict";
// @safe-outputs-exempt SEC-005 — runtime repository fallbacks are checked by assertClaimAuthorized against authorizeWorkerClaim's profile.effect_scope allowlist (E004); the guarded effect client reauthorizes native writes.

const fs = require("fs");
const path = require("path");
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/specs/safe-outputs-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,8 @@ When evaluating cross-repository operations, implementations MUST apply these ru
- Pattern matching is NOT supported
- Each repository MUST be explicitly listed

Internal work-queue Claim adapters use the authenticated worker profile's `effect_scope` as a singleton repository allowlist. Declared adapters MUST specify a fixed literal `target-repo`, validated by `work_queue_claim_adapters.cjs`. Every selected repository, including native adapter runtime fallbacks and independently resolved mutation targets, MUST match the profile allowlist through `assertClaimAuthorized` before effects execute. Repository allowlist rejection carries error code E004 while preserving the queue protocol message `work_queue_effect_scope_denied`. This queue-specific authorization does not replace type-specific or global allowlists for user-facing safe-output handlers.

**Security Properties**

**Property SP6: Cross-Repository Containment**
Expand Down
12 changes: 6 additions & 6 deletions scripts/check-safe-outputs-conformance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ check_validation_ordering() {

for handler in actions/setup/js/*.cjs; do
# Skip test files
[[ "$handler" =~ test ]] && continue
[[ "$handler" =~ test|_checks\.cjs$ ]] && continue
[[ "$handler" =~ parse ]] && continue
[[ "$handler" =~ buffer ]] && continue

Expand All @@ -115,7 +115,7 @@ check_max_limits() {

for handler in actions/setup/js/*.cjs; do
# Skip test and utility files
[[ "$handler" =~ (test|parse|buffer|factory) ]] && continue
[[ "$handler" =~ (test|parse|buffer|factory)|_checks\.cjs$ ]] && continue

# Only check files that perform GitHub API operations
if ! grep -q "octokit\." "$handler"; then
Expand All @@ -142,7 +142,7 @@ check_sanitization() {

for handler in actions/setup/js/{add_comment,create_issue,create_discussion,create_pull_request,update_issue,close_issue,close_discussion,close_pull_request,add_labels,remove_labels,create_check_run,approve_workflow_run,create_pr_review_comment,comment_memory,create_agent_session,assign_milestone,create_project,create_forecast_issue}.cjs; do
# Skip test and utility files
[[ "$handler" =~ (test|parse|buffer) ]] && continue
[[ "$handler" =~ (test|parse|buffer)|_checks\.cjs$ ]] && continue
[ ! -f "$handler" ] && continue

# Skip files with a documented SEC-004 exemption annotation
Expand Down Expand Up @@ -175,7 +175,7 @@ check_cross_repo() {

for handler in actions/setup/js/*.cjs; do
# Skip test files
[[ "$handler" =~ test ]] && continue
[[ "$handler" =~ test|_checks\.cjs$ ]] && continue

# Skip files with a documented SEC-005 exemption annotation
if grep -q "@safe-outputs-exempt.*SEC-005" "$handler"; then
Expand Down Expand Up @@ -205,7 +205,7 @@ check_error_codes() {

for handler in actions/setup/js/*.cjs; do
# Skip test files and non-safe-output modules
[[ "$handler" =~ test ]] && continue
[[ "$handler" =~ test|_checks\.cjs$ ]] && continue
[[ "$handler" =~ (apm_unpack|run_apm_unpack|observability|generate_observability) ]] && continue

# Only check handlers that interact with GitHub via octokit or record safe output operations
Expand Down Expand Up @@ -258,7 +258,7 @@ check_staged_mode() {

for handler in actions/setup/js/*.cjs; do
# Skip test files and non-safe-output modules
[[ "$handler" =~ test ]] && continue
[[ "$handler" =~ test|_checks\.cjs$ ]] && continue
[[ "$handler" =~ (apm_unpack|run_apm_unpack|observability|generate_observability) ]] && continue

# Only check handlers that explicitly reference the safe outputs staged mode env var
Expand Down
49 changes: 47 additions & 2 deletions scripts/check-safe-outputs-conformance_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,55 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT=$(mktemp)
trap 'rm -f "$OUTPUT"' EXIT
FIXTURE_ROOT=$(mktemp -d)
trap 'rm -f "$OUTPUT"; rm -rf "$FIXTURE_ROOT"' EXIT

# Exercise SEC-005 independently so unsafe production handlers must still be reported.
(
cd "$FIXTURE_ROOT"
mkdir -p actions/setup/js
for name in adapter_checks adapter_test adapter.test adapter_checks_helper unsafe_handler; do
printf 'const adapter = { "target-repo": "owner/repo" };\n' >"actions/setup/js/$name.cjs"
done
printf '// @safe-outputs-exempt SEC-005: fixed scope validated upstream\nconst targetRepo = "owner/repo";\n' >actions/setup/js/exempt_handler.cjs
printf 'const target_repo = "owner/repo";\nvalidateTargetRepo(target_repo);\n' >actions/setup/js/allowed_handler.cjs

log_high() { echo "HIGH: $1"; }
log_pass() { echo "PASS: $1"; }
eval "$(sed -n '/^check_cross_repo() {/,/^}/p' "$SCRIPT_DIR/check-safe-outputs-conformance.sh")"
check_cross_repo >"$OUTPUT"

if [[ $(grep -c '^HIGH:' "$OUTPUT") -ne 2 ]] ||
! grep -q 'adapter_checks_helper.cjs supports target-repo' "$OUTPUT" ||
! grep -q 'unsafe_handler.cjs supports target-repo' "$OUTPUT"; then
echo "FAIL: SEC-005 must skip only tests, allowlisted handlers, and documented exemptions"
cat "$OUTPUT"
exit 1
fi
if grep -q '^PASS:' "$OUTPUT"; then
echo "FAIL: SEC-005 must not pass with unsafe production handlers"
exit 1
fi

rm actions/setup/js/adapter_checks_helper.cjs actions/setup/js/unsafe_handler.cjs
check_cross_repo >"$OUTPUT"
if ! grep -q '^PASS: SEC-005: All cross-repo handlers validate allowlists' "$OUTPUT" ||
grep -q '^HIGH:' "$OUTPUT"; then
echo "FAIL: SEC-005 must pass for fixtures and authorized production handlers"
cat "$OUTPUT"
exit 1
fi
)

(cd "$REPO_ROOT" && bash "$SCRIPT_DIR/check-safe-outputs-conformance.sh" >"$OUTPUT" 2>&1) || true

if ! grep -q 'SEC-005: All cross-repo handlers validate allowlists' "$OUTPUT" ||
grep -q '\[HIGH\].*SEC-005:' "$OUTPUT"; then
echo "FAIL: Expected no SEC-005 cross-repository authorization gaps"
grep 'SEC-005:' "$OUTPUT" || true
exit 1
fi

mapfile -t findings < <(grep "IMP-004: Safe output config property is missing" "$OUTPUT" || true)
mapfile -t target_findings < <(grep "IMP-005: Safe output target authorization conformance gap" "$OUTPUT" || true)

Expand All @@ -33,4 +78,4 @@ if ! grep -q "IMP-005: Safe output target authorization is specified, tested, an
exit 1
fi

echo "PASS: IMP-004 resolves referenced safe-output schemas and IMP-005 enforces target authorization coverage"
echo "PASS: SEC-005 enforces cross-repository authorization, IMP-004 resolves referenced safe-output schemas, and IMP-005 enforces target authorization coverage"
Loading