From 6d2e03e1cf812a5721e6dd27dd7fca03dcfdc0c8 Mon Sep 17 00:00:00 2001 From: "Jyotirmoy Bandyopadhyaya [Bravo68]" Date: Fri, 2 Aug 2024 03:36:29 +0530 Subject: [PATCH] doc: worked on ci --- .github/README.md | 121 +---------------------- .github/workflows/compile.yml | 23 +++++ Makefile | 2 + lib/compile.py | 83 ---------------- lib/generate.py | 153 ----------------------------- lib/markdown.py | 177 ---------------------------------- lib/requirements.txt | 5 - lib/validate.py | 78 --------------- resume.yaml | 4 +- 9 files changed, 32 insertions(+), 614 deletions(-) delete mode 100644 lib/compile.py delete mode 100644 lib/generate.py delete mode 100644 lib/markdown.py delete mode 100644 lib/requirements.txt delete mode 100644 lib/validate.py diff --git a/.github/README.md b/.github/README.md index 69a3e57..a9e1974 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,137 +1,24 @@ -

Alicia Sykes - CV

+

Jyotirmoy Bandyopadhayaya - CV

- This repo contains the source for my personal CV
-🌐 cv.aliciasykes.com | 📄 Alicia-Sykes-CV.pdf
-

-
- - - > **Motivation**
> Why spend 30 minutes writing your CV, when you could spend 30 hours automating it? - -The resume content is defined in [`resume.yml`]() following the [jsonresume.org](https://jsonresume.org/) standard, and validated against [`schema.json`](/schema.json). -A LaTex document is then generated from [`template.jinja`](/template.jinja) formated with [`resume-format.cls`](/tex/resume-format.cls), which is then compiled into a PDF by GitHub Actions, and published under the [Releases]() tab. -A markdown version is also generated by [`lib/markdown.py`](/lib/markdown.py), as well as a CV website which is built as a static Svelte site and deployed to GitHub Pages at [cv.aliciasykes.com](https://aliciasykes.com) - - --- ## Usage -### Option #1 - GitHub Upon creating a tag (by triggering the [Tag Workflow](/.github/workflows/tag.yml)), a new Release will be drafted with your compiled CV PDF attatched 1. Fork the repo -2. Update resume.json with your own content +2. Update resume.yaml with your own content 3. Trigger the GitHub Action to compile the PDF - -
Workflows - -- `tag` - Creates a new Git tag. Optionally specify the tag name and description, or by default it will just bump the sem ver patch number by 1 -- `generate` - Generates your resume in PDF form as an artifact. If triggered by a tag, then a new release will be created, with the PDF attatched -- `validate` - Validates your resume data against the schema. This will also run whenever a new PR is opened, to ensure it's valid and working -
- ---- - -### Option #2 - Local -See the [`Makefile`](/Makefile) for all the available commands. Or, just run `make` from the root, to install deps, validate content, generate LaTex, and compile PDF - -1. Clone the repo -2. Update resume.json with your own content -3. Run `make` from the root, to install deps, validate content, generate LaTex, and compile PDF - -
Commands - -- `make install` - Download dependencies -- `make validate` - Validate content -- `make generate` - Generate LaTex -- `make compile` - Compile PDF -- `make clean` - Remove generated files -- `make watch` - Watch for changes, recompile and refresh -
- --- ### Editing -Modify data by editing [`resume.yml`](/resume.yml)
-If you need to customize the layout, edit [`template.jinja`](/template.jinja)
-Or to change the styles and formatting, edit [`resume-format.cls`](/lib/resume-format.cls)
-All the scripts used to generate output are located in [`lib/`](/lib/)
-These are triggered either by the [`Makefile`](/Makefile) or via GitHub Actions with the [`workflows/`](/.github/workflows)
-The source for the website version is located in [`web/`](/web) - ---- - -## Attributions - -### Contributors - -![Contributors](https://readme-contribs.as93.net/contributors/lissy93/cv) - -### Sponsors - -![Sponsors](https://readme-contribs.as93.net/sponsors/lissy93) - ---- - -## License - -> _**[Lissy93/CV](https://github.com/Lissy93/cv)** is licensed under [MIT](https://github.com/Lissy93/cv/blob/HEAD/LICENSE) © [Alicia Sykes](https://aliciasykes.com) 2024._
-> For information, see TLDR Legal > MIT - -
-Expand License - -``` -The MIT License (MIT) -Copyright (c) Alicia Sykes - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sub-license, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished -to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included install -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANT ABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -``` - -
- - -

- © Alicia Sykes 2024
- Licensed under MIT
-
- Thanks for visiting :) -

- - - +Modify data by editing [`resume.yaml`](/resume.yaml)
+--- \ No newline at end of file diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 9108640..3fa8323 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -17,6 +17,8 @@ on: env: PDF_NAME: Jyotirmoy_Bandyopadhayaya_CV.pdf + MISC_NAME: resume.zip + HTML_NAME: Jyotirmoy_Bandyopadhayaya_CV.html jobs: build: @@ -50,6 +52,12 @@ jobs: name: resume-pdf path: out/${{ env.PDF_NAME }} + - name: 📤 Upload Misc artifact + uses: actions/upload-artifact@v2 + with: + name: resume-misc-zip + path: ${{ env.MISC_NAME }} + release: needs: build runs-on: ubuntu-latest @@ -111,6 +119,11 @@ jobs: name: resume-pdf path: out/ + - name: 📤 Download Misc artifact + uses: actions/download-artifact@v2 + with: + name: resume-misc-zip + - name: 🔑 Set up GitHub CLI run: | echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token @@ -119,3 +132,13 @@ jobs: run: | gh release create $TAG_NAME out/${{ env.PDF_NAME }} --title "Release $TAG_NAME" --notes "Generated CV PDF for release $TAG_NAME" + - name: 📄 Upload assets to docs/ folder + run: | + mkdir -p docs + cp out/${{ env.PDF_NAME }} docs/cv.pdf + cp out/${{ env.HTML_NAME }} docs/index.html + git config user.name "b68web" + git config user.email "git@b68.dev" + git add docs/ + git commit -m "Add release $TAG_NAME" + git push \ No newline at end of file diff --git a/Makefile b/Makefile index b40c3d5..ab1ac97 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ # Define variables for common paths RESUME := resume.yaml OUTPUT_DIR := out +ZIP_FILE := resume.zip all: generate # Generate the LaTeX file from the template and JSON data generate: rendercv render $(RESUME) --output-folder-name $(OUTPUT_DIR) + zip -r $(ZIP_FILE) $(OUTPUT_DIR) diff --git a/lib/compile.py b/lib/compile.py deleted file mode 100644 index 5f41386..0000000 --- a/lib/compile.py +++ /dev/null @@ -1,83 +0,0 @@ -import argparse -import logging -import os -import subprocess -from colorama import init, Fore - -# Initialize colorama -init(autoreset=True) - -print(f"{Fore.CYAN}➡️ Starting: Compiling LaTex files into a PDF") - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') -logger = logging.getLogger(__name__) - -def compile_latex(input_tex: str, output_pdf: str, timeout: int = 60) -> None: - """ - Compiles a LaTeX file into a PDF using xelatex. - - Args: - input_tex (str): The path to the input LaTeX (.tex) file. - output_pdf (str): The path to the output PDF file. - timeout (int): The timeout in seconds for the LaTeX compilation process. - """ - try: - input_dir = os.path.dirname(input_tex) - output_dir = os.path.dirname(output_pdf) - base_name = os.path.basename(input_tex).replace('.tex', '.pdf') - - logger.info(f"Compiling {input_tex} to PDF...") - - # Change to the directory containing the input .tex file - compile_cmd = ['xelatex', '-interaction=nonstopmode', os.path.basename(input_tex)] - result = subprocess.run(compile_cmd, check=True, capture_output=True, text=True, timeout=timeout, cwd=input_dir) - - if result.returncode == 0: - logger.info("Compilation successful.") - # Ensure the output directory exists - if not os.path.exists(output_dir): - os.makedirs(output_dir) - # Move the generated PDF to the desired output path - compiled_pdf_path = os.path.join(input_dir, base_name) - os.rename(compiled_pdf_path, output_pdf) - print(f"{Fore.GREEN}✅ Success: PDF generated at {output_pdf}") - else: - logger.error("Compilation failed with errors.") - print(f"{Fore.RED}❌ Error: Compilation failed.") - print(result.stdout) - print(result.stderr) - except subprocess.CalledProcessError as e: - logger.error(f"Compilation failed: {e}") - print(f"{Fore.RED}❌ Error: Compilation failed: {e}") - except subprocess.TimeoutExpired as e: - logger.error("Compilation process timed out.") - print(f"{Fore.RED}❌ Error: Compilation process timed out.") - except Exception as e: - logger.error(f"Unexpected error: {e}") - print(f"{Fore.RED}❌ Error: Unexpected error: {e}") - -def main(): - parser = argparse.ArgumentParser(description="Compile a LaTeX file into a PDF.") - parser.add_argument('--input', required=True, help="Path to the input LaTeX (.tex) file") - parser.add_argument('--output', required=True, help="Path to the output PDF file") - - args = parser.parse_args() - - input_tex = args.input - output_pdf = args.output - - if not os.path.isfile(input_tex): - logger.error(f"Input file not found: {input_tex}") - print(f"{Fore.RED}❌ Error: Input file not found: {input_tex}") - return - - if not output_pdf.endswith('.pdf'): - logger.error("Output file must have a .pdf extension") - print(f"{Fore.RED}❌ Error: Output file must have a .pdf extension") - return - - compile_latex(input_tex, output_pdf) - -if __name__ == '__main__': - main() diff --git a/lib/generate.py b/lib/generate.py deleted file mode 100644 index 899f189..0000000 --- a/lib/generate.py +++ /dev/null @@ -1,153 +0,0 @@ -import argparse -import re -import logging -import os -import yaml -from datetime import datetime -from jinja2 import Environment, FileSystemLoader, select_autoescape -from colorama import init, Fore, Style - -# Initialize colorama -init(autoreset=True) - -print(f"{Fore.CYAN}➡️ Starting: Generating LaTex files from Jinja templates and YAML data") - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') -logger = logging.getLogger(__name__) - -def load_resume(yaml_path: str) -> dict: - """ - Load the resume data from a YAML file. - Args: - yaml_path (str): The path to the YAML file containing resume data. - Returns: - dict: The loaded resume data. - """ - try: - with open(yaml_path, 'r') as file: - return yaml.safe_load(file) - except Exception as e: - logger.error(f"Failed to load YAML file {yaml_path}: {e}") - raise - -def latex_escape(text: str) -> str: - """ - Escapes characters for LaTeX. - Args: - text (str): The input text to be escaped. - Returns: - str: The escaped text. - """ - latex_special_chars = { - '&': r'\&', - '%': r'\%', - '$': r'\$', - '#': r'\#', - '_': r'\_', - '{': r'\{', - '}': r'\}', - '~': r'\textasciitilde{}', - '^': r'\textasciicircum{}', - '\\': r'\textbackslash{}', - '<': r'\textless{}', - '>': r'\textgreater{}', - '|': r'\textbar{}', - '\'': r'\textquotesingle{}', - } - return ''.join(latex_special_chars.get(char, char) for char in text) - -def markdown_to_latex(text): - link_pattern = re.compile(r'\[([^\]]+)\]\(([^)]+)\)') - return link_pattern.sub(r'\\href{\2}{\1}', text).replace('%', '\%') - -def format_date(date_str: str) -> str: - """ - Formats a date string to 'Month Year'. If the format is unknown, returns the original string. - Args: - date_str (str): The input date string. - Returns: - str: The formatted date string. - """ - formats = ['%d-%m-%Y', '%m-%Y', '%Y-%m-%d', '%Y-%m'] - for fmt in formats: - try: - return datetime.strptime(date_str, fmt).strftime('%B %Y') - except ValueError: - continue - return date_str - -def render_template(template_path: str, resume_data: dict) -> str: - """ - Renders the LaTeX resume template with the provided resume data. - Args: - template_path (str): The path to the Jinja2 template file. - resume_data (dict): The resume data. - Returns: - str: The rendered LaTeX resume. - """ - env = Environment( - loader=FileSystemLoader(os.path.dirname(template_path)), - autoescape=select_autoescape(['html', 'xml', 'tex', 'jinja2']) - ) - env.filters['latex_escape'] = latex_escape - env.filters['format_date'] = format_date - env.filters['markdown_to_latex'] = markdown_to_latex - template = env.get_template(os.path.basename(template_path)) - return template.render( - basics=resume_data.get('basics', {}), - personal_statement=resume_data.get('personal-statement', ""), - work=resume_data.get('work', []), - education=resume_data.get('education', []), - skills=resume_data.get('skills', []), - awards=resume_data.get('awards', []), - achivments=resume_data.get('achivments', []), - extra_links=resume_data.get('extra-links', {}) - ) - -def main(): - parser = argparse.ArgumentParser(description="Generate a LaTeX resume from a YAML file and a Jinja2 template.") - parser.add_argument('--resume', required=True, help="Path to the YAML resume file") - parser.add_argument('--template', required=True, help="Path to the Jinja2 template file") - parser.add_argument('--output', required=True, help="Path to the output LaTeX file") - - args = parser.parse_args() - - if not os.path.isfile(args.resume): - logger.error(f"Resume file not found: {args.resume}") - print(f"{Fore.RED}❌ Error: Resume file not found: {args.resume}") - return - if not os.path.isfile(args.template): - logger.error(f"Template file not found: {args.template}") - print(f"{Fore.RED}❌ Error: Template file not found: {args.template}") - return - - try: - resume_data = load_resume(args.resume) - except yaml.YAMLError as e: - logger.error(f"Error decoding YAML from {args.resume}: {e}") - print(f"{Fore.RED}❌ Error decoding YAML from {args.resume}: {e}") - return - except Exception as e: - logger.error(f"Error loading resume file {args.resume}: {e}") - print(f"{Fore.RED}❌ Error: {e}") - return - - try: - rendered_resume = render_template(args.template, resume_data) - except Exception as e: - logger.error(f"Error rendering template: {e}") - print(f"{Fore.RED}❌ Error rendering template: {e}") - return - - try: - with open(args.output, 'w') as output_file: - output_file.write(rendered_resume) - logger.info(f"Resume successfully generated at {args.output}") - print(f"{Fore.GREEN}✅ Success: Resume successfully generated at {args.output}") - except Exception as e: - logger.error(f"Error writing to output file {args.output}: {e}") - print(f"{Fore.RED}❌ Error writing to output file {args.output}: {e}") - -if __name__ == '__main__': - main() diff --git a/lib/markdown.py b/lib/markdown.py deleted file mode 100644 index 7c41dbd..0000000 --- a/lib/markdown.py +++ /dev/null @@ -1,177 +0,0 @@ -import argparse -import logging -import os -import yaml -from colorama import init, Fore -from typing import Any, Dict, List, Optional -from datetime import datetime - -# Initialize colorama -init(autoreset=True) - -print(f"{Fore.CYAN}➡️ Starting: Generating Markdown from resume.yml") - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') -logger = logging.getLogger(__name__) - -def load_yaml(input_path: str) -> Dict[str, Any]: - """ - Loads a YAML file and returns its contents as a dictionary. - - Args: - input_path (str): The path to the input YAML file. - - Returns: - Dict[str, Any]: The contents of the YAML file. - """ - try: - with open(input_path, 'r') as file: - return yaml.safe_load(file) - except Exception as e: - logger.error(f"Failed to load YAML file: {e}") - raise - -def write_markdown(output_path: str, content: str) -> None: - """ - Writes the given content to a markdown file. - - Args: - output_path (str): The path to the output markdown file. - content (str): The content to write. - """ - try: - with open(output_path, 'w') as file: - file.write(content) - logger.info(f"Markdown file written to {output_path}") - except Exception as e: - logger.error(f"Failed to write markdown file: {e}") - raise - -def format_section(title: str, items: List[str]) -> str: - """ - Formats a section of the markdown file. - - Args: - title (str): The title of the section. - items (List[str]): The items to include in the section. - - Returns: - str: The formatted markdown section. - """ - if not items: - return "" - - section = f"\n## {title}\n" - section += "\n".join(items) - section += "\n" - return section - -def format_date(date_str: str) -> str: - """ - Formats a date string in the format YYYY-MM to a more readable format. - - Args: - date_str (str): The date string to format. - - Returns: - str: The formatted date string. - """ - try: - date = datetime.strptime(date_str, '%Y-%m') - return date.strftime('%B %Y') - except ValueError: - return date_str - -def generate_markdown(resume: Dict[str, Any]) -> str: - """ - Generates markdown content from the resume data. - - Args: - resume (Dict[str, Any]): The resume data. - - Returns: - str: The generated markdown content. - """ - md_content = [] - - basics = resume.get('basics', {}) - if basics: - email = basics.get('email', '') - url = basics.get('url', '') - location = basics.get('location', {}).get('address', '') - md_content.append(f"

{basics.get('name', '')}

") - md_content.append(f"

{email} | {url.replace('https://', '')} | {location}

") - - personal_statement = resume.get('personal-statement', '') - if personal_statement: - md_content.append(f"\n\n## Personal Statement\n{personal_statement}\n") - - extra_links = resume.get('extra-links', {}) - extra_links_work = extra_links.get('work_history', {}) - extra_links_projects = extra_links.get('projects', {}) - - education = resume.get('education', []) - edu_items = [ - f"- **{edu['institution']}** ({format_date(edu.get('startDate', ''))} - {format_date(edu.get('endDate', ''))}): {edu.get('studyType', '')} in {edu.get('area', '')}
\n {edu.get('score', '')}" - for edu in education - ] - md_content.append(format_section('Education', edu_items)) - - work = resume.get('work', []) - work_items = [] - for job in work: - highlights = "\n".join([f" - {highlight}" for highlight in job.get('highlights', [])]) - work_items.append( - f"- **{job['name']}** ({format_date(job.get('startDate', ''))} - {format_date(job.get('endDate', ''))}): {job['position']}
\n {highlights}" - ) - if extra_links_work: - work_items.append(f"See all previous roles at [{extra_links_work['text']}]({extra_links_work['link']})") - md_content.append(format_section('Work Experience', work_items)) - - skills = resume.get('skills', []) - skill_items = [ - f"- **{skill['name']}**: {', '.join(skill.get('keywords', []))}" - for skill in skills - ] - if extra_links_projects: - skill_items.append(f"See example projects built with each technology at {extra_links_projects['link']}") - md_content.append(format_section('Skills', skill_items)) - - achievements = resume.get('achivments', []) - achievement_items = [f"{achievement}\n" for achievement in achievements] - md_content.append(format_section('Achievements', achievement_items)) - - return ''.join(md_content) - -def main(): - parser = argparse.ArgumentParser(description="Generate Markdown from a YAML resume.") - parser.add_argument('--input', required=True, help="Path to the input YAML file") - parser.add_argument('--output', required=True, help="Path to the output Markdown file") - - args = parser.parse_args() - - input_yaml = args.input - output_markdown = args.output - - if not os.path.isfile(input_yaml): - logger.error(f"Input file not found: {input_yaml}") - print(f"{Fore.RED}❌ Error: Input file not found: {input_yaml}") - return - - if not output_markdown.endswith('.md'): - logger.error("Output file must have a .md extension") - print(f"{Fore.RED}❌ Error: Output file must have a .md extension") - return - - try: - resume_data = load_yaml(input_yaml) - markdown_content = generate_markdown(resume_data) - write_markdown(output_markdown, markdown_content) - print(f"{Fore.GREEN}✅ Success: Markdown generated at {output_markdown}") - except Exception as e: - logger.error(f"Failed to generate markdown: {e}") - print(f"{Fore.RED}❌ Error: Failed to generate markdown: {e}") - -if __name__ == '__main__': - main() diff --git a/lib/requirements.txt b/lib/requirements.txt deleted file mode 100644 index 69f2adc..0000000 --- a/lib/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -jsonschema -PyYAML -jinja2 -pylatex -colorama diff --git a/lib/validate.py b/lib/validate.py deleted file mode 100644 index 30ae84e..0000000 --- a/lib/validate.py +++ /dev/null @@ -1,78 +0,0 @@ -import json -import yaml -import jsonschema -from jsonschema import validate, Draft7Validator -import logging -import argparse -import sys -from colorama import init, Fore, Style - -# Initialize colorama -init(autoreset=True) - -print(f"{Fore.CYAN}➡️ Starting: Validating resume data against schema") - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') -logger = logging.getLogger(__name__) - -def load_yaml(file_path: str) -> dict: - """ - Load the resume data from a YAML file. - Args: - file_path (str): The path to the YAML file containing resume data. - Returns: - dict: The loaded resume data. - """ - try: - with open(file_path, 'r') as file: - return yaml.safe_load(file) - except Exception as e: - logger.error(f"Failed to load YAML file {file_path}: {e}") - sys.exit(1) - -def load_json(file_path: str) -> dict: - """ - Load the schema data from a JSON file. - Args: - file_path (str): The path to the JSON file containing schema data. - Returns: - dict: The loaded schema data. - """ - try: - with open(file_path, 'r') as file: - return json.load(file) - except Exception as e: - logger.error(f"Failed to load JSON file {file_path}: {e}") - sys.exit(1) - -def validate_resume(schema_path: str, resume_path: str): - """ - Validate the resume data against the provided schema. - Args: - schema_path (str): The path to the schema JSON file. - resume_path (str): The path to the resume YAML file. - """ - schema = load_json(schema_path) - resume = load_yaml(resume_path) - - validator = Draft7Validator(schema) - errors = sorted(validator.iter_errors(resume), key=lambda e: e.path) - - if errors: - logger.error(f"Error: {len(errors)} validation failures were found in {resume_path}") - for error in errors: - logger.error(f"Validation error: {error.message} at {'/'.join(map(str, error.path))}") - print(f"{Fore.RED}❌ Error: {len(errors)} validation failures were found in {resume_path}") - sys.exit(1) - else: - logger.info(f"Success: {resume_path} matches the schema") - print(f"{Fore.GREEN}✅ Success: {resume_path} matches the schema") - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Validate resume.yml against schema.json.') - parser.add_argument('--schema', type=str, default='schema.json', help='Path to the schema file') - parser.add_argument('--resume', type=str, default='resume.yml', help='Path to the resume file') - - args = parser.parse_args() - validate_resume(args.schema, args.resume) diff --git a/resume.yaml b/resume.yaml index 26956d4..f291def 100644 --- a/resume.yaml +++ b/resume.yaml @@ -92,4 +92,6 @@ cv: - label: "DevOps" details: "AWS, Heroku, Netlify, Vercel, DigitalOcean, Google Cloud, Azure, GitHub Actions, CodeBuild, CodePipeline, ECS, EC2, ECR, GCP, Azure" - label: "Security" - details: "CEHv12, CPTS, Security Researcher, CTF Player, OWASP, DevSecOps, Nmap, Burp Suite, Metasploit, Wireshark, WPScan, Nessus, Nikto, OSINT" \ No newline at end of file + details: "CEHv12, CPTS, Security Researcher, CTF Player, OWASP, DevSecOps, Nmap, Burp Suite, Metasploit, Wireshark, WPScan, Nessus, Nikto, OSINT" +design: + theme: engineeringresumes \ No newline at end of file