lib/mix/tasks/relyra.security_review.ex

defmodule Mix.Tasks.Relyra.SecurityReview do
  @moduledoc """
  Generates the checked-in security review evidence packet from executable security state.

      mix relyra.security_review
      mix relyra.security_review --output tmp/SECURITY_REVIEW_EVIDENCE.md
      mix relyra.security_review --check
  """
  @shortdoc "Generate or drift-check SECURITY_REVIEW_EVIDENCE.md."

  use Mix.Task

  alias Relyra.Security.AlgorithmPolicy

  @default_output "SECURITY_REVIEW_EVIDENCE.md"
  @legacy_override_path "test/security/strict_default_proof_test.exs"
  @escape_hatch_path "test/relyra/ecto/escape_hatch_audit_test.exs"

  @impl true
  def run(args) do
    Mix.Task.run("app.start")

    {opts, _argv, invalid} =
      OptionParser.parse(args,
        strict: [output: :string, check: :boolean],
        aliases: [o: :output]
      )

    if invalid != [] do
      Mix.raise("invalid options: #{Enum.map_join(invalid, ", ", &elem(&1, 0))}")
    end

    output_path = output_path(opts)
    contents = render_report()

    if Keyword.get(opts, :check, false) do
      check_report!(output_path, contents)
    else
      File.write!(output_path, contents)
      Mix.shell().info("relyra.security_review: wrote generated report to #{output_path}")
      :ok
    end
  end

  defp output_path(opts) do
    opts
    |> Keyword.get(:output, @default_output)
    |> Path.expand()
  end

  defp check_report!(output_path, contents) do
    case File.read(output_path) do
      {:ok, existing} when existing == contents ->
        Mix.shell().info(
          "relyra.security_review: #{output_path} matches generated security-review state"
        )

        :ok

      {:ok, _existing} ->
        Mix.raise(
          "relyra.security_review drift detected for #{output_path}; rerun mix relyra.security_review"
        )

      {:error, :enoent} ->
        Mix.raise("relyra.security_review --check target is missing: #{output_path}")

      {:error, reason} ->
        Mix.raise(
          "relyra.security_review could not read #{output_path}: #{:file.format_error(reason)}"
        )
    end
  end

  defp render_report do
    policy = AlgorithmPolicy.default()

    [
      "# Security Review Evidence",
      "",
      "Generated from executable security defaults and checked-in proof lanes in this repository.",
      "",
      "## Rerun Commands",
      "",
      "- `mix ci.security`",
      "- `mix ci.verify`",
      "- `mix relyra.conformance --check`",
      "- `mix relyra.security_review --check`",
      "- `mix test #{@legacy_override_path} --warnings-as-errors`",
      "- `mix test #{@escape_hatch_path} --warnings-as-errors`",
      "",
      "## Strict Default Evidence",
      "",
      strict_default_table(policy),
      "",
      "## Escape Hatch And Audit Evidence",
      "",
      escape_hatch_table(),
      "",
      "## Linked Artifacts",
      "",
      linked_artifacts_table(),
      ""
    ]
    |> Enum.join("\n")
  end

  defp strict_default_table(policy) do
    [
      "| claim | executable state | seam | proof command | artifact |",
      "| --- | --- | --- | --- | --- |",
      "| strict default signature policy | #{length(policy.allowed_signature_methods)} allowed signature methods; legacy SHA-1 override absent by default | `Relyra.Security.AlgorithmPolicy.default/0` | `mix test #{@legacy_override_path} --warnings-as-errors` | `test/security/strict_default_proof_test.exs` |",
      "| strict default digest policy | #{length(policy.allowed_digest_methods)} allowed digest methods; SHA-1 rejected unless time-boxed | `Relyra.Security.AlgorithmPolicy.enforce_digest_method/2` | `mix test #{@legacy_override_path} --warnings-as-errors` | `test/security/strict_default_proof_test.exs` |",
      "| relay_state raw URL rejection | opaque `rs_` handles only; raw URLs fail closed | `Relyra.Security.RelayState.validate/1` | `mix test #{@legacy_override_path} --warnings-as-errors` | `test/security/strict_default_proof_test.exs` |",
      "| signed content trust rejection | document-provided `KeyInfo` is never accepted as a trust source | `Relyra.Security.Signature.verify/3` | `mix test #{@legacy_override_path} --warnings-as-errors` | `test/security/strict_default_proof_test.exs` |"
    ]
    |> Enum.join("\n")
  end

  defp escape_hatch_table do
    [
      "| claim | executable state | seam | proof command | artifact |",
      "| --- | --- | --- | --- | --- |",
      "| legacy unsigned metadata escape hatch is explicit and time-boxed | bypass exists only through `legacy_unsigned_metadata_policy.allow_until` on a metadata source | `Relyra.Metadata.AutoRefresh.refresh/2` | `mix test #{@escape_hatch_path} --warnings-as-errors` | `test/relyra/ecto/escape_hatch_audit_test.exs` |",
      "| risky compatibility paths remain attributable | actor, cause, and correlation_id remain attached to metadata and audit rows | `Relyra.Ecto.MetadataApply` + `Relyra.Ecto.AuditWriter` | `mix test #{@escape_hatch_path} --warnings-as-errors` | `test/relyra/ecto/escape_hatch_audit_test.exs` |",
      "| reviewer-facing evidence stays redaction-safe | actor PII is omitted and correlation_id is hashed in export | `Relyra.Diagnostic.AllowList.export_audit_log/1` | `mix test #{@escape_hatch_path} --warnings-as-errors` | `test/relyra/ecto/escape_hatch_audit_test.exs` |",
      "| prior conformance and corpus regressions remain part of the packet | existing generated evidence is still required for review reruns | `Mix.Tasks.Relyra.Conformance` | `mix relyra.conformance --check` | `CONFORMANCE.md` |"
    ]
    |> Enum.join("\n")
  end

  defp linked_artifacts_table do
    [
      "| artifact | role |",
      "| --- | --- |",
      "| `SECURITY_REVIEW.md` | canonical reviewer entry point |",
      "| `docs/security_boundary.md` | trust-boundary and scope map |",
      "| `docs/security_findings.md` | findings ledger and remediation policy |",
      "| `SECURITY.md` | public policy and release prerequisites |",
      "| `CONFORMANCE.md` | generated conformance and CVE regression evidence |"
    ]
    |> Enum.join("\n")
  end
end