feat(audit): SBOM export + scheduled signed reports - #38
Merged
Conversation
A verified install means nothing if a different binary earlier on PATH is what actually executes. doctor --audit now resolves every managed tool on PATH (exec.LookPath semantics, PATHEXT-aware on windows, symlink-resolved, case-insensitive compare on windows) and reports: - shadowed-binary (critical): PATH resolves the tool name to a binary other than the one scuta verified - bin-dir-not-in-path (warning): scuta's bin directory is not on PATH, so installed tools are not runnable by name Report schema stays v1: new Tool fields effective_path and shadowed are additive and omitempty. PathEnv injects PATH/PATHEXT/GOOS/Stat/EvalSymlinks so both platforms are tested from any host. Closes #32
…apter doctor --audit --system extends the audit beyond scuta-managed tools: one adapter per package manager, each answering where a package came from and whether its integrity can be verified. Adapters report only what the manager can prove — no integrity data means IntegrityNotVerifiable, never a silent pass. Undetected managers are still listed so the report is explicit about what was and was not checked. First adapter: go install. Reads toolchain-embedded build metadata (debug/buildinfo, no commands executed) and reports module origin, version, and sum, flagging source-checkout builds (unpinned-build, info), missing module sums (no-integrity-data, info), and dirty VCS working trees (dirty-build, warning). No criticals: --system never gates CI yet. Report schema stays v1: system section and summary count are additive omitempty fields. Closes #33
Extends --system audit with three new manager adapters: - brew: reads Cellar INSTALL_RECEIPT.json directly (no shelling out). Flags third-party taps (third-party-source) and source builds (unpinned-build). Homebrew keeps no per-file manifest, so integrity is honestly reported as not-verifiable. - mise: inventories $MISE_DATA_DIR/installs, skipping symlinked partial-version aliases. Checksums live in per-project mise.lock, so integrity is not-verifiable machine-wide. - dpkg: counts packages from /var/lib/dpkg/status (summarized, not listed), runs a single 'dpkg --verify' pass. Non-conffile checksum mismatches are reported as binary-drift at critical severity: these are the first --system findings that can gate CI, deliberately, since a modified package binary is exactly what this audit exists to catch. Conffile changes are config-drift (info). Findings capped at 25 with an overflow summary. New finding codes: third-party-source, config-drift, inventory-summarized. Report schema stays v1 (all fields omitempty). Closes #34
lookPath joined candidate paths with filepath.Join, which uses the host separator. On a Windows runner that produced backslash paths, so the injected-GOOS test environments (keyed with forward slashes) never matched and all five cross-platform shadowing tests failed there. Join with a forward slash instead, trimming trailing separators per the injected GOOS. Every supported platform accepts forward slashes, so the real check is unaffected; lookups now depend only on the injected GOOS, not the machine running them.
scuta doctor --audit --sbom cyclonedx emits the audit inventory as a CycloneDX 1.5 JSON document: managed tools, plus system packages when combined with --system. Pure projection of data the audit already collects; no additional inspection. - New lib/sbom: hand-rolled minimal document (no new dependency), RFC 4122 v4 serial number, timestamp from the report - purls: managed tools pkg:github/<repo>@<version>, go modules pkg:golang, brew pkg:brew, mise pkg:generic; bom-ref falls back to an origin-qualified name@version when no purl exists so same-named packages from different managers cannot collide - Recorded install hashes exported as SHA-256; audit verdicts carried as scuta: properties (verified, drift, shadowed, manager, integrity) so not-verifiable stays visible in the export - dpkg summarizes its inventory, so it contributes no components - --sbom requires --audit; unknown formats are rejected; critical findings still gate the exit code, so CI can archive the SBOM and fail the build in one invocation Closes #35
Turns the audit into a recurring posture feed without a server, an agent, or any upload. Sender-only per the descope on #36: scuta writes a local report and your own collector ships it. doctor --audit gains file-output plumbing: - --output: write the machine document (report, or SBOM with --sbom) atomically via temp file + rename, so collectors never see a half-written file - --sign-key: detached Ed25519 signature at <output>.sig, reusing the admin keygen/sign/verify machinery, so posture claims are attributable to a machine - --if-changed: skip the write (and re-sign) when the report's stable hash (generation time zeroed) matches the previous run scuta monitor install|uninstall|status schedules the audit with the OS scheduler: launchd agent on macOS, systemd user timer on Linux. No daemon, no resident process. Windows returns an explicit not-supported error (Task Scheduler support is a roadmap item). Activation failures keep the unit files and print manual instructions. Runs with critical findings exit non-zero, which the scheduler surfaces as a failed job. docs/FLEET.md documents fleet aggregation with existing sinks (Splunk, S3 + Athena, any webhook) and what to alert on. The upload-to-URL piece of #36 stays deferred; no network code was added. Closes #36
Same host leak as the Phase A PATH lookup: filepath.Join uses the host separator, so on a Windows runner the launchd/systemd unit paths came out with backslashes and the injected-GOOS tests never matched their fake filesystems. Use slash-based path.Join instead. The generated units only ever exist on darwin and linux, where forward slashes are the native separator, so real behavior is unchanged; construction now depends only on the injected GOOS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phases D and E of the system-audit roadmap (#35, #36). Phase D exports the audit inventory as a CycloneDX 1.5 SBOM; Phase E turns the audit into a recurring, signed, sender-only posture feed with no server and no upload code.
Phase D: SBOM export (#35)
scuta doctor --audit --sbom cyclonedxemits the audit inventory (managed tools, plus system packages with--system) as a CycloneDX 1.5 JSON document. Pure projection of data Phases A-C already collect.lib/sbom: hand-rolled minimal document rather than a cyclonedx-go dependency (~180 lines for the subset scuta needs)pkg:github, go modulespkg:golang, brewpkg:brew, misepkg:generic;bom-reffalls back to origin-qualifiedmanager:name@versionso same-named packages cannot collidescuta:properties (verified,drift,shadowed,manager,integrity) so the honest integrity states survive the export--sbomrequires--audit; unknown formats rejected; criticals still gate the exit codePhase E: scheduled audits, signed local reports (#36, descoped)
Sender-only per the descope: scuta writes a local report, your own collector ships it. No ingestion server, no dashboard, no network code added.
doctor --audit --output <file>: atomic write (temp + rename) of the machine document;--sign-key: detached Ed25519 signature at<output>.sigreusing theadmin keygen/sign/verifymachinery;--if-changed: skip the write when the report's stable hash (generation time zeroed) matches the previous runscuta monitor install|uninstall|status: schedules the audit with the OS scheduler (launchd agent on macOS, systemd user timer on Linux). No daemon or resident process. Windows returns an explicit not-supported error (Task Scheduler is a roadmap item). Activation failures keep the unit files and print manual instructionsdocs/FLEET.md: fleet aggregation with existing sinks (Splunk HEC, S3 + Athena, any webhook) and what to alert onDesign notes
lib/monitorinjects all OS touchpoints (GOOS, home, uid, executable, fs ops, command runner), so launchd and systemd generation are both tested from any host; plist arguments are XML-escaped, systemd ExecStart is quoted per unit syntaxReport.StableHash()lives inlib/audit: SHA-256 over the report withgenerated_atzeroed, so unchanged findings hash identically across runsTesting
go test -race ./...green (31 pkgs), darwin + windows/amd64 builds, golangci-lint 0 issues--output-> second run skips via--if-changed->admin verifypasses; all flag-combination guards exit 1 with clear messages;monitor statusreports correctlyAlso updates README (commands, Security section, roadmap), docs/SECURITY.md recommendations, and doctor help text.
Closes #35
Closes #36