Skip to content

feat: add self-grading Smart Cell #84

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 7, 2025
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 .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.DS_Store
122 changes: 93 additions & 29 deletions modules/2-owasp.livemd
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
# ESCT: Part 2 - OWASP

```elixir
Mix.install([
{:grading_client, path: "#{__DIR__}/grading_client"},
:bcrypt_elixir,
:httpoison,
{:absinthe, "~> 1.7.0"},
{:phoenix, "~> 1.0"},
{:plug, "~> 1.3.2"}
])
Mix.install(
[
{:grading_client, path: "#{__DIR__}/grading_client"}
],
config_path: "#{__DIR__}/grading_client/config/config.exs"
)

md5_hash = :crypto.hash(:md5, "users_password")
bcrypt_salted_hash = Bcrypt.hash_pwd_salt("users_password")
Expand Down Expand Up @@ -103,27 +101,55 @@ Notable CWEs included are CWE-259: Use of Hard-coded Password, CWE-327: Broken o

_Please uncomment the function call that you believe is correct._

<!-- livebook:{"attrs":"eyJtb2R1bGVfaWQiOm51bGwsInF1ZXN0aW9uX2lkIjpudWxsLCJzb3VyY2UiOiIjT1dBU1A6MVxuZGVmbW9kdWxlIFBhc3N3b3JkQ29tcGFyZSBkb1xuICBkZWYgb3B0aW9uX29uZShwYXNzd29yZCwgbWQ1X2hhc2gpIGRvXG4gICAgY2FzZSA6Y3J5cHRvLmhhc2goOm1kNSwgcGFzc3dvcmQpID09IG1kNV9oYXNoIGRvXG4gICAgICB0cnVlIC0+IDplbnRyeV9ncmFudGVkX29wMVxuICAgICAgZmFsc2UgLT4gOmVudHJ5X2RlbmllZF9vcDFcbiAgICBlbmRcbiAgZW5kXG5cbiAgZGVmIG9wdGlvbl90d28ocGFzc3dvcmQsIGJjcnlwdF9zYWx0ZWRfaGFzaCkgZG9cbiAgICBjYXNlIEJjcnlwdC52ZXJpZnlfcGFzcyhwYXNzd29yZCwgYmNyeXB0X3NhbHRlZF9oYXNoKSBkb1xuICAgICAgdHJ1ZSAtPiA6ZW50cnlfZ3JhbnRlZF9vcDJcbiAgICAgIGZhbHNlIC0+IDplbnRyeV9kZW5pZWRfb3AyXG4gICAgZW5kXG4gIGVuZFxuZW5kXG5cbiMgRE8gTk9UIENIQU5HRSBDT0RFIEFCT1ZFIFRISVMgTElORSA9PT09PT09PT09PT09PT09PT09PT09PT09XG5cbiMgUGFzc3dvcmRDb21wYXJlLm9wdGlvbl9vbmUoXCJ1c2Vyc19wYXNzd29yZFwiLCBtZDVfaGFzaClcbiMgUGFzc3dvcmRDb21wYXJlLm9wdGlvbl90d28oXCJ1c2Vyc19wYXNzd29yZFwiLCBiY3J5cHRfc2FsdGVkX2hhc2gpIn0","chunks":null,"kind":"Elixir.GradingClient.GradedCell","livebook_object":"smart_cell"} -->

```elixir
defmodule PasswordCompare do
def option_one(password, md5_hash) do
case :crypto.hash(:md5, password) == md5_hash do
true -> :entry_granted_op1
false -> :entry_denied_op1
result =
defmodule PasswordCompare do
def option_one(password, md5_hash) do
case :crypto.hash(:md5, password) == md5_hash do
true -> :entry_granted_op1
false -> :entry_denied_op1
end
end
end

def option_two(password, bcrypt_salted_hash) do
case Bcrypt.verify_pass(password, bcrypt_salted_hash) do
true -> :entry_granted_op2
false -> :entry_denied_op2
def option_two(password, bcrypt_salted_hash) do
case Bcrypt.verify_pass(password, bcrypt_salted_hash) do
true -> :entry_granted_op2
false -> :entry_denied_op2
end
end
end
end

# DO NOT CHANGE CODE ABOVE THIS LINE =========================
[module_id, question_id] =
"#OWASP:1\ndefmodule PasswordCompare do\n def option_one(password, md5_hash) do\n case :crypto.hash(:md5, password) == md5_hash do\n true -> :entry_granted_op1\n false -> :entry_denied_op1\n end\n end\n\n def option_two(password, bcrypt_salted_hash) do\n case Bcrypt.verify_pass(password, bcrypt_salted_hash) do\n true -> :entry_granted_op2\n false -> :entry_denied_op2\n end\n end\nend\n\n# DO NOT CHANGE CODE ABOVE THIS LINE =========================\n\n# PasswordCompare.option_one(\"users_password\", md5_hash)\n# PasswordCompare.option_two(\"users_password\", bcrypt_salted_hash)"
|> String.split("\n", parts: 2)
|> hd()
|> String.trim_leading("#")
|> String.split(":", parts: 2)

module_id =
case %{"OWASP" => OWASP}[String.trim(module_id)] do
nil -> raise "invalid module id: #{module_id}"
module_id -> module_id
end

question_id =
case Integer.parse(String.trim(question_id)) do
{id, ""} -> id
_ -> raise "invalid question id: #{question_id}"
end

case GradingClient.check_answer(module_id, question_id, result) do
:correct ->
IO.puts([IO.ANSI.green(), "Correct!", IO.ANSI.reset()])

{:incorrect, help_text} when is_binary(help_text) ->
IO.puts([IO.ANSI.red(), "Incorrect: ", IO.ANSI.reset(), help_text])

# PasswordCompare.option_one("users_password", md5_hash)
# PasswordCompare.option_two("users_password", bcrypt_salted_hash)
_ ->
IO.puts([IO.ANSI.red(), "Incorrect.", IO.ANSI.reset()])
end
```

<!-- livebook:{"branch_parent_index":3} -->
Expand Down Expand Up @@ -244,19 +270,57 @@ Notable CWE included is CWE-1104: Use of Unmaintained Third-Party Components

### <span style="color:red">QUIZ</span>

**Which of the outdated components currently installed is vulnerable?**
**Which of the outdated components listed below is vulnerable?**

_Please change the atom below to the name of the vulnerable package installed in this Livebook AND update the afflicted package._

_HINT: Installed dependencies can be found at the very top, it was the very first cell you ran._
_HINT: Check the changelogs for each dependency._

<!-- livebook:{"attrs":"eyJtb2R1bGVfaWQiOm51bGwsInF1ZXN0aW9uX2lkIjpudWxsLCJzb3VyY2UiOiIjT1dBU1A6MlxuYW5zd2VyID0gXG4gIEtpbm8uSW5wdXQuc2VsZWN0KFwiQW5zd2VyXCIsIFtcbiAgICB7OmVjdG8sIFwiRWN0byB2Mi4yLjJcIn0sXG4gICAgezpueCwgXCJOeCB2MC41LjBcIn0sXG4gICAgezpwbHVnLCBcIlBsdWcgdjEuMy4yXCJ9XG4gIF0pXG5cbktpbm8ucmVuZGVyKGFuc3dlcilcblxuS2luby5JbnB1dC5yZWFkKGFuc3dlcikifQ","chunks":null,"kind":"Elixir.GradingClient.GradedCell","livebook_object":"smart_cell"} -->

```elixir
# CHANGE ME
vulnerable_dependency = :vulnerable_dependency
result =
(
answer =
Kino.Input.select("Answer",
ecto: "Ecto v2.2.2",
nx: "Nx v0.5.0",
plug: "Plug v1.3.2"
)

Kino.render(answer)
Kino.Input.read(answer)
)

[module_id, question_id] =
"#OWASP:2\nanswer = \n Kino.Input.select(\"Answer\", [\n {:ecto, \"Ecto v2.2.2\"},\n {:nx, \"Nx v0.5.0\"},\n {:plug, \"Plug v1.3.2\"}\n ])\n\nKino.render(answer)\n\nKino.Input.read(answer)"
|> String.split("\n", parts: 2)
|> hd()
|> String.trim_leading("#")
|> String.split(":", parts: 2)

module_id =
case %{"OWASP" => OWASP}[String.trim(module_id)] do
nil -> raise "invalid module id: #{module_id}"
module_id -> module_id
end

question_id =
case Integer.parse(String.trim(question_id)) do
{id, ""} -> id
_ -> raise "invalid question id: #{question_id}"
end

case GradingClient.check_answer(module_id, question_id, result) do
:correct ->
IO.puts([IO.ANSI.green(), "Correct!", IO.ANSI.reset()])

# DO NOT CHANGE CODE BELOW THIS LINE ============================
Application.spec(vulnerable_dependency)[:vsn] |> List.to_string() |> IO.puts()
IO.puts(vulnerable_dependency)
{:incorrect, help_text} when is_binary(help_text) ->
IO.puts([IO.ANSI.red(), "Incorrect: ", IO.ANSI.reset(), help_text])

_ ->
IO.puts([IO.ANSI.red(), "Incorrect.", IO.ANSI.reset()])
end
```

<!-- livebook:{"branch_parent_index":3} -->
Expand Down
1 change: 1 addition & 0 deletions modules/grading_client/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
27 changes: 3 additions & 24 deletions modules/grading_client/lib/grading_client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,8 @@ defmodule GradingClient do
Checks the answer to a question.
"""

@spec check_answer(any() | String.t(), integer(), integer()) :: :ok | {:error, String.t()}
def check_answer(answer, module_id, question_id) when not is_binary(answer) do
check_answer(module_id, question_id, "#{inspect(answer)}")
end

def check_answer(answer, module_id, question_id) do
# TODO: Make this configurable
url = "http://localhost:4000/api/answers/check"

headers = [
{"Content-Type", "application/json"}
]

json = Jason.encode!(%{module_id: module_id, question_id: question_id, answer: answer})

%{body: body, status_code: 200} = HTTPoison.post!(url, json, headers)

%{"correct" => is_correct, "help_text" => help_text} = Jason.decode!(body)

if is_correct do
:ok
else
{:error, help_text}
end
@spec check_answer(any(), any(), any()) :: :correct | {:incorrect, String.t() | nil}
def check_answer(module_id, question_id, answer) do
GradingClient.Answers.check(module_id, question_id, answer)
end
end
11 changes: 11 additions & 0 deletions modules/grading_client/lib/grading_client/answer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule GradingClient.Answer do
@enforce_keys [:question_id, :module_id, :answer, :help_text]
defstruct [:question_id, :module_id, :answer, :help_text]

@type t :: %__MODULE__{
module_id: term(),
question_id: term(),
answer: term(),
help_text: term()
}
end
72 changes: 72 additions & 0 deletions modules/grading_client/lib/grading_client/answers.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule GradingClient.Answers do
@moduledoc """
This module is responsible for checking if an answer is correct.
It uses the `AnswerStore` to fetch the answer and compares if it is correct or not.
"""
use GenServer

alias GradingClient.Answer

@table :answer_store

def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end

@impl true
def init(opts) do
:ets.new(@table, [:set, :named_table])

filename = opts[:filename]

{answers, _} = Code.eval_file(filename)

modules =
MapSet.new(answers, fn answer ->
:ets.insert(@table, {{answer.module_id, answer.question_id}, answer})
answer.module_id
end)

{:ok, %{modules: modules}}
end

@doc """
Checks if the given answer is correct.
"""
@spec check(integer(), integer(), String.t()) :: :correct | {:incorrect, String.t()}
def check(module_id, question_id, answer) do
GenServer.call(__MODULE__, {:check, module_id, question_id, answer})
end

@doc """
Returns the list of modules.
"""
@spec get_modules() :: [atom()]
def get_modules() do
GenServer.call(__MODULE__, :get_modules)
end

@impl true
def handle_call(:get_modules, _from, state) do
{:reply, state.modules, state}
end

@impl true
def handle_call({:check, module_id, question_id, answer}, _from, state) do
result =
case :ets.lookup(@table, {module_id, question_id}) do
[] ->
{:incorrect, "Question not found"}

[{_id, %Answer{answer: correct_answer, help_text: help_text}}] ->

if answer == correct_answer do
:correct
else
{:incorrect, help_text}
end
end

{:reply, result, state}
end
end
17 changes: 17 additions & 0 deletions modules/grading_client/lib/grading_client/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule GradingClient.Application do
use Application

def start(_type, _args) do
Kino.SmartCell.register(GradingClient.GradedCell)

default_filename = Path.join(:code.priv_dir(:grading_client), "answers.exs")

children = [
{GradingClient.Answers,
filename: Application.get_env(:grading_client, :answers_file, default_filename)}
]

opts = [strategy: :one_for_one, name: GradingClient.Supervisor]
Supervisor.start_link(children, opts)
end
end
Loading