Packages
macula
0.22.9
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_registry_system/macula_security_scanner.erl
%%%-------------------------------------------------------------------
%%% @doc Macula Security Scanner
%%%
%%% Static analysis engine for scanning BEAM files:
%%% - Detects dangerous BIF usage (os:cmd, open_port, etc.)
%%% - Identifies undeclared capabilities
%%% - Detects NIF loading attempts
%%% - Calculates security score
%%%
%%% All functions are stateless.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_security_scanner).
%% API
-export([scan_beam_archive/1, scan_beam_files/1]).
-export([scan_manifest/1]).
-export([get_dangerous_bifs/0]).
-export([calculate_score/1]).
%% Types
-export_type([scan_result/0, warning/0]).
-type scan_result() :: #{
dangerous_bifs := [#{
module := atom(),
function := atom(),
arity := non_neg_integer(),
locations := [term()]
}],
undeclared_capabilities := [term()],
nif_usage := [#{
module := atom(),
nif_lib := binary()
}],
warnings := [warning()],
score := 0..100
}.
-type warning() :: #{
severity := low | medium | high | critical,
type := atom(),
message := binary(),
location => term()
}.
%% Dangerous BIFs that should be flagged
-define(DANGEROUS_BIFS, [
{os, cmd, 1},
{os, cmd, 2},
{erlang, open_port, 2},
{erlang, load_nif, 2},
{code, load_binary, 3},
{code, purge, 1},
{code, delete, 1},
{file, write_file, 2},
{file, write_file, 3},
{file, delete, 1},
{file, del_dir, 1},
{net_kernel, connect_node, 1},
{net_kernel, disconnect, 1},
{erlang, halt, 0},
{erlang, halt, 1},
{erlang, halt, 2},
{init, stop, 0},
{init, stop, 1}
]).
%%%===================================================================
%%% API functions
%%%===================================================================
%% @doc Scan a gzipped BEAM archive
-spec scan_beam_archive(Archive :: binary()) -> {ok, scan_result()} | {error, term()}.
scan_beam_archive(Archive) when is_binary(Archive) ->
%% Try to decompress if gzipped
BeamData = try
zlib:gunzip(Archive)
catch
_:_ -> Archive % Already decompressed or not gzipped
end,
%% Extract BEAM files from tar or treat as single file
case extract_beam_files(BeamData) of
{ok, BeamFiles} ->
scan_beam_files(BeamFiles);
{error, Reason} ->
{error, {extraction_failed, Reason}}
end.
%% @doc Scan a list of {ModuleName, BeamBinary} tuples
-spec scan_beam_files([{atom(), binary()}]) -> {ok, scan_result()} | {error, term()}.
scan_beam_files(BeamFiles) when is_list(BeamFiles) ->
%% Scan each BEAM file
Results = lists:map(fun({ModName, BeamBin}) ->
scan_single_beam(ModName, BeamBin)
end, BeamFiles),
%% Merge results
MergedResult = merge_scan_results(Results),
%% Calculate score
Score = calculate_score(MergedResult),
{ok, MergedResult#{score => Score}}.
%% @doc Scan a manifest for security issues
-spec scan_manifest(Manifest :: map()) -> {ok, [warning()]} | {error, term()}.
scan_manifest(Manifest) when is_map(Manifest) ->
Warnings = lists:flatten([
check_capability_patterns(Manifest),
check_nif_declarations(Manifest),
check_network_access(Manifest)
]),
{ok, Warnings}.
%% @doc Get list of dangerous BIFs
-spec get_dangerous_bifs() -> [{atom(), atom(), non_neg_integer()}].
get_dangerous_bifs() ->
?DANGEROUS_BIFS.
%% @doc Calculate security score based on scan results
-spec calculate_score(scan_result()) -> 0..100.
calculate_score(Result) ->
DangerousBifs = maps:get(dangerous_bifs, Result, []),
NifUsage = maps:get(nif_usage, Result, []),
Warnings = maps:get(warnings, Result, []),
%% Start with 100 and deduct points
Score0 = 100,
%% Deduct for dangerous BIFs
BifPenalty = length(DangerousBifs) * 15,
%% Deduct for NIF usage
NifPenalty = length(NifUsage) * 20,
%% Deduct for warnings by severity
WarningPenalty = lists:sum(lists:map(fun(W) ->
case maps:get(severity, W, low) of
critical -> 25;
high -> 15;
medium -> 10;
low -> 5
end
end, Warnings)),
Score = Score0 - BifPenalty - NifPenalty - WarningPenalty,
max(0, min(100, Score)).
%%%===================================================================
%%% Internal functions
%%%===================================================================
%% @private Extract BEAM files from archive
extract_beam_files(Data) when is_binary(Data) ->
%% Try to parse as tar
case erl_tar:extract({binary, Data}, [memory, compressed]) of
{ok, FileList} ->
BeamFiles = lists:filtermap(fun({FileName, Content}) ->
case filename:extension(FileName) of
".beam" ->
ModName = list_to_atom(filename:basename(FileName, ".beam")),
{true, {ModName, Content}};
_ ->
false
end
end, FileList),
{ok, BeamFiles};
{error, _} ->
%% Try as single BEAM file
case is_beam_file(Data) of
true ->
{ok, [{unknown_module, Data}]};
false ->
{error, not_a_beam_file}
end
end.
%% @private Check if binary is a BEAM file (starts with "FOR1")
is_beam_file(<<"FOR1", _/binary>>) -> true;
is_beam_file(_) -> false.
%% @private Scan a single BEAM file
scan_single_beam(ModName, BeamBin) ->
DangerousCalls = find_dangerous_calls(ModName, BeamBin),
NifUsage = find_nif_usage(ModName, BeamBin),
Warnings = generate_warnings(ModName, DangerousCalls, NifUsage),
#{
dangerous_bifs => DangerousCalls,
nif_usage => NifUsage,
warnings => Warnings,
undeclared_capabilities => []
}.
%% @private Find dangerous function calls in BEAM
find_dangerous_calls(ModName, BeamBin) ->
case get_abstract_code(BeamBin) of
{ok, AbstractCode} ->
find_calls_in_forms(ModName, AbstractCode, ?DANGEROUS_BIFS);
error ->
[]
end.
%% @private Get abstract code from BEAM
get_abstract_code(BeamBin) ->
case beam_lib:chunks(BeamBin, [abstract_code]) of
{ok, {_, [{abstract_code, {raw_abstract_v1, Forms}}]}} ->
{ok, Forms};
{ok, {_, [{abstract_code, no_abstract_code}]}} ->
%% No debug info - try to decompile
error;
_ ->
error
end.
%% @private Find calls to dangerous functions in AST
find_calls_in_forms(ModName, Forms, DangerousList) ->
lists:flatten(lists:map(fun(Form) ->
find_calls_in_form(ModName, Form, DangerousList)
end, Forms)).
find_calls_in_form(ModName, {function, Line, FunName, Arity, Clauses}, DangerousList) ->
lists:flatten(lists:map(fun(Clause) ->
find_calls_in_clause(ModName, FunName, Arity, Line, Clause, DangerousList)
end, Clauses));
find_calls_in_form(_, _, _) ->
[].
find_calls_in_clause(ModName, FunName, FunArity, _Line, {clause, _, _Args, _Guards, Body}, DangerousList) ->
find_calls_in_exprs(ModName, FunName, FunArity, Body, DangerousList);
find_calls_in_clause(_, _, _, _, _, _) ->
[].
find_calls_in_exprs(ModName, FunName, FunArity, Exprs, DangerousList) ->
lists:flatten(lists:map(fun(Expr) ->
find_calls_in_expr(ModName, FunName, FunArity, Expr, DangerousList)
end, Exprs)).
find_calls_in_expr(ModName, FunName, FunArity, {call, Line, {remote, _, {atom, _, M}, {atom, _, F}}, Args}, DangerousList) ->
Arity = length(Args),
case lists:member({M, F, Arity}, DangerousList) of
true ->
[#{
module => M,
function => F,
arity => Arity,
locations => [{ModName, FunName, FunArity, Line}]
}];
false ->
%% Recurse into arguments
find_calls_in_exprs(ModName, FunName, FunArity, Args, DangerousList)
end;
find_calls_in_expr(ModName, FunName, FunArity, {call, _, FunExpr, Args}, DangerousList) ->
%% Check function expression and arguments
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, FunExpr, DangerousList),
find_calls_in_exprs(ModName, FunName, FunArity, Args, DangerousList)
]);
find_calls_in_expr(ModName, FunName, FunArity, {'case', _, Expr, Clauses}, DangerousList) ->
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, Expr, DangerousList) |
[find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- Clauses]
]);
find_calls_in_expr(ModName, FunName, FunArity, {'if', _, Clauses}, DangerousList) ->
lists:flatten([find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- Clauses]);
find_calls_in_expr(ModName, FunName, FunArity, {'receive', _, Clauses}, DangerousList) ->
lists:flatten([find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- Clauses]);
find_calls_in_expr(ModName, FunName, FunArity, {'try', _, Body, CaseClauses, CatchClauses, After}, DangerousList) ->
lists:flatten([
find_calls_in_exprs(ModName, FunName, FunArity, Body, DangerousList),
[find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- CaseClauses],
[find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- CatchClauses],
find_calls_in_exprs(ModName, FunName, FunArity, After, DangerousList)
]);
find_calls_in_expr(ModName, FunName, FunArity, {block, _, Exprs}, DangerousList) ->
find_calls_in_exprs(ModName, FunName, FunArity, Exprs, DangerousList);
find_calls_in_expr(ModName, FunName, FunArity, {'fun', _, {clauses, Clauses}}, DangerousList) ->
lists:flatten([find_calls_in_clause(ModName, FunName, FunArity, 0, C, DangerousList) || C <- Clauses]);
find_calls_in_expr(ModName, FunName, FunArity, {lc, _, Expr, Qualifiers}, DangerousList) ->
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, Expr, DangerousList) |
[find_calls_in_expr(ModName, FunName, FunArity, Q, DangerousList) || Q <- Qualifiers]
]);
find_calls_in_expr(ModName, FunName, FunArity, {generate, _, Pattern, Expr}, DangerousList) ->
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, Pattern, DangerousList),
find_calls_in_expr(ModName, FunName, FunArity, Expr, DangerousList)
]);
find_calls_in_expr(ModName, FunName, FunArity, {match, _, Left, Right}, DangerousList) ->
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, Left, DangerousList),
find_calls_in_expr(ModName, FunName, FunArity, Right, DangerousList)
]);
find_calls_in_expr(ModName, FunName, FunArity, {tuple, _, Elements}, DangerousList) ->
find_calls_in_exprs(ModName, FunName, FunArity, Elements, DangerousList);
find_calls_in_expr(ModName, FunName, FunArity, {cons, _, Head, Tail}, DangerousList) ->
lists:flatten([
find_calls_in_expr(ModName, FunName, FunArity, Head, DangerousList),
find_calls_in_expr(ModName, FunName, FunArity, Tail, DangerousList)
]);
find_calls_in_expr(ModName, FunName, FunArity, {map, _, Assocs}, DangerousList) ->
find_calls_in_exprs(ModName, FunName, FunArity, [V || {_, _, _, V} <- Assocs], DangerousList);
find_calls_in_expr(_, _, _, _, _) ->
[].
%% @private Find NIF usage
find_nif_usage(ModName, BeamBin) ->
case get_abstract_code(BeamBin) of
{ok, Forms} ->
find_nif_loads(ModName, Forms);
error ->
[]
end.
find_nif_loads(ModName, Forms) ->
lists:flatten(lists:map(fun(Form) ->
case Form of
{function, _, _, _, Clauses} ->
lists:flatten([find_nif_in_clause(ModName, C) || C <- Clauses]);
_ ->
[]
end
end, Forms)).
find_nif_in_clause(ModName, {clause, _, _, _, Body}) ->
lists:flatten(lists:map(fun(Expr) ->
find_nif_in_expr(ModName, Expr)
end, Body));
find_nif_in_clause(_, _) ->
[].
find_nif_in_expr(ModName, {call, _, {remote, _, {atom, _, erlang}, {atom, _, load_nif}}, Args}) ->
case Args of
[{string, _, LibPath}, _] ->
[#{module => ModName, nif_lib => list_to_binary(LibPath)}];
_ ->
[#{module => ModName, nif_lib => <<"unknown">>}]
end;
find_nif_in_expr(_, _) ->
[].
%% @private Generate warnings from scan results
generate_warnings(ModName, DangerousCalls, NifUsage) ->
BifWarnings = lists:map(fun(#{module := M, function := F, arity := A}) ->
#{
severity => classify_bif_severity(M, F, A),
type => dangerous_bif,
message => iolist_to_binary(io_lib:format(
"Dangerous function call: ~p:~p/~p", [M, F, A])),
location => ModName
}
end, DangerousCalls),
NifWarnings = lists:map(fun(#{nif_lib := Lib}) ->
#{
severity => high,
type => nif_usage,
message => iolist_to_binary(io_lib:format(
"NIF library loaded: ~s", [Lib])),
location => ModName
}
end, NifUsage),
BifWarnings ++ NifWarnings.
%% @private Classify severity of dangerous BIF
classify_bif_severity(os, cmd, _) -> critical;
classify_bif_severity(erlang, open_port, 2) -> critical;
classify_bif_severity(erlang, load_nif, 2) -> high;
classify_bif_severity(code, load_binary, 3) -> high;
classify_bif_severity(erlang, halt, _) -> critical;
classify_bif_severity(init, stop, _) -> critical;
classify_bif_severity(file, write_file, _) -> medium;
classify_bif_severity(file, delete, _) -> medium;
classify_bif_severity(net_kernel, _, _) -> high;
classify_bif_severity(_, _, _) -> low.
%% @private Merge multiple scan results
merge_scan_results(Results) ->
lists:foldl(fun(R, Acc) ->
#{
dangerous_bifs => maps:get(dangerous_bifs, Acc, []) ++ maps:get(dangerous_bifs, R, []),
nif_usage => maps:get(nif_usage, Acc, []) ++ maps:get(nif_usage, R, []),
warnings => maps:get(warnings, Acc, []) ++ maps:get(warnings, R, []),
undeclared_capabilities => maps:get(undeclared_capabilities, Acc, []) ++ maps:get(undeclared_capabilities, R, [])
}
end, #{dangerous_bifs => [], nif_usage => [], warnings => [], undeclared_capabilities => []}, Results).
%% @private Check capability patterns in manifest
check_capability_patterns(Manifest) ->
Capabilities = maps:get(capabilities, Manifest, []),
lists:filtermap(fun(Cap) ->
case check_capability(Cap) of
ok -> false;
{warning, W} -> {true, W}
end
end, Capabilities).
check_capability({network, Specs}) ->
case lists:any(fun({connect, <<"**">>}) -> true; (_) -> false end, Specs) of
true ->
{warning, #{
severity => high,
type => broad_network_access,
message => <<"Package requests unrestricted network access">>
}};
false ->
ok
end;
check_capability({file_access, Specs}) ->
case lists:any(fun({write, <<"/">>}) -> true;
({write, <<"/**">>}) -> true;
(_) -> false end, Specs) of
true ->
{warning, #{
severity => critical,
type => root_file_access,
message => <<"Package requests root file system write access">>
}};
false ->
ok
end;
check_capability(_) ->
ok.
%% @private Check NIF declarations
check_nif_declarations(Manifest) ->
case maps:get(capabilities, Manifest, []) of
Caps ->
case lists:keyfind(nifs, 1, Caps) of
{nifs, NifList} when length(NifList) > 0 ->
[#{
severity => medium,
type => nif_declared,
message => iolist_to_binary(io_lib:format(
"Package declares NIF usage: ~p", [NifList]))
}];
_ ->
[]
end
end.
%% @private Check network access patterns
check_network_access(Manifest) ->
case maps:get(capabilities, Manifest, []) of
Caps ->
case lists:keyfind(network, 1, Caps) of
{network, Specs} ->
lists:filtermap(fun({connect, Pattern}) ->
case binary:match(Pattern, <<"*">>) of
nomatch -> false;
_ -> {true, #{
severity => low,
type => wildcard_network,
message => iolist_to_binary(io_lib:format(
"Wildcard network pattern: ~s", [Pattern]))
}}
end
end, Specs);
_ ->
[]
end
end.