Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- feat(slsa): GitHub native build-provenance attestation verification (#56) — `Opsm.Slsa.GithubAttestation` fetches Sigstore attestation records from the GitHub attestations API, verification is delegated to checky-monkey's new `POST /verify/github-attestation` endpoint (wrapping `gh attestation verify`) with a local-CLI SafeExec fallback, and `Opsm.Slsa.Provenance.verify_github_attestation/2` trusts the GitHub Actions builder identity only after the bundle cryptographically verifies. Wired into the trust pipeline as the fail-open `:github_attestation` check.
- feat(storage): S3/IPFS tarball cache backends replacing /tmp-only caching
- feat(security): CVE/OSV scanning + typosquat detection
- feat(tui): wire opsm tui dispatch to ratatui binary
Expand Down
36 changes: 36 additions & 0 deletions opsm_ex/lib/opsm/clients/checky_monkey.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule Opsm.Clients.CheckyMonkey do
HttpConfig,
CheckyMonkeyRequest,
CheckyMonkeyResponse,
GithubAttestationVerification,
VerificationResult,
OikosHealthResponse
}
Expand Down Expand Up @@ -79,6 +80,41 @@ defmodule Opsm.Clients.CheckyMonkey do
end
end

@doc """
Verify a GitHub native build-provenance attestation (Sigstore bundle)
for an artifact. The service wraps `gh attestation verify`.

Request map keys: `:owner_repo` (required, `"owner/repo"`), plus one
subject — `:oci_uri` (`"oci://..."`) or `:artifact_path` (a path the
service can read). Returns `{:ok, %GithubAttestationVerification{}}`
or `{:error, reason}` (service unreachable / bad request).
"""
def verify_github_attestation(%__MODULE__{client: client}, request) when is_map(request) do
body = %{
"ownerRepo" => Map.fetch!(request, :owner_repo),
"ociUri" => Map.get(request, :oci_uri),
"artifactPath" => Map.get(request, :artifact_path)
}

case Http.post_json_get_json(client, "/verify/github-attestation", body) do
{:ok, json} when is_map(json) ->
{:ok,
%GithubAttestationVerification{
verified: json["verified"] == true,
builder_id: json["builderId"],
predicate_type: json["predicateType"],
message: json["message"] || "",
details: json["details"]
}}

{:ok, _other} ->
{:error, "Unexpected response shape from checky-monkey"}

{:error, reason} ->
{:error, reason}
end
end

@doc """
Check service health.
"""
Expand Down
57 changes: 40 additions & 17 deletions opsm_ex/lib/opsm/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,23 +78,7 @@ defmodule Opsm.Http do
def get_json(client, path) do
case Req.get(client, url: path) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
# Validate JSON response if it's a string
case body do
body when is_binary(body) ->
case Json.decode(body) do
{:ok, parsed} -> {:ok, parsed}
{:error, reason} -> {:error, "JSON validation failed: #{inspect(reason)}"}
end

body when is_map(body) or is_list(body) ->
# Already parsed by Req, return as-is
# Note: We trust Req's JSON decoder for now, but in v2.0
# we should validate all JSON regardless of source
{:ok, body}

_other ->
{:error, "Invalid response body type"}
end
validate_json_body(body)

{:ok, %Req.Response{status: status, body: body}} ->
{:error, "HTTP error: #{status} - #{inspect(body)}"}
Expand All @@ -104,6 +88,45 @@ defmodule Opsm.Http do
end
end

@doc """
POST JSON to a path and return the parsed JSON response body.

Unlike `post_json/3` (which discards 2xx bodies), this is for endpoints
whose response payload matters. Validated via Verified.Json like `get_json/2`.
"""
def post_json_get_json(client, path, body) do
case Req.post(client, url: path, json: body) do
{:ok, %Req.Response{status: status, body: rbody}} when status in 200..299 ->
validate_json_body(rbody)

{:ok, %Req.Response{status: status, body: rbody}} ->
{:error, "HTTP error: #{status} - #{inspect(rbody)}"}

{:error, reason} ->
{:error, "Request failed: #{inspect(reason)}"}
end
end

# Validate a response body: strings go through Verified.Json (size/depth
# limits); Req-parsed maps/lists pass through.
defp validate_json_body(body) do
case body do
body when is_binary(body) ->
case Json.decode(body) do
{:ok, parsed} -> {:ok, parsed}
{:error, reason} -> {:error, "JSON validation failed: #{inspect(reason)}"}
end

body when is_map(body) or is_list(body) ->
# Already parsed by Req. Note: we trust Req's JSON decoder for now,
# but in v2.0 we should validate all JSON regardless of source.
{:ok, body}

_other ->
{:error, "Invalid response body type"}
end
end

@doc """
Build URL by joining base and path.
"""
Expand Down
3 changes: 2 additions & 1 deletion opsm_ex/lib/opsm/safe_exec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ defmodule Opsm.SafeExec do

with :ok <- validate_command(command, allowlist),
:ok <- validate_args(args) do
System.cmd(command, args, opts)
# :allowlist is ours, not System.cmd's — it rejects unknown options
System.cmd(command, args, Keyword.delete(opts, :allowlist))
else
{:error, reason} -> {"safe-exec blocked: #{reason}", 1}
end
Expand Down
Loading
Loading