Skip to content

Commit 3ea8f7b

Browse files
hyperpolymathclaude
andcommitted
feat: Ecto persistence, miniKanren scanner integration, JSON parsing
Backend: - Ecto + PostgreSQL: Repo, schemas (Stack, User, UserSettings), 3 migrations - Repo startup is conditional — app works without PostgreSQL - SecurityScanner now calls miniKanren Engine for enriched port analysis - Kanren findings merged with existing vulns (deduped), severity_distribution included in response as kanrenAnalysis field Frontend: - Update.res: real JSON parsing for SecurityScanResult and GapAnalysisResult - ~280 lines of type-safe parsers for all SecurityInspector and GapAnalysis types - Falls back to init state only if top-level JSON structure is invalid Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bcbaf4f commit 3ea8f7b

16 files changed

Lines changed: 1549 additions & 46 deletions

backend/config/config.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,16 @@
88
import Config
99

1010
config :stapeln,
11+
ecto_repos: [Stapeln.Repo],
1112
generators: [timestamp_type: :utc_datetime]
1213

14+
config :stapeln, Stapeln.Repo,
15+
database: "stapeln_dev",
16+
username: "postgres",
17+
password: "postgres",
18+
hostname: "localhost",
19+
port: 5432
20+
1321
config :stapeln, :api_auth,
1422
enabled: true,
1523
token: nil

backend/config/dev.exs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import Config
22

3+
# Configure the Repo for development
4+
config :stapeln, Stapeln.Repo,
5+
database: "stapeln_dev",
6+
username: "postgres",
7+
password: "postgres",
8+
hostname: "localhost",
9+
port: 5432,
10+
stacktrace: true,
11+
show_sensitive_data_on_connection_error: true,
12+
pool_size: 10
13+
314
# For development, we disable any cache and enable
415
# debugging and code reloading.
516
#

backend/config/test.exs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import Config
22

3+
# Configure the Repo for test — use a separate database and sandbox pool
4+
config :stapeln, Stapeln.Repo,
5+
database: "stapeln_test#{System.get_env("MIX_TEST_PARTITION")}",
6+
username: "postgres",
7+
password: "postgres",
8+
hostname: "localhost",
9+
port: 5432,
10+
pool: Ecto.Adapters.SQL.Sandbox,
11+
pool_size: System.schedulers_online() * 2
12+
313
# We don't run a server during test. If one is required,
414
# you can enable the server option below.
515
config :stapeln, StapelnWeb.Endpoint,

backend/lib/stapeln/application.ex

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,32 @@ defmodule Stapeln.Application do
77

88
@impl true
99
def start(_type, _args) do
10-
children = [
11-
StapelnWeb.Telemetry,
12-
{DNSCluster, query: Application.get_env(:stapeln, :dns_cluster_query) || :ignore},
13-
{Phoenix.PubSub, name: Stapeln.PubSub},
14-
Stapeln.StackStore,
15-
Stapeln.Auth.UserStore,
16-
Stapeln.SettingsStore,
17-
{Task.Supervisor, name: Stapeln.TaskSupervisor},
18-
# Start a worker by calling: Stapeln.Worker.start_link(arg)
19-
# {Stapeln.Worker, arg},
20-
# Start to serve requests, typically the last entry
21-
StapelnWeb.Endpoint
22-
]
10+
# Optionally start the Ecto Repo when ecto_sql is available and
11+
# PostgreSQL is configured. Falls back to GenServer stores otherwise.
12+
repo_children =
13+
if Code.ensure_loaded?(Stapeln.Repo) do
14+
[Stapeln.Repo]
15+
else
16+
[]
17+
end
18+
19+
children =
20+
[
21+
StapelnWeb.Telemetry,
22+
{DNSCluster, query: Application.get_env(:stapeln, :dns_cluster_query) || :ignore},
23+
{Phoenix.PubSub, name: Stapeln.PubSub}
24+
] ++
25+
repo_children ++
26+
[
27+
Stapeln.StackStore,
28+
Stapeln.Auth.UserStore,
29+
Stapeln.SettingsStore,
30+
{Task.Supervisor, name: Stapeln.TaskSupervisor},
31+
# Start a worker by calling: Stapeln.Worker.start_link(arg)
32+
# {Stapeln.Worker, arg},
33+
# Start to serve requests, typically the last entry
34+
StapelnWeb.Endpoint
35+
]
2336

2437
# See https://hexdocs.pm/elixir/Supervisor.html
2538
# for other strategies and supported options

backend/lib/stapeln/repo.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# repo.ex - Ecto repository for stapeln PostgreSQL persistence
3+
4+
defmodule Stapeln.Repo do
5+
@moduledoc """
6+
Ecto repository for PostgreSQL persistence.
7+
8+
This is an optional persistence layer — the GenServer-based stores
9+
(StackStore, UserStore, SettingsStore) remain as fallbacks when
10+
PostgreSQL is unavailable.
11+
"""
12+
13+
use Ecto.Repo, otp_app: :stapeln, adapter: Ecto.Adapters.Postgres
14+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# stack.ex - Ecto schema for container stacks
3+
4+
defmodule Stapeln.Schemas.Stack do
5+
@moduledoc """
6+
Ecto schema for container stack definitions.
7+
8+
Mirrors the shape used by `Stapeln.StackStore` so that data can move
9+
between the in-memory GenServer fallback and PostgreSQL transparently.
10+
"""
11+
12+
use Ecto.Schema
13+
import Ecto.Changeset
14+
15+
schema "stacks" do
16+
field :name, :string
17+
field :description, :string
18+
field :services, {:array, :map}, default: []
19+
20+
timestamps(type: :utc_datetime)
21+
end
22+
23+
@required_fields ~w(name)a
24+
@optional_fields ~w(description services)a
25+
26+
@doc "Build a changeset for creating or updating a stack."
27+
@spec changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
28+
def changeset(stack, attrs) do
29+
stack
30+
|> cast(attrs, @required_fields ++ @optional_fields)
31+
|> validate_required(@required_fields)
32+
|> validate_length(:name, min: 1, max: 255)
33+
end
34+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# user.ex - Ecto schema for authenticated users
3+
4+
defmodule Stapeln.Schemas.User do
5+
@moduledoc """
6+
Ecto schema for user accounts.
7+
8+
Mirrors the shape used by `Stapeln.Auth.UserStore` so that data can
9+
move between the in-memory GenServer fallback and PostgreSQL.
10+
"""
11+
12+
use Ecto.Schema
13+
import Ecto.Changeset
14+
15+
schema "users" do
16+
field :email, :string
17+
field :password_hash, :string
18+
19+
has_many :user_settings, Stapeln.Schemas.UserSettings
20+
21+
timestamps(type: :utc_datetime)
22+
end
23+
24+
@required_fields ~w(email password_hash)a
25+
26+
@doc "Build a changeset for creating or updating a user."
27+
@spec changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
28+
def changeset(user, attrs) do
29+
user
30+
|> cast(attrs, @required_fields)
31+
|> validate_required(@required_fields)
32+
|> validate_format(:email, ~r/@/)
33+
|> unique_constraint(:email)
34+
end
35+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# user_settings.ex - Ecto schema for per-user settings
3+
4+
defmodule Stapeln.Schemas.UserSettings do
5+
@moduledoc """
6+
Ecto schema for per-user settings.
7+
8+
Settings are stored as a JSON map, mirroring the shape used by
9+
`Stapeln.SettingsStore`.
10+
"""
11+
12+
use Ecto.Schema
13+
import Ecto.Changeset
14+
15+
schema "user_settings" do
16+
field :settings, :map, default: %{}
17+
18+
belongs_to :user, Stapeln.Schemas.User
19+
20+
timestamps(type: :utc_datetime)
21+
end
22+
23+
@required_fields ~w(user_id)a
24+
@optional_fields ~w(settings)a
25+
26+
@doc "Build a changeset for creating or updating user settings."
27+
@spec changeset(%__MODULE__{} | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
28+
def changeset(user_settings, attrs) do
29+
user_settings
30+
|> cast(attrs, @required_fields ++ @optional_fields)
31+
|> validate_required(@required_fields)
32+
|> foreign_key_constraint(:user_id)
33+
end
34+
end

backend/lib/stapeln/security_scanner.ex

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Stapeln.SecurityScanner do
1111
"""
1212

1313
alias Stapeln.ValidationEngine
14+
alias Stapeln.Kanren.Engine, as: KanrenEngine
1415

1516
@known_db_kinds ~w(db database postgres postgresql mysql mariadb mongo mongodb redis memcached cassandra elasticsearch)
1617
@sensitive_ports [3306, 5432, 27017, 6379, 9200, 11211, 9042]
@@ -34,15 +35,22 @@ defmodule Stapeln.SecurityScanner do
3435
vulnerabilities = detect_vulnerabilities(services)
3536
checks = run_security_checks(services)
3637
exposed_ports = detect_exposed_ports(services)
37-
metrics = calculate_metrics(vulnerabilities, checks, validation)
38+
39+
# miniKanren reasoning engine — additional port-level findings
40+
kanren_result = KanrenEngine.reason(stack)
41+
kanren_vulns = convert_kanren_findings(kanren_result.port_findings)
42+
merged_vulnerabilities = merge_vulnerabilities(vulnerabilities, kanren_vulns)
43+
44+
metrics = calculate_metrics(merged_vulnerabilities, checks, validation)
3845
grade = calculate_grade(metrics)
3946

4047
%{
4148
metrics: metrics,
4249
grade: grade,
43-
vulnerabilities: vulnerabilities,
50+
vulnerabilities: merged_vulnerabilities,
4451
checks: checks,
45-
exposedPorts: exposed_ports
52+
exposedPorts: exposed_ports,
53+
kanrenAnalysis: kanren_result.severity_distribution
4654
}
4755
end
4856

@@ -460,6 +468,59 @@ defmodule Stapeln.SecurityScanner do
460468
end
461469
end
462470

471+
# ---------------------------------------------------------------------------
472+
# miniKanren Integration
473+
# ---------------------------------------------------------------------------
474+
475+
defp convert_kanren_findings(port_findings) do
476+
Enum.map(port_findings, fn finding ->
477+
%{
478+
title: finding.message,
479+
severity: Atom.to_string(finding.severity),
480+
description: "miniKanren reasoning engine flagged port #{finding.port}: #{finding.message}",
481+
affectedComponent: "port:#{finding.port}",
482+
cveId: nil,
483+
fixAvailable: false,
484+
fixDescription: nil,
485+
source: :kanren
486+
}
487+
end)
488+
end
489+
490+
defp merge_vulnerabilities(existing, kanren_vulns) do
491+
new_kanren =
492+
Enum.reject(kanren_vulns, fn kv ->
493+
Enum.any?(existing, fn ev ->
494+
kanren_port = kv.affectedComponent
495+
existing_port = ev.affectedComponent
496+
497+
# Deduplicate when same port and same or higher severity already reported
498+
kanren_port == existing_port and
499+
severity_rank(ev.severity) >= severity_rank(kv.severity)
500+
end)
501+
end)
502+
503+
merged = existing ++ new_kanren
504+
505+
# Re-index all vulnerability IDs
506+
merged
507+
|> Enum.with_index(1)
508+
|> Enum.map(fn {vuln, idx} ->
509+
Map.put(vuln, :id, "VULN-#{String.pad_leading(Integer.to_string(idx), 4, "0")}")
510+
end)
511+
end
512+
513+
defp severity_rank(severity) do
514+
case severity do
515+
"critical" -> 5
516+
"high" -> 4
517+
"medium" -> 3
518+
"low" -> 2
519+
"info" -> 1
520+
_ -> 0
521+
end
522+
end
523+
463524
# ---------------------------------------------------------------------------
464525
# Helpers
465526
# ---------------------------------------------------------------------------

backend/mix.exs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ defmodule Stapeln.MixProject do
5050
{:jason, "~> 1.2"},
5151
{:dns_cluster, "~> 0.2.0"},
5252
{:bandit, "~> 1.5"},
53-
{:req, "~> 0.5"}
53+
{:req, "~> 0.5"},
54+
{:ecto_sql, "~> 3.12"},
55+
{:postgrex, "~> 0.19"}
5456
]
5557
end
5658

@@ -62,7 +64,9 @@ defmodule Stapeln.MixProject do
6264
# See the documentation for `Mix` for more info on aliases.
6365
defp aliases do
6466
[
65-
setup: ["deps.get"],
67+
setup: ["deps.get", "ecto.setup"],
68+
"ecto.setup": ["ecto.create", "ecto.migrate"],
69+
"ecto.reset": ["ecto.drop", "ecto.setup"],
6670
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
6771
]
6872
end

0 commit comments

Comments
 (0)