Skip to content
53 changes: 34 additions & 19 deletions dag/extdeps/bmc/http.dag
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import extdeps.external_authority { ExternalAuthority }
import extdeps.uri { Uri, Https }
import std.decl_ref { DeclarationRef, WholeDeclaration }
import std.disposition { Disposition, RealizationDispatch, Scaffold }
import std.types { Bool, NonEmptyStr, Secret, String }
import std.types { Bool, NonEmptyStr, String }
data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority {
uri: Uri {
scheme: Https
Expand All @@ -26,6 +26,34 @@ data redfish_http_hardwired_transport_note: String = "§3(b) declared debt: ever

data redfish_http_success_semantics_note: String = "LOAD-BEARING: success:Bool derives from exit_success, so curl MUST fail-close on HTTP status. Every argv carries --fail-with-body (curl >=7.76). WITHOUT it curl exits 0 on 401/403/404/5xx and `success` reads a rejected auth or a missing resource as TRUE — a §5 fail-open: a factory-401 misreads as FactoryCredentialActive (so an already-rotated BMC gets re-rotated), and a rotation PATCH that 401s reports rotated while changing nothing (the 'secret populated, server never rotated' state). --fail-with-body (not bare --fail) keeps the OpenBMC error body in `body` for diagnosis (e.g. PropertyValueFormatError on a rejected password). Do not drop the flag; a new redfish op without it re-opens the arm. Found by execution against srv4 2026-07-23."

// EVERY REDFISH OPERATION AUTHENTICATES BY FILE, AND THE ABSENCE OF `Secret` IN THIS MODULE'S
// IMPORTS IS THE RECEIPT. GetSystem, GetPower and GetThermal each declared `password: Secret` and
// then interpolated it into an argv token -- `-u {username}:{password}` -- so the credential became
// a word in curl's command line. On the fleet hosts that is not a theoretical exposure: /proc is
// mounted with no hidepid, /proc/PID/cmdline is mode 0444, and the CI runner slots are ordinary host
// systemd services sharing the host pid namespace, so any job payload reads it with an unprivileged
// ps. bmc_onboard called GetSystem with the FRESHLY ROTATED password, which put the new credential
// in argv at the exact moment the old one was being retired.
//
// THIS WAS A HALF-FINISHED REPLACEMENT MIGRATION, NOT A NEW DISCOVERY, and that is the part worth
// recording. Every MUTATING operation here already used --netrc-file, and a GetSystemWithNetrc sat
// directly beside the leaking GetSystem answering the identical question. So the correct shape was
// already present and already understood; what kept the leak alive was that the old form was left
// standing next to the new one instead of being cut at the root. While X stands, callers keep
// reaching for it -- five call sites did.
//
// The cut is therefore at the root rather than at the leaves: the three argv-borne arms are DELETED,
// not deprecated, and GetSystemWithNetrc is deleted too because once GetSystem no longer takes a
// password the `WithNetrc` suffix distinguishes nothing and is a second name for one concept. One
// operation per resource, authenticating one way. A future operation cannot reintroduce the class by
// copying a neighbour, because no neighbour spells it any more.
//
// WHAT THIS DOES NOT CLAIM: the module is clean by CONSTRUCTION nowhere. Nothing refuses a new
// `password: Secret` interpolated into an argv token -- v2.std.operation_argv's binding wall refuses
// undeclared names and a non-literal executable position, but ShellTransportOperationRow carries
// declared_inputs as a List<String>, names without types, so the binder cannot see that an input is
// Secret and could not refuse it. That missing declared-input TYPE on the row is this class's
// next-rung trigger; until it lands the class sits at mitigatable and this note is diligence.
service redfish.Http {

operation GetServiceRoot {
Expand All @@ -42,19 +70,6 @@ service redfish.Http {
}

operation GetSystem {
input { bmc_host: NonEmptyStr, username: NonEmptyStr, password: Secret }
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
transport shell {
argv: ["curl", "--fail-with-body", "-sS", "-k", "-u", "{username}:{password}", "https://{bmc_host}/redfish/v1/Systems/system"]
}
exit {
0 => Unit
nonzero => String "redfish system GET failed"
}
}

operation GetSystemWithNetrc {
input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
Expand All @@ -63,7 +78,7 @@ service redfish.Http {
}
exit {
0 => Unit
nonzero => String "redfish system GET (netrc) failed"
nonzero => String "redfish system GET failed"
}
}

Expand All @@ -81,11 +96,11 @@ service redfish.Http {
}

operation GetPower {
input { bmc_host: NonEmptyStr, username: NonEmptyStr, password: Secret }
input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
transport shell {
argv: ["curl", "--fail-with-body", "-sS", "-k", "-u", "{username}:{password}", "https://{bmc_host}/redfish/v1/Chassis/Self/Power"]
argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Chassis/Self/Power"]
}
exit {
0 => Unit
Expand All @@ -94,11 +109,11 @@ service redfish.Http {
}

operation GetThermal {
input { bmc_host: NonEmptyStr, username: NonEmptyStr, password: Secret }
input { bmc_host: NonEmptyStr, netrc_file: NonEmptyStr }
output { body: String from "stdout", success: Bool from "exit_success" }
readonly
transport shell {
argv: ["curl", "--fail-with-body", "-sS", "-k", "-u", "{username}:{password}", "https://{bmc_host}/redfish/v1/Chassis/Self/Thermal"]
argv: ["curl", "--fail-with-body", "-sS", "-k", "--netrc-file", "{netrc_file}", "https://{bmc_host}/redfish/v1/Chassis/Self/Thermal"]
}
exit {
0 => Unit
Expand Down
8 changes: 0 additions & 8 deletions dag/extdeps/bmc/mock_corpus.dag
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ data redfish_http_mock_case_get_system: PublishedMockCase = PublishedMockCase {
case_id: 0
}

data redfish_http_mock_case_get_system_with_netrc: PublishedMockCase = PublishedMockCase {
operation_key: "redfish.Http.GetSystemWithNetrc",
service: "redfish.Http",
operation: "GetSystemWithNetrc",
case_id: 0
}

data redfish_http_mock_case_get_chassis_sensors: PublishedMockCase = PublishedMockCase {
operation_key: "redfish.Http.GetChassisSensors",
service: "redfish.Http",
Expand Down Expand Up @@ -60,7 +53,6 @@ data bmc_ipmi_tool_mock_case_sel_list: PublishedMockCase = PublishedMockCase {

data bmc_published_mock_corpus: List<PublishedMockCase> = [
redfish_http_mock_case_get_system,
redfish_http_mock_case_get_system_with_netrc,
redfish_http_mock_case_get_chassis_sensors,
redfish_http_mock_case_get_power,
redfish_http_mock_case_get_thermal,
Expand Down
2 changes: 0 additions & 2 deletions dag/extdeps/diagnostic_mock_corpus.dag
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import extdeps.ssh.mock_corpus {
import extdeps.linux.mock_corpus { linux_edac_rasmcctl_mock_case_correctable_counts }
import extdeps.bmc.mock_corpus {
redfish_http_mock_case_get_system,
redfish_http_mock_case_get_system_with_netrc,
redfish_http_mock_case_get_chassis_sensors,
redfish_http_mock_case_get_power,
redfish_http_mock_case_get_thermal,
Expand All @@ -27,7 +26,6 @@ data diagnostic_published_mock_corpus: List<PublishedMockCase> = [
ssh_session_mock_case_exec_portable_words_with_stdin,
ssh_session_mock_case_reachability,
redfish_http_mock_case_get_system,
redfish_http_mock_case_get_system_with_netrc,
redfish_http_mock_case_get_chassis_sensors,
redfish_http_mock_case_get_power,
redfish_http_mock_case_get_thermal,
Expand Down
57 changes: 53 additions & 4 deletions dag/gunbc/ci_spec.dag
Original file line number Diff line number Diff line change
Expand Up @@ -737,22 +737,69 @@ fn gunbc_ci_multi_principal_probe_invoke() -> String {
// fulfilled by this same change (gunbc_ci_fleet_key_agent_prelude below, emitted through
// v2.workflow.gunbc_invoke_step_emit gunbc_invoke_step_emit_pipeline), so a corrected row
// would be created and discharged in the same diff — ceremony, not a standing obligation.
// THE TOKEN IS WRITTEN BY A SHELL BUILTIN, AND THAT IS THE WHOLE MECHANISM. Both preludes below used
// `curl -H "Authorization: Bearer $WIF_ACCESS_TOKEN"`, and the shell expands that BEFORE exec, so the
// GCP access token became a word in curl's argv -- readable by any unprivileged `ps` for the life of
// the request. On these runners that is not a hypothetical audience: /proc carries no hidepid,
// /proc/PID/cmdline is mode 0444, and the runner slots are ordinary host systemd services in the host
// pid namespace, so a job payload sees every process on the machine.
//
// curl reads a header from a file with `-H @FILE` (>=7.55; the runners carry 7.88), which moves the
// value out of curl's argv. That alone would not be enough: writing the file with any EXTERNAL
// command would simply relocate the token from curl's argv into that command's argv. `printf` is a
// bash BUILTIN, so it forks no process and creates no command line -- the token exists in the shell's
// own memory and in the file, and never in any process table.
//
// The mode is set BEFORE the content exists. Both preludes already run `umask 077`, so the redirect
// creates the file 0600 at creation rather than writing world-readable bytes and narrowing them
// afterwards; there is no window where the token sits at a wider mode. Each prelude's existing trap
// removes HDR_FILE on exit alongside the other credential material it already cleans up.
//
// WHAT IS NOT CLAIMED: this is a repair at two call sites, not a wall. Nothing refuses the next
// prelude that interpolates a credential into a command string -- these bodies are hand-authored
// shell text, not declared operation argv, so v2.std.operation_argv's binding wall cannot see them
// at all. The class stays mitigatable here and the trigger is the same one extdeps.bmc.http names:
// credentials modeled as declared inputs whose type the binder can refuse.
// WHY THIS PAYLOAD IS SHELL TEXT AND WHAT WOULD CHANGE THAT (review 55857 asked for a bash-emit
// scaffold marker here; recorded rather than marked, because the marker would assert the wrong
// thing). This prelude is NOT the unmigrated medium-as-string form that the two scaffolds in this
// module carry a dissolution trigger for -- those mark *_script functions that concat-build a whole
// script, and their trigger text says so. gunbc_ci_fleet_key_agent_script builds a typed Pipeline of
// PipelineStep and emits it through v2.workflow.gunbc_invoke_step_emit, which IS the orchestration
// emit those triggers name as their terminal state; gunbc.fleet_ssh_credential_verify
// fleet_ssh_credential_shell_verification_supersession_note records that migration. Marking one
// command payload as a scaffold while its twenty peers in the same function carry none would claim
// this line is unmigrated debt when it sits in the migrated construction.
//
// The residue the review is right about: a Run's `command` is still shell text, corpus-wide, tracked
// by bash-emit rather than per line. And the reason no modeled actuator was available HERE is a
// boundary rather than a preference -- this prelude executes as raw shell in a FOREIGN EXECUTOR
// (GitHub Actions `run:`) before gunbc runs at all, so Filesystem.WriteOwnerOnly, which this same
// change uses for the netrc on the BMC path, is unreachable at this point in a job. Choosing printf
// is not declining a modeled route; there is no modeled route on that side of the boundary yet,
// which is exactly what bash-emit would change.
fn gunbc_ci_auth_header_file_write_command() -> String {
"printf 'Authorization: Bearer %s\\n' \"$WIF_ACCESS_TOKEN\" > \"$HDR_FILE\""
}

fn gunbc_ci_fleet_key_agent_prelude() -> List<PipelineStep> {
[
Do { run: ci_retry_body_run(command: "set -euo pipefail") },
Do { run: ci_retry_body_run(command: "umask 077") },
Do { run: ci_retry_body_run(command: "KEY_FILE=\"$RUNNER_TEMP/fleet-automation-key\"") },
Do { run: ci_retry_body_run(command: "HDR_FILE=\"$RUNNER_TEMP/fleet-automation-auth-header\"") },
Do { run: ci_retry_body_run(command: "AGENT_STARTED=0") },
Do {
run: ci_retry_body_run(
command: "cleanup() { rm -f \"$KEY_FILE\"; if [ \"$AGENT_STARTED\" = 1 ] && [ -n \"$\{SSH_AGENT_PID:-}\" ]; then ssh-agent -k >/dev/null 2>&1 || true; fi; }"
command: "cleanup() { rm -f \"$KEY_FILE\" \"$HDR_FILE\"; if [ \"$AGENT_STARTED\" = 1 ] && [ -n \"$\{SSH_AGENT_PID:-}\" ]; then ssh-agent -k >/dev/null 2>&1 || true; fi; }"
)
},
Do { run: ci_retry_body_run(command: "trap cleanup EXIT") },
Do { run: ci_retry_body_run(command: gunbc_ci_auth_header_file_write_command()) },
Do {
run: ci_retry_body_run(
command: join([
"curl -sSf -H \"Authorization: Bearer $WIF_ACCESS_TOKEN\" \"",
"curl -sSf -H @\"$HDR_FILE\" \"",
fleet_automation_privkey_sm_access_url(),
"\" | python3 -c 'import sys,json,base64; sys.stdout.buffer.write(base64.b64decode(json.load(sys.stdin)[\"payload\"][\"data\"]))' > \"$KEY_FILE\""
], "")
Expand Down Expand Up @@ -843,11 +890,13 @@ fn gunbc_ci_spark_grant_install_credential_prelude() -> List<PipelineStep> {
Do { run: ci_retry_body_run(command: "umask 077") },
Do { run: ci_retry_body_run(command: "CRED_FILE=\"$RUNNER_TEMP/spark-administrator-credential\"") },
Do { run: ci_retry_body_run(command: "RESP_FILE=\"$RUNNER_TEMP/spark-administrator-response.json\"") },
Do { run: ci_retry_body_run(command: "trap 'rm -f \"$CRED_FILE\" \"$RESP_FILE\"' EXIT") },
Do { run: ci_retry_body_run(command: "HDR_FILE=\"$RUNNER_TEMP/spark-administrator-auth-header\"") },
Do { run: ci_retry_body_run(command: "trap 'rm -f \"$CRED_FILE\" \"$RESP_FILE\" \"$HDR_FILE\"' EXIT") },
Do { run: ci_retry_body_run(command: gunbc_ci_auth_header_file_write_command()) },
Do {
run: ci_retry_body_run(
command: join([
"HTTP_CODE=\"$(curl -sS -o \"$RESP_FILE\" -w '%\{http_code}' -H \"Authorization: Bearer $WIF_ACCESS_TOKEN\" \"",
"HTTP_CODE=\"$(curl -sS -o \"$RESP_FILE\" -w '%\{http_code}' -H @\"$HDR_FILE\" \"",
secret_ref_access_url(ref: spark_administrator_password_secret_ref),
"\" || echo 000)\""
], "")
Expand Down
Loading
Loading