Findings from a full-repo audit at ed5f1c4. Baseline is clean: go vet, golangci-lint run (repo config), go test -race ./..., govulncheck, and osv-scanner all pass. Everything below came from extra linters and from reading the paths that construct remote commands, derive names, and manage fencing.
Status
Each item is tracked as a sub-issue, so the progress bar above this body is the
real state rather than something that has to be kept in sync by hand. Severity
is this audit's own assessment, revised where later work changed it.
| # |
Item |
Severity |
State |
| #53 |
1 — Unquoted config value reaches a remote shell |
high |
fixed in #52 (81d0ead) |
| #54 |
2 — Environment names are never validated |
low |
fixed in #73 |
| #55 |
3 — Fence epoch fails open on an unreadable epoch file |
high impact, low probability |
fixed in #85 (95c6fd7) |
| #56 |
4 — Four derived names use hyphen joins the naming contract forbids |
low-moderate |
open |
| #57 |
5 — Stale timer sweep skips removal when disable fails |
moderate |
open |
| #58 |
6 — Runtime names carry no environment |
high |
fixed in #71 |
| #59 |
7 — internal/engine/protection_units.go has no production implementation |
dead code |
removed in #51 (f9ef6bc) |
| #60 |
8 — Exported symbols with no references |
dead code |
removed in #51 (f9ef6bc) |
| #61 |
9 — Smaller items |
low |
open |
| #62 |
10 — Linters worth adding to the gate |
low |
open — unused enabled in #51; five others outstanding |
The sections below are the original audit, left as written so the reasoning that
produced each item stays readable. Two assessments have since changed and are
corrected in place at the items concerned: item 1's payload constraints, and
item 2's severity.
The remaining audit work is #56, #57, #61 and #62. Of those, #57 is the
highest-impact behavior defect; the others are lower-priority naming,
correctness-cleanup and tooling work.
1. Unquoted config value reaches a remote shell
Fixed in #52. One correction to what follows: gURLPath also refuses
spaces, which this write-up does not mention, so a payload has to be
space-free. /health;id below works; a touch /tmp/x variant would not. That
narrows the shape of an attack and changes nothing about whether one exists.
The sibling in generate.go was also worse than rated here — see the comment
thread.
internal/engine/verify.go:303
cres, err := e.T.Run(ctx, fmt.Sprintf("curl -fsS -m 5 http://%s:%d%s", ip, port, chk.HTTP))
chk.HTTP is interpolated without quoting. Its grammar, gURLPath in internal/app/constraints.go:68, rejects ' " $ `, space, and control characters, but permits ;, |, &, ( and ). A value of /health;id passes validation and the trailing command runs on the target through the deploy transport.
Two things suggest this is an omission rather than a decision:
ip is guarded against metacharacters three lines above (verify.go:300), so the surrounding code is aware of the risk on this line.
- The adjacent
chk.Exec branch at verify.go:311 does quote, via q(chk.Exec).
Suggested fix: quote the whole constructed URL rather than the path alone.
cres, err := e.T.Run(ctx, "curl -fsS -m 5 "+q(fmt.Sprintf("http://%s:%d%s", ip, port, chk.HTTP)))
Same class, smaller blast radius because it executes inside the container rather than on the host: internal/app/generate.go:576 builds a CMD-SHELL healthcheck from the same unquoted URL shape.
2. Environment names are never validated
Downgraded to low after #51. This item was rated on the environment name
reaching systemd unit filenames. That path was protection_units.go, which
#51 deleted as dead code. ProtectionTimerForEnvironment now has one caller,
internal/onebox/protected_identity.go:58, which seals the derived names into
a JSON identity record rather than executing them. The name still reaches
notification payloads, journals and error strings — no shell, no filename. The
validation gap is real and worth closing for consistency; it is no longer a
security finding.
internal/app/validate.go:20-24
Every other map key in the spec is checked against gIdent:
| Block |
Check |
backup_targets |
validate.go:26 |
external_services |
validate.go:34 |
workloads |
validate.go:42 |
services |
validate.go:50 |
environments |
none |
The loop over p.Environments validates each environment's contents but never its key. That key reaches systemd unit filenames through Names.ProtectionTimerForEnvironment, the X-Onebox-Environment= metadata in renderProtectionServiceUnit, and the ownership prefix compared in ReconcileProtectionUnits.
Suggested fix: add gIdent.check("environments."+name, name) alongside the existing validateEnvironment call.
3. Fence epoch fails open on an unreadable epoch file
internal/engine/lock.go:56-61, and the same shape in internal/engine/protection_lock.go:162-167
eres, err := e.T.Run(ctx, "cat "+q(e.epochPath())+" 2>/dev/null || echo 0")
...
prev, _ := strconv.Atoi(strings.TrimSpace(eres.Stdout))
epoch := prev + 1
An epoch file that is missing, unreadable, truncated, or non-numeric collapses to prev = 0, and the next epoch becomes 1. The comment at lock.go:52-55 states that fencing depends on a strictly increasing epoch, so this silently reissues an epoch a stale runner may still hold in its fence value.
This is also the inconsistent half of a pair. lockAgeCmd directly below (lock.go:176-194) goes to considerable length to fail closed for exactly this threat, including the "absence has to be established, not assumed" arm.
Two sub-parts:
- Distinguish "no epoch file yet" from "epoch file unreadable or malformed". The first is legitimately 0; the second should refuse.
lock.go:77 persists the epoch with echo N > file, which is neither atomic nor durable. A partial write leaves an empty file, which then reads back as 0. internal/release/secrets.go:217 already uses mktemp + mv for the same kind of write.
4. Four derived names use hyphen joins the naming contract forbids
internal/app/names.go
The header comment at names.go:15-20 explains that hyphen joins are ambiguous — ob-<app>-<service> maps both (a-b, c) and (a, b-c) to ob-a-b-c — and that this is why persistent names use join (underscore) and runtime names use runtimeName (authored - escaped to --).
Four functions bypass both:
names.go:176 ProtectionTimer
names.go:180 ProtectionTimerForEnvironment
names.go:103 ProtectionCredentialFile
names.go:123 ProtectionEnvelopePath
gIdent permits hyphens (^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$), so the collision is reachable. Systemd unit names are host-global, which makes the two timer functions the ones that matter most.
ProtectionTimer has no callers and can simply be deleted; see item 8.
5. Stale timer sweep skips removal when disable fails
internal/engine/schedule.go:93
"systemctl disable --now %s.timer >/dev/null 2>&1 && rm -f /etc/systemd/system/%s.timer /etc/systemd/system/%s.service"
The && ties removal to a successful disable. A unit systemd cannot disable leaves both files on disk, and because the command runs under mutateChecked the non-zero status aborts the deploy — so the sweep neither completes nor leaves the host in the state the next run expects. The disable and the removal want separate handling.
6. Runtime names carry no environment, and nothing guards two environments on one host
ComposeProject() is the bare application name (names.go:56), Container(workload, replica) is <app>-<workload>-<n> (names.go:187), and WorkloadVolume is ob_<app>_<workload>_<volume> (names.go:152). None carry the environment.
Meanwhile Environment.BasePath (types.go:71) is a per-environment override, and the host owner record is application-scoped (internal/engine/host_owner.go:69) — it compares owner != e.Spec.Name and nothing else. So pointing staging at production's server passes ownership, and passes preflight too, because preflight looks for foreign resources and these names are the application's own. The result is staging adopting production's containers and volumes while writing releases under a different base path.
The model is also inconsistent with itself: ProtectionTimerForEnvironment does carry the environment, and ProtectionTimer (the variant without it) is dead.
Either record the environment alongside the application in the host owner record and refuse a mismatch, or state one-environment-per-host as a documented constraint. Right now it is neither enforced nor written down.
7. internal/engine/protection_units.go has no production implementation
The file is 274 lines. ProtectionUnitTarget (:61), ReconcileProtectionUnits (:124), and InspectProtectionUnits (:203) are referenced only from protection_units_test.go, which supplies an in-memory memoryProtectionSystemd double. There is no implementation of the interface outside tests and no caller in cmd/ or internal/.
Live scheduling goes through internal/engine/schedule.go instead, which writes units directly and has its own ownership convention.
Worth resolving one way or the other, since the two files encode different rules for the same host resource: protection_units.go embeds X-Onebox-* ownership metadata and refuses to overwrite foreign units, while schedule.go matches on filename prefix alone.
8. Exported symbols with no references
Zero references anywhere in internal/, cmd/, or e2e/:
| Symbol |
Location |
release.Activate |
internal/release/release.go:202 |
Spec.ComposeRefsOf |
internal/app/compose.go:356 |
InspectProtectionUnits |
internal/engine/protection_units.go:203 |
LoadMigrationBackupOverride |
internal/onebox/backup_evidence.go:928 |
transport.NewSSH |
internal/transport/ssh.go:43 |
Names.ProtectionTimer |
internal/app/names.go:176 |
Rendered.Runnable |
internal/app/generate.go:36 |
SaveScheduledOperationEnvelope |
internal/onebox/scheduled_envelope.go:388 |
ScheduledArtifactsAsProtectionResources |
internal/onebox/scheduled_install.go:125 |
ActiveVolumeRecord.Selection |
internal/onebox/active_volume.go:146 |
SortedRouteKeys |
internal/app/generate.go:739 |
SortedRouteKeys carries the comment "exported for tests that assert label determinism", but no test calls it. release.Activate is the surprising entry — presumably superseded by the manifest activation path.
These are invisible to the unused linter because they are exported, so they sit alongside the 13 unexported findings that .golangci.yml already documents as held back.
9. Smaller items
internal/notify/notify.go:97 uses http.NewRequest rather than NewRequestWithContext. The webhook ignores operation cancellation; only the 5s client timeout bounds it. Line 85 also assigns body, contentType := []byte(nil), "application/json" and then reassigns both in each branch.
internal/transport/ssh.go:252 type-asserts err.(*ssh.ExitError) instead of using errors.As. A wrapped exit error would be reported as a transport failure rather than surfacing its exit code — which matters here, because several call sites branch on res.ExitCode for expected non-zero statuses. Same shape at cmd/ob/preview.go:135 for *app.Error.
%v where %w is intended, so callers cannot errors.Is through: internal/engine/bootstrap.go:60, internal/engine/gate.go:260, internal/engine/lock.go:79.
cmd/ob-scheduled-runner/main.go:77 calls os.Exit(130), skipping defer stop().
10. Linters worth adding to the gate
.golangci.yml states that the gate should be green from the first run and should name what it does not yet check. These five are close to that bar today:
| Linter |
Findings |
Non-test |
errorlint |
6 |
4 |
noctx |
9 |
3 |
copyloopvar |
1 |
1 |
unconvert |
3 |
3 |
intrange |
4 |
2 |
errorlint and noctx overlap with items 1, 9, and the *ssh.ExitError assertion above, so they would have caught real findings rather than style. Reproduce with:
golangci-lint run --no-config --default=none \
--enable=errorlint,noctx,copyloopvar,unconvert,intrange ./...
contextcheck, gocritic, and prealloc were also run; their findings are style or false positives against this codebase's conventions and are not included here.
Findings from a full-repo audit at
ed5f1c4. Baseline is clean:go vet,golangci-lint run(repo config),go test -race ./...,govulncheck, andosv-scannerall pass. Everything below came from extra linters and from reading the paths that construct remote commands, derive names, and manage fencing.Status
Each item is tracked as a sub-issue, so the progress bar above this body is the
real state rather than something that has to be kept in sync by hand. Severity
is this audit's own assessment, revised where later work changed it.
81d0ead)95c6fd7)internal/engine/protection_units.gohas no production implementationf9ef6bc)f9ef6bc)unusedenabled in #51; five others outstandingThe sections below are the original audit, left as written so the reasoning that
produced each item stays readable. Two assessments have since changed and are
corrected in place at the items concerned: item 1's payload constraints, and
item 2's severity.
The remaining audit work is #56, #57, #61 and #62. Of those, #57 is the
highest-impact behavior defect; the others are lower-priority naming,
correctness-cleanup and tooling work.
1. Unquoted config value reaches a remote shell
internal/engine/verify.go:303chk.HTTPis interpolated without quoting. Its grammar,gURLPathininternal/app/constraints.go:68, rejects' " $`, space, and control characters, but permits;,|,&,(and). A value of/health;idpasses validation and the trailing command runs on the target through the deploy transport.Two things suggest this is an omission rather than a decision:
ipis guarded against metacharacters three lines above (verify.go:300), so the surrounding code is aware of the risk on this line.chk.Execbranch atverify.go:311does quote, viaq(chk.Exec).Suggested fix: quote the whole constructed URL rather than the path alone.
Same class, smaller blast radius because it executes inside the container rather than on the host:
internal/app/generate.go:576builds aCMD-SHELLhealthcheck from the same unquoted URL shape.2. Environment names are never validated
internal/app/validate.go:20-24Every other map key in the spec is checked against
gIdent:backup_targetsvalidate.go:26external_servicesvalidate.go:34workloadsvalidate.go:42servicesvalidate.go:50environmentsThe loop over
p.Environmentsvalidates each environment's contents but never its key. That key reaches systemd unit filenames throughNames.ProtectionTimerForEnvironment, theX-Onebox-Environment=metadata inrenderProtectionServiceUnit, and the ownership prefix compared inReconcileProtectionUnits.Suggested fix: add
gIdent.check("environments."+name, name)alongside the existingvalidateEnvironmentcall.3. Fence epoch fails open on an unreadable epoch file
internal/engine/lock.go:56-61, and the same shape ininternal/engine/protection_lock.go:162-167An epoch file that is missing, unreadable, truncated, or non-numeric collapses to
prev = 0, and the next epoch becomes 1. The comment atlock.go:52-55states that fencing depends on a strictly increasing epoch, so this silently reissues an epoch a stale runner may still hold in its fence value.This is also the inconsistent half of a pair.
lockAgeCmddirectly below (lock.go:176-194) goes to considerable length to fail closed for exactly this threat, including the "absence has to be established, not assumed" arm.Two sub-parts:
lock.go:77persists the epoch withecho N > file, which is neither atomic nor durable. A partial write leaves an empty file, which then reads back as 0.internal/release/secrets.go:217already usesmktemp+mvfor the same kind of write.4. Four derived names use hyphen joins the naming contract forbids
internal/app/names.goThe header comment at
names.go:15-20explains that hyphen joins are ambiguous —ob-<app>-<service>maps both(a-b, c)and(a, b-c)toob-a-b-c— and that this is why persistent names usejoin(underscore) and runtime names useruntimeName(authored-escaped to--).Four functions bypass both:
names.go:176ProtectionTimernames.go:180ProtectionTimerForEnvironmentnames.go:103ProtectionCredentialFilenames.go:123ProtectionEnvelopePathgIdentpermits hyphens (^[a-z]([a-z0-9-]{0,38}[a-z0-9])?$), so the collision is reachable. Systemd unit names are host-global, which makes the two timer functions the ones that matter most.ProtectionTimerhas no callers and can simply be deleted; see item 8.5. Stale timer sweep skips removal when disable fails
internal/engine/schedule.go:93"systemctl disable --now %s.timer >/dev/null 2>&1 && rm -f /etc/systemd/system/%s.timer /etc/systemd/system/%s.service"The
&&ties removal to a successful disable. A unit systemd cannot disable leaves both files on disk, and because the command runs undermutateCheckedthe non-zero status aborts the deploy — so the sweep neither completes nor leaves the host in the state the next run expects. The disable and the removal want separate handling.6. Runtime names carry no environment, and nothing guards two environments on one host
ComposeProject()is the bare application name (names.go:56),Container(workload, replica)is<app>-<workload>-<n>(names.go:187), andWorkloadVolumeisob_<app>_<workload>_<volume>(names.go:152). None carry the environment.Meanwhile
Environment.BasePath(types.go:71) is a per-environment override, and the host owner record is application-scoped (internal/engine/host_owner.go:69) — it comparesowner != e.Spec.Nameand nothing else. So pointingstagingat production's server passes ownership, and passes preflight too, because preflight looks for foreign resources and these names are the application's own. The result isstagingadopting production's containers and volumes while writing releases under a different base path.The model is also inconsistent with itself:
ProtectionTimerForEnvironmentdoes carry the environment, andProtectionTimer(the variant without it) is dead.Either record the environment alongside the application in the host owner record and refuse a mismatch, or state one-environment-per-host as a documented constraint. Right now it is neither enforced nor written down.
7.
internal/engine/protection_units.gohas no production implementationThe file is 274 lines.
ProtectionUnitTarget(:61),ReconcileProtectionUnits(:124), andInspectProtectionUnits(:203) are referenced only fromprotection_units_test.go, which supplies an in-memorymemoryProtectionSystemddouble. There is no implementation of the interface outside tests and no caller incmd/orinternal/.Live scheduling goes through
internal/engine/schedule.goinstead, which writes units directly and has its own ownership convention.Worth resolving one way or the other, since the two files encode different rules for the same host resource:
protection_units.goembedsX-Onebox-*ownership metadata and refuses to overwrite foreign units, whileschedule.gomatches on filename prefix alone.8. Exported symbols with no references
Zero references anywhere in
internal/,cmd/, ore2e/:release.Activateinternal/release/release.go:202Spec.ComposeRefsOfinternal/app/compose.go:356InspectProtectionUnitsinternal/engine/protection_units.go:203LoadMigrationBackupOverrideinternal/onebox/backup_evidence.go:928transport.NewSSHinternal/transport/ssh.go:43Names.ProtectionTimerinternal/app/names.go:176Rendered.Runnableinternal/app/generate.go:36SaveScheduledOperationEnvelopeinternal/onebox/scheduled_envelope.go:388ScheduledArtifactsAsProtectionResourcesinternal/onebox/scheduled_install.go:125ActiveVolumeRecord.Selectioninternal/onebox/active_volume.go:146SortedRouteKeysinternal/app/generate.go:739SortedRouteKeyscarries the comment "exported for tests that assert label determinism", but no test calls it.release.Activateis the surprising entry — presumably superseded by the manifest activation path.These are invisible to the
unusedlinter because they are exported, so they sit alongside the 13 unexported findings that.golangci.ymlalready documents as held back.9. Smaller items
internal/notify/notify.go:97useshttp.NewRequestrather thanNewRequestWithContext. The webhook ignores operation cancellation; only the 5s client timeout bounds it. Line 85 also assignsbody, contentType := []byte(nil), "application/json"and then reassigns both in each branch.internal/transport/ssh.go:252type-assertserr.(*ssh.ExitError)instead of usingerrors.As. A wrapped exit error would be reported as a transport failure rather than surfacing its exit code — which matters here, because several call sites branch onres.ExitCodefor expected non-zero statuses. Same shape atcmd/ob/preview.go:135for*app.Error.%vwhere%wis intended, so callers cannoterrors.Isthrough:internal/engine/bootstrap.go:60,internal/engine/gate.go:260,internal/engine/lock.go:79.cmd/ob-scheduled-runner/main.go:77callsos.Exit(130), skippingdefer stop().10. Linters worth adding to the gate
.golangci.ymlstates that the gate should be green from the first run and should name what it does not yet check. These five are close to that bar today:errorlintnoctxcopyloopvarunconvertintrangeerrorlintandnoctxoverlap with items 1, 9, and the*ssh.ExitErrorassertion above, so they would have caught real findings rather than style. Reproduce with:contextcheck,gocritic, andpreallocwere also run; their findings are style or false positives against this codebase's conventions and are not included here.