Skip to content

Commit 8c65cc3

Browse files
Merge pull request #10189 from kikofernandez/kiko/create-openvex-gh-prs/OTP-19763
Automate creation of OpenVEX statements when new CVEs are created in Erlang/OTP repo OTP-19763
2 parents 493035d + e8952f2 commit 8c65cc3

File tree

8 files changed

+535
-307
lines changed

8 files changed

+535
-307
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env sh
2+
3+
## %CopyrightBegin%
4+
##
5+
## SPDX-License-Identifier: Apache-2.0
6+
##
7+
## Copyright Ericsson AB 2026. All Rights Reserved.
8+
##
9+
## Licensed under the Apache License, Version 2.0 (the "License");
10+
## you may not use this file except in compliance with the License.
11+
## You may obtain a copy of the License at
12+
##
13+
## http://www.apache.org/licenses/LICENSE-2.0
14+
##
15+
## Unless required by applicable law or agreed to in writing, software
16+
## distributed under the License is distributed on an "AS IS" BASIS,
17+
## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
## See the License for the specific language governing permissions and
19+
## limitations under the License.
20+
##
21+
## %CopyrightEnd%
22+
23+
24+
REPO=$1
25+
BRANCH_NAME=$2
26+
# Fetch PR data using gh CLI
27+
PR_STATUS=$(gh pr view "$BRANCH_NAME" --repo "$REPO" --json state -q ".state")
28+
29+
if [ $? -ne 0 ]; then
30+
echo "Failed to fetch PR #$BRANCH_NAME from $REPO"
31+
exit 2
32+
fi
33+
34+
git config user.name "github-actions[bot]"
35+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
36+
37+
# Check if PR is closed
38+
if [ "$PR_STATUS" = "CLOSED" ] || [ "$PR_STATUS" = "MERGED" ]; then
39+
echo "✅ Pull request #$BRANCH_NAME is CLOSED or MERGED."
40+
git branch "$BRANCH_NAME" master
41+
git checkout "$BRANCH_NAME"
42+
git add make/openvex.table
43+
git add vex
44+
git commit -m "Automatic update of OpenVEX Statements for erlang/otp"
45+
git push --force origin "$BRANCH_NAME"
46+
gh pr create --repo "$REPO" -B master \
47+
--title "Automatic update of OpenVEX Statements for erlang/otp" \
48+
--body "Automatic Action. There is a vulnerability from GH Advisories without a matching OpenVEX statement"
49+
exit 0
50+
else
51+
echo "❌ Pull request #$BRANCH_NAME is OPEN. Create a PR once the PR is closed or merged."
52+
exit 0
53+
fi

.github/scripts/otp-compliance.es

Lines changed: 126 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,23 @@
9696
%% VEX MACROS
9797
%%
9898
-define(VexPath, ~"vex/").
99+
-define(OpenVEXTablePath, "make/openvex.table").
99100
-define(ErlangPURL, "pkg:github/erlang/otp").
100101

101102
-define(FOUND_VENDOR_VULNERABILITY_TITLE, "Vendor vulnerability found").
102103
-define(FOUND_VENDOR_VULNERABILITY, lists:append(string:replace(?FOUND_VENDOR_VULNERABILITY_TITLE, " ", "+", all))).
103104

105+
-define(OTP_GH_URI, "https://raw.githubusercontent.com/" ++ ?GH_ACCOUNT ++ "/refs/heads/master/").
106+
104107
%% GH default options
105108
-define(GH_ADVISORIES_OPTIONS, "state=published&direction=desc&per_page=100&sort=updated").
106109

107110
%% Advisories to download from last X years.
108111
-define(GH_ADVISORIES_FROM_LAST_X_YEARS, 5).
109112

113+
%% Defines path of script to create PRs for missing openvex/vulnerabilities
114+
-define(CREATE_OPENVEX_PR_SCRIPT_FILE, ".github/scripts/create-openvex-pr.sh").
115+
110116
%% Sets end point account to fetch information from GH
111117
%% used by `gh` command-line tool.
112118
%% change to your fork for testing, e.g., `kikofernandez/otp`
@@ -260,7 +266,8 @@ cli() ->
260266
"osv-scan" =>
261267
#{ help =>
262268
"""
263-
Performs vulnerability scanning on vendor libraries
269+
Performs vulnerability scanning on vendor libraries.
270+
As a side effect,
264271
265272
Example:
266273
@@ -295,10 +302,15 @@ cli() ->
295302
#{ help =>
296303
"""
297304
Download Github Advisories for erlang/otp.
298-
Checks that those are present in OpenVEX statements.
305+
Download OpenVEX statement from erlang/otp for the selected branch.
306+
Checks that those Advisories are present in OpenVEX statements.
299307
Creates PR for any non-present Github Advisory.
308+
309+
Example:
310+
> .github/scripts/otp-compliance.es vex verify -p
311+
300312
""",
301-
arguments => [branch_option(), vex_path_option()],
313+
arguments => [create_pr()],
302314
handler => fun verify_openvex/1
303315
},
304316

@@ -480,6 +492,13 @@ vex_path_option() ->
480492
help => "Path to folder containing openvex statements, e.g., `vex/`",
481493
long => "-vex-path"}.
482494

495+
create_pr() ->
496+
#{name => create_pr,
497+
short => $p,
498+
type => boolean,
499+
default => false,
500+
help => "Indicates if missing OpenVEX statements create and submit a PR"}.
501+
483502
%%
484503
%% Commands
485504
%%
@@ -1497,7 +1516,7 @@ create_gh_issue(Version, Title, BodyText) ->
14971516
ok.
14981517

14991518
ignore_vex_cves(Branch, Vulns) ->
1500-
OpenVex = get_otp_openvex_file(Branch),
1519+
OpenVex = download_otp_openvex_file(Branch),
15011520
OpenVex1 = format_vex_statements(OpenVex),
15021521

15031522
case OpenVex1 of
@@ -1544,33 +1563,54 @@ format_vex_statements(OpenVex) ->
15441563
Result ++ Acc
15451564
end, [], Stmts).
15461565

1547-
get_otp_openvex_file(Branch) ->
1548-
OpenVexPath = fetch_openvex_filename(Branch),
1566+
read_openvex_file(Branch) ->
1567+
_ = create_dir(?VexPath),
1568+
OpenVexPath = path_to_openvex_filename(Branch),
1569+
OpenVexStr = erlang:binary_to_list(OpenVexPath),
1570+
decode(OpenVexStr).
1571+
1572+
-spec download_otp_openvex_file(Branch :: binary()) -> Json :: map() | EmptyMap :: #{} | no_return().
1573+
download_otp_openvex_file(Branch) ->
1574+
_ = create_dir(?VexPath),
1575+
OpenVexPath = path_to_openvex_filename(Branch),
15491576
OpenVexStr = erlang:binary_to_list(OpenVexPath),
1550-
GithubURI = "https://raw.githubusercontent.com/" ++ ?GH_ACCOUNT ++ "/refs/heads/master/" ++ OpenVexStr,
1577+
GithubURI = get_gh_download_uri(OpenVexStr),
15511578

15521579
io:format("Checking OpenVex statements in '~s' from~n'~s'...~n", [OpenVexPath, GithubURI]),
15531580

15541581
ValidURI = "curl -I -Lj --silent " ++ GithubURI ++ " | head -n1 | cut -d' ' -f2",
15551582
case string:trim(os:cmd(ValidURI)) of
15561583
"200" ->
1584+
%% Overrides existing file.
15571585
io:format("OpenVex file found.~n~n"),
15581586
Command = "curl -LJ " ++ GithubURI ++ " --output " ++ OpenVexStr,
1587+
io:format("Proceed to download:~n~s~n~n", [Command]),
15591588
os:cmd(Command, #{ exception_on_failure => true }),
15601589
decode(OpenVexStr);
15611590
E ->
1562-
io:format("[~p] No OpenVex file found.~n~n", [E]),
1591+
io:format("[~p] No OpenVex statements found for file '~s'.~n~n", [E, OpenVexStr]),
15631592
#{}
15641593
end.
15651594

1566-
fetch_openvex_filename(Branch) ->
1595+
-spec get_gh_download_uri(String :: list()) -> String :: list().
1596+
get_gh_download_uri(File) ->
1597+
?OTP_GH_URI ++ File.
1598+
1599+
-spec create_dir(DirName :: binary()) -> ok | no_return().
1600+
create_dir(DirName) ->
1601+
case file:make_dir(DirName) of
1602+
Result when Result == ok;
1603+
Result == {error, eexist} ->
1604+
io:format("Directory ~s created successfully.~n", [DirName]);
1605+
{error, Reason} ->
1606+
fail("Failed to create directory ~s: ~p~n", [DirName, Reason])
1607+
end.
1608+
1609+
-spec path_to_openvex_filename(Branch :: binary()) -> Path :: binary().
1610+
path_to_openvex_filename(Branch) ->
15671611
_ = valid_scan_branches(Branch),
15681612
Version = maint_to_otp_conversion(Branch),
15691613
vex_path(Version).
1570-
fetch_openvex_filename(Branch, VexPath) ->
1571-
_ = valid_scan_branches(Branch),
1572-
Version = maint_to_otp_conversion(Branch),
1573-
vex_path(VexPath, Version).
15741614

15751615
maint_to_otp_conversion(Branch) ->
15761616
case Branch of
@@ -1588,6 +1628,7 @@ maint_to_otp_conversion(Branch) ->
15881628
OTP
15891629
end.
15901630

1631+
-spec valid_scan_branches(Branch :: binary()) -> ok | no_return().
15911632
valid_scan_branches(Branch) ->
15921633
case Branch of
15931634
~"master" ->
@@ -2474,28 +2515,80 @@ run_openvex1(VexStmts, VexTableFile, Branch, VexPath) ->
24742515
Statements = calculate_statements(VexStmts, VexTableFile, Branch, VexPath),
24752516
lists:foreach(fun (St) -> io:format("~ts", [St]) end, Statements).
24762517

2477-
verify_openvex(#{branch := Branch, vex_path := VexPath}) ->
2478-
UpdatedBranch = maint_to_otp_conversion(Branch),
2479-
OpenVEX = read_openvex(VexPath, UpdatedBranch),
2480-
Advisory = download_advisory_from_branch(UpdatedBranch),
2481-
case verify_advisory_against_openvex(OpenVEX, Advisory) of
2482-
[] ->
2483-
ok;
2484-
MissingAdvisories when is_list(MissingAdvisories) ->
2485-
create_advisory(MissingAdvisories)
2486-
end.
2487-
2488-
read_openvex(VexPath, Branch) ->
2489-
InitVex = fetch_openvex_filename(Branch, VexPath),
2490-
case filelib:is_file(InitVex) of
2491-
true -> % file exists
2492-
decode(InitVex);
2518+
verify_openvex(#{create_pr := PR}) ->
2519+
Branches = get_supported_branches(),
2520+
io:format("Sync ~p~n", [Branches]),
2521+
_ = lists:foreach(
2522+
fun (Branch) ->
2523+
case verify_openvex_advisories(Branch) of
2524+
[] ->
2525+
io:format("No new advisories nor OpenVEX statements created for '~s'.", [Branch]);
2526+
MissingAdvisories ->
2527+
io:format("Missing Advisories:~n~p~n~n", [MissingAdvisories]),
2528+
case PR of
2529+
false ->
2530+
io:format("To automatically update openvex.table and create a PR run:~n" ++
2531+
".github/scripts/otp-compliance.es vex verify -b ~s -p~n~n", [Branch]);
2532+
true ->
2533+
Advs = create_advisory(MissingAdvisories),
2534+
_ = update_openvex_otp_table(Branch, Advs),
2535+
BranchStr = erlang:binary_to_list(Branch),
2536+
_ = cmd(".github/scripts/otp-compliance.es vex run -b "++ BranchStr ++ " | bash")
2537+
end
2538+
end
2539+
end, Branches),
2540+
case PR of
2541+
true ->
2542+
cmd(".github/scripts/create-openvex-pr.sh " ++ ?GH_ACCOUNT ++ " vex");
24932543
false ->
2494-
throw(file_not_found)
2544+
ok
24952545
end.
24962546

2547+
verify_openvex_advisories(Branch) ->
2548+
OpenVEX = read_openvex_file(Branch),
2549+
Advisory = download_advisory_from_branch(Branch),
2550+
verify_advisory_against_openvex(OpenVEX, Advisory).
2551+
2552+
-spec get_supported_branches() -> [Branches :: binary()].
2553+
get_supported_branches() ->
2554+
Branches = cmd(".github/scripts/get-supported-branches.sh"),
2555+
BranchesBin = json:decode(erlang:list_to_binary(Branches)),
2556+
io:format("~p~n~p~n", [Branches, BranchesBin]),
2557+
lists:filtermap(fun (<<"maint-", _/binary>>=OTP) -> {true, maint_to_otp_conversion(OTP)};
2558+
(_) -> false
2559+
end, BranchesBin).
2560+
24972561
create_advisory(Advisories) ->
2498-
io:format("Missing:~n~p~n~n", [Advisories]).
2562+
lists:foldl(fun (Adv, Acc) ->
2563+
create_openvex_otp_entries(Adv) ++ Acc
2564+
end, [], Advisories).
2565+
2566+
create_openvex_otp_entries(#{'CVE' := CVEId,
2567+
'appName' := AppName,
2568+
'affectedVersions' := AffectedVersions,
2569+
'fixedVersions' := FixedVersions}) ->
2570+
AppFixedVersions = lists:map(fun (Ver) -> create_app_purl(AppName, Ver) end, FixedVersions),
2571+
lists:map(fun (Affected) ->
2572+
Purl = create_app_purl(AppName, Affected),
2573+
create_openvex_app_entry(Purl, CVEId, AppFixedVersions)
2574+
end, AffectedVersions).
2575+
2576+
create_app_purl(AppName, Version) when is_binary(AppName), is_binary(Version) ->
2577+
<<"pkg:otp/", AppName/binary, "@", Version/binary>>.
2578+
2579+
create_openvex_app_entry(Purl, CVEId, FixedVersions) ->
2580+
#{Purl => CVEId,
2581+
~"status" =>
2582+
#{ ~"affected" => iolist_to_binary(io_lib:format("Update to any of the following versions: ~s", [FixedVersions])),
2583+
~"fixed" => FixedVersions}}.
2584+
2585+
update_openvex_otp_table(Branch, Advs) ->
2586+
Path = ?OpenVEXTablePath,
2587+
io:format("OpenVEX Statements:~n~p~n~n", [Advs]),
2588+
#{Branch := Statements}=Table = decode(Path),
2589+
UpdatedTable = Table#{Branch := Advs ++ Statements},
2590+
io:format("Update table:~n~p~n", [UpdatedTable]),
2591+
file:write_file(Path, json:format(UpdatedTable)).
24992592

25002593
generate_gh_link(Part) ->
25012594
"\"/repos/erlang/otp/security-advisories?" ++ Part ++ "\"".
@@ -2886,7 +2979,8 @@ format_vexctl(VexPath, Versions, CVE, S) when S =:= ~"fixed";
28862979
[VexPath, Versions, CVE, S]).
28872980

28882981

2889-
-spec fetch_otp_purl_versions(OTP :: binary(), FixedVersions :: [binary()] ) -> OTPAppVersions :: binary().
2982+
-spec fetch_otp_purl_versions(OTP :: binary(), FixedVersions :: [binary()] ) ->
2983+
{AffectedPurls :: binary(), FixedPurls :: binary()} | false.
28902984
fetch_otp_purl_versions(<<?ErlangPURL, _/binary>>, _FixedVersions) ->
28912985
%% ignore
28922986
false;

.github/workflows/openvex-sync.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
## %CopyrightBegin%
2+
##
3+
## SPDX-License-Identifier: Apache-2.0
4+
##
5+
## Copyright Ericsson AB 2024-2025. All Rights Reserved.
6+
##
7+
## Licensed under the Apache License, Version 2.0 (the "License");
8+
## you may not use this file except in compliance with the License.
9+
## You may obtain a copy of the License at
10+
##
11+
## http://www.apache.org/licenses/LICENSE-2.0
12+
##
13+
## Unless required by applicable law or agreed to in writing, software
14+
## distributed under the License is distributed on an "AS IS" BASIS,
15+
## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
## See the License for the specific language governing permissions and
17+
## limitations under the License.
18+
##
19+
## %CopyrightEnd%
20+
21+
## Periodically syncs OpenVEX files against Erlang OTP Securities,
22+
## creating an automatic PR with the missing published securities.
23+
name: OpenVEX Securities Syncing
24+
25+
on:
26+
workflow_dispatch:
27+
schedule:
28+
- cron: 0 1 * * *
29+
30+
permissions:
31+
contents: read
32+
33+
jobs:
34+
run-scheduled-openvex-sync:
35+
runs-on: ubuntu-latest
36+
permissions:
37+
security-events: read
38+
actions: write
39+
contents: write
40+
pull-requests: write
41+
steps:
42+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/[email protected]
43+
with:
44+
ref: 'master' # '' = default branch
45+
46+
- uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # racket:actions/checkout@v1
47+
with:
48+
otp-version: '28'
49+
50+
- uses: openvex/setup-vexctl@e85ca48f3c8a376289f6476129d59cda82147e71 # ratchet:openvex/[email protected]
51+
with:
52+
vexctl-release: '0.3.0'
53+
54+
- name: 'Open OpenVEX Pull Requests for newly released vulnerabilities'
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
REPO: ${{ github.repository }}
58+
run: |
59+
.github/scripts/otp-compliance.es vex verify -p

.github/workflows/osv-scanner-scheduled.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ jobs:
5757
type: ${{ fromJson(needs.schedule-scan.outputs.versions) }}
5858
fail-fast: false
5959
permissions:
60-
actions: write
60+
security-events: read
6161
issues: write
62+
actions: write
63+
contents: write
64+
pull-requests: write
6265
steps:
6366
# this call to a workflow_dispatch ref=master is important because
6467
# using ref={{matrix.type}} would trigger the workflow

.github/workflows/reusable-vendor-vulnerability-scanner.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,6 @@ jobs:
115115
chmod +x otp-compliance.es
116116
cp otp-compliance.es /home/runner/work/otp/otp/.github/scripts/otp-compliance.es
117117
cd /home/runner/work/otp/otp && \
118-
mkdir -p vex && \
119118
.github/scripts/otp-compliance.es sbom osv-scan \
120119
--version ${{ inputs.version }} \
121120
--fail_if_cve ${{ inputs.fail_if_cve }}
122-
.github/scripts/otp-compliance.es vex verify -b ${{ inputs.version }}

0 commit comments

Comments
 (0)