diff --git a/.github/workflows/vex-automation.yml b/.github/workflows/vex-automation.yml new file mode 100644 index 0000000..0fed06c --- /dev/null +++ b/.github/workflows/vex-automation.yml @@ -0,0 +1,56 @@ +name: VEX Entry Automation + +on: + issues: + types: [closed] + +permissions: + contents: read + +jobs: + create-vex-entry: + runs-on: ubuntu-latest + if: | + contains(github.event.issue.labels.*.name, 'vulnerable_code_not_in_execute_path') || + contains(github.event.issue.labels.*.name, 'vulnerable_code_not_present') || + contains(github.event.issue.labels.*.name, 'vulnerable_code_cannot_be_controlled_by_adversary') || + contains(github.event.issue.labels.*.name, 'inline_mitigations_already_exist') + steps: + - name: Checkout current repository + uses: actions/checkout@v5 + + - name: Checkout security-wg repo + uses: actions/checkout@v5 + with: + repository: nodejs/security-wg + token: ${{ secrets.SECURITY_WG_TOKEN }} + path: security-wg + + - name: Setup Python 3.9 + uses: actions/setup-python@v6 + with: + python-version: '3.9' + + - name: Generate VEX entry + working-directory: ./scripts + run: | + python generate_vex_entry.py \ + --issue-number "${{ github.event.issue.number }}" \ + --issue-title "${{ github.event.issue.title }}" \ + --issue-url "${{ github.event.issue.html_url }}" \ + --labels "${{ join(github.event.issue.labels.*.name, ',') }}" \ + --output-dir "../security-wg/vuln/deps" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + token: ${{ secrets.SECURITY_WG_TOKEN }} + path: security-wg + branch: vex-issue-${{ github.event.issue.number }} + commit-message: "vuln: add VEX entry for issue #${{ github.event.issue.number }}" + title: "vuln: add VEX entry for nodejs/nodejs-dependency-vuln-assessments#${{ github.event.issue.number }}" + body: | + Automated VEX entry from issue closure. + + **Source:** ${{ github.event.issue.html_url }} + **Closed by:** @${{ github.event.sender.login }} diff --git a/README.md b/README.md index 3fe6e12..0be0d91 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ This repo is used to Automated checks are currently run through a GitHub action using [dep_checker](https://github.com/nodejs/nodejs-dependency-vuln-assessments/tree/main/dep_checker). +When issues are closed with specific labels (e.g., `vulnerable_code_not_in_execute_path`), +a VEX entry is automatically generated via +[vex-automation](https://github.com/nodejs/nodejs-dependency-vuln-assessments/tree/main/.github/workflows/vex-automation.yml). + **DO NOT REPORT OR DISCUSS VULNERABILITIES THAT ARE NOT ALREADY PUBLIC IN THIS REPO**. Please report new vulnerabilities either to the projects for a specific dependency or report to the Node.js project diff --git a/scripts/generate_vex_entry.py b/scripts/generate_vex_entry.py new file mode 100644 index 0000000..c701c0b --- /dev/null +++ b/scripts/generate_vex_entry.py @@ -0,0 +1,121 @@ +"""Generate VEX entry JSON file from closed issue data. + +This script creates a VEX (Vulnerability Exploitability eXchange) entry +when an issue is closed with a specific label indicating the vulnerability +assessment result. +""" + +import argparse +import json +import os +import re +import sys + + +# Maps issue labels to VEX justification values +JUSTIFICATION_MAP = { + "vulnerable_code_not_in_execute_path": "vulnerable_code_not_in_execute_path", + "vulnerable_code_not_present": "vulnerable_code_not_present", + "vulnerable_code_cannot_be_controlled_by_adversary": "vulnerable_code_cannot_be_controlled_by_adversary", + "inline_mitigations_already_exist": "inline_mitigations_already_exist", +} + + +def get_next_file_number(vuln_deps_path: str) -> int: + """Find the next available VEX file number.""" + if not os.path.exists(vuln_deps_path): + return 1 + + existing = [f for f in os.listdir(vuln_deps_path) if f.endswith(".json")] + numbers = [] + for f in existing: + name = f.replace(".json", "") + if name.isdigit(): + numbers.append(int(name)) + + return max(numbers) + 1 if numbers else 1 + + +def parse_cve_from_title(title: str) -> str | None: + """Extract CVE ID from issue title.""" + match = re.search(r"CVE-\d{4}-\d+", title, re.IGNORECASE) + return match.group(0).upper() if match else None + + +def get_justification(labels: str) -> str | None: + """Get VEX justification from issue labels.""" + for label in labels.split(","): + label = label.strip() + if label in JUSTIFICATION_MAP: + return JUSTIFICATION_MAP[label] + return None + + +def create_vex_entry( + issue_number: str, + issue_title: str, + issue_url: str, + justification: str, + cve_id: str, +) -> dict: + """Create VEX entry dictionary.""" + return { + "cve": cve_id, + "ref": issue_url, + "vulnerable": "n/a", + "patched": "n/a", + "vex": { + "status": "not_affected", + "justification": justification, + }, + } + + +def main(): + parser = argparse.ArgumentParser(description="Generate VEX entry from issue") + parser.add_argument("--issue-number", required=True) + parser.add_argument("--issue-title", required=True) + parser.add_argument("--issue-url", required=True) + parser.add_argument("--labels", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args() + + # Extract CVE from title + cve_id = parse_cve_from_title(args.issue_title) + if not cve_id: + print(f"Error: No CVE found in title: {args.issue_title}") + sys.exit(1) + + # Get justification from labels + justification = get_justification(args.labels) + if not justification: + print(f"Error: No valid justification label found in: {args.labels}") + sys.exit(1) + + # Verify output directory exists + if not os.path.exists(args.output_dir): + print(f"Error: Output directory not found: {args.output_dir}") + sys.exit(1) + + # Create VEX entry + vex_entry = create_vex_entry( + args.issue_number, + args.issue_title, + args.issue_url, + justification, + cve_id, + ) + + # Write to file + file_number = get_next_file_number(args.output_dir) + output_file = os.path.join(args.output_dir, f"{file_number}.json") + + with open(output_file, "w") as f: + json.dump(vex_entry, f, indent=2) + f.write("\n") + + print(f"Created: {output_file}") + + +if __name__ == "__main__": + main()