vulguard 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vulguard/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ vulguard - A lightweight security tool that automatically scans source code
3
+ for vulnerabilities, highlights risky patterns, and guides developers toward
4
+ safer implementations to strengthen their applications' overall security posture.
5
+
6
+ :author: Ron Webb
7
+ :since: 1.0.0
8
+ """
9
+
10
+ from env_dir_bootstrap import EnvDirBootstrap
11
+
12
+ __version__ = "1.0.0"
13
+
14
+ _bootstrapper = EnvDirBootstrap(
15
+ env_var="VULGUARD_CONFIG_DIR",
16
+ resources=["logging.ini", "config.ini"],
17
+ package="vulguard",
18
+ )
19
+
20
+ _bootstrapper.setup()
21
+
22
+ CONF_DIR = str(_bootstrapper.get_dir())
vulguard/cli.py ADDED
@@ -0,0 +1,297 @@
1
+ """
2
+ vulguard.cli - Command-line interface for the vulguard security inspection tool.
3
+
4
+ Defines the Click command group and the ``inspect`` sub-command. Handles
5
+ recursive file collection, orchestrates per-file Copilot inspections (results
6
+ persisted to SQLite via ``vulguard.db``), and delegates report generation to
7
+ ``vulguard.report``.
8
+
9
+ :author: Ron Webb
10
+ :since: 1.0.0
11
+ """
12
+
13
+ import asyncio
14
+ import uuid
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from logenrich import setup_logger
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.text import Text
22
+
23
+ from . import __version__, CONF_DIR
24
+ from .config import Config
25
+ from .db import (
26
+ delete_results_by_session,
27
+ get_db_path,
28
+ get_results_by_session,
29
+ init_db,
30
+ insert_result,
31
+ )
32
+ from .inspector import inspect_file, load_system_prompt
33
+ from .report import build_report, write_html, write_json
34
+
35
+ _console = Console()
36
+ _logger = setup_logger(__name__, conf_dir=CONF_DIR)
37
+
38
+
39
+ def _should_include(path: str | Path, extensions: list[str]) -> bool:
40
+ """Determines whether a file should be included based on its extension.
41
+
42
+ :param path: The file path to evaluate.
43
+ :param extensions: List of allowed extensions without dots. Empty means all files.
44
+ :return: True if the file should be included.
45
+ """
46
+ if not extensions:
47
+ return True
48
+ return Path(path).suffix.lstrip(".").lower() in extensions
49
+
50
+
51
+ def _collect_files(paths: tuple[str, ...], extensions: list[str]) -> list[str]:
52
+ """Collects files from the given paths, recursively walking directories.
53
+
54
+ :param paths: Tuple of file or directory paths supplied by the user.
55
+ :param extensions: List of allowed extensions. Empty list means all files.
56
+ :return: Sorted list of unique absolute file paths.
57
+ """
58
+ collected: set[str] = set()
59
+ for raw_path in paths:
60
+ path = Path(raw_path)
61
+ if path.is_file():
62
+ if _should_include(path, extensions):
63
+ collected.add(str(path.resolve()))
64
+ elif path.is_dir():
65
+ for file_path in path.rglob("*"):
66
+ if file_path.is_file() and _should_include(file_path, extensions):
67
+ collected.add(str(file_path.resolve()))
68
+ return sorted(collected)
69
+
70
+
71
+ async def _inspect_and_persist( # pylint: disable=too-many-arguments,too-many-positional-arguments
72
+ file_path: str,
73
+ system_prompt: str,
74
+ config: Config,
75
+ db_path: str,
76
+ session_id: str,
77
+ ) -> None:
78
+ """Inspects a single file and persists the result to the database.
79
+
80
+ :param file_path: Absolute path to the file to inspect.
81
+ :param system_prompt: The security inspection system prompt text.
82
+ :param config: The vulguard configuration instance.
83
+ :param db_path: Absolute path to the vulguard SQLite database file.
84
+ :param session_id: UUID string identifying the current inspection run.
85
+ """
86
+ _logger.debug("Inspecting file: %s", file_path)
87
+ severity = "NONE"
88
+ details = "The code is safe."
89
+ try:
90
+ result = await inspect_file(file_path, system_prompt, config)
91
+ severity = result.get("severity", "NONE")
92
+ details = result.get("details", "The code is safe.")
93
+ _logger.debug(
94
+ "Inspection complete for %s — severity: %s",
95
+ file_path,
96
+ severity,
97
+ )
98
+ except Exception as exc: # pylint: disable=broad-exception-caught
99
+ severity = "ERROR"
100
+ details = f"Inspection failed: {exc}"
101
+ _logger.error("Failed to inspect %s: %s", file_path, exc)
102
+ _console.print(f"[red]Failed to inspect {file_path}: {exc}[/red]")
103
+ loop = asyncio.get_running_loop()
104
+ await loop.run_in_executor(
105
+ None, insert_result, db_path, session_id, file_path, severity, details
106
+ )
107
+
108
+
109
+ async def _inspect_all( # pylint: disable=too-many-arguments,too-many-positional-arguments
110
+ files: list[str],
111
+ system_prompt: str,
112
+ config: Config,
113
+ db_path: str,
114
+ session_id: str,
115
+ ) -> None:
116
+ """Runs inspection for each file concurrently and persists each result to the database.
117
+
118
+ :param files: Sorted list of absolute file paths to inspect.
119
+ :param system_prompt: The security inspection system prompt text.
120
+ :param config: The vulguard configuration instance.
121
+ :param db_path: Absolute path to the vulguard SQLite database file.
122
+ :param session_id: UUID string identifying the current inspection run.
123
+ """
124
+ with _console.status(f"Inspecting {len(files)} file(s)…", spinner="dots"):
125
+ tasks = [
126
+ _inspect_and_persist(fp, system_prompt, config, db_path, session_id)
127
+ for fp in files
128
+ ]
129
+ await asyncio.gather(*tasks)
130
+
131
+
132
+ def _setup_db_session(db_dir: str | None) -> tuple[str, str]:
133
+ """Initialises the SQLite database and creates a new inspection session.
134
+
135
+ :param db_dir: Optional directory override for the database location.
136
+ :return: Tuple of ``(db_path, session_id)``.
137
+ """
138
+ db_path = get_db_path(db_dir)
139
+ init_db(db_path)
140
+ session_id = str(uuid.uuid4())
141
+ _logger.debug("Inspection session: %s db: %s", session_id, db_path)
142
+ return db_path, session_id
143
+
144
+
145
+ def _write_reports(
146
+ report: dict[str, object],
147
+ output_dir: str,
148
+ report_base: str,
149
+ fmt: str,
150
+ ) -> None:
151
+ """Writes the JSON report and, optionally, the HTML report to disk.
152
+
153
+ :param report: The report dict produced by :func:`.report.build_report`.
154
+ :param output_dir: Directory where report files are written.
155
+ :param report_base: Base filename (no extension).
156
+ :param fmt: ``'json'`` or ``'html'``; ``'html'`` also produces a JSON file.
157
+ """
158
+ output_path = Path(output_dir)
159
+ json_path = output_path / f"{report_base}.json"
160
+ write_json(report, json_path)
161
+ _logger.info("JSON report written: %s", json_path)
162
+ _console.print(f"[green]JSON report:[/green] {json_path}")
163
+
164
+ if fmt == "html":
165
+ html_path = output_path / f"{report_base}.html"
166
+ write_html(report, html_path)
167
+ _logger.info("HTML report written: %s", html_path)
168
+ _console.print(f"[green]HTML report:[/green] {html_path}")
169
+
170
+
171
+ async def _run_inspection( # pylint: disable=too-many-arguments,too-many-positional-arguments
172
+ paths: tuple[str, ...],
173
+ extensions: list[str],
174
+ output_dir: str,
175
+ report_base: str,
176
+ fmt: str,
177
+ db_dir: str | None = None,
178
+ ) -> None:
179
+ """Orchestrates file collection, Copilot inspection, and report writing.
180
+
181
+ Inspection results are persisted to SQLite (``vulguard.db``) during the
182
+ run. After the report is written the session records are deleted.
183
+
184
+ :param paths: Tuple of file or directory paths to inspect.
185
+ :param extensions: File extension filter list (empty = all files).
186
+ :param output_dir: Directory where reports are written.
187
+ :param report_base: Base filename for the report (no suffix appended).
188
+ :param fmt: Report format — ``'json'`` or ``'html'``.
189
+ :param db_dir: Optional directory that overrides the default database
190
+ location (``~/.vulguard``).
191
+ """
192
+ _logger.info("Starting vulguard inspection — paths: %s, fmt: %s", paths, fmt)
193
+ system_prompt = load_system_prompt()
194
+ config = Config()
195
+ files = _collect_files(paths, extensions)
196
+
197
+ if not files:
198
+ _logger.warning("No files found matching the specified criteria.")
199
+ _console.print(
200
+ "[yellow]No files found matching the specified criteria.[/yellow]"
201
+ )
202
+ return
203
+
204
+ db_path, session_id = _setup_db_session(db_dir)
205
+ _logger.info("Collected %d file(s) for inspection.", len(files))
206
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
207
+ await _inspect_all(files, system_prompt, config, db_path, session_id)
208
+
209
+ results = get_results_by_session(db_path, session_id)
210
+ report = build_report(results, __version__)
211
+ vuln_count = sum(1 for r in results if r.get("severity") != "NONE")
212
+
213
+ _write_reports(report, output_dir, report_base, fmt)
214
+
215
+ delete_results_by_session(db_path, session_id)
216
+ _logger.debug("Session %s removed from database.", session_id)
217
+
218
+ _logger.info("Inspection complete. %d vulnerability(ies) found.", vuln_count)
219
+ _console.print(
220
+ f"\n[bold]Inspection complete.[/bold] {vuln_count} vulnerability(ies) found."
221
+ )
222
+
223
+
224
+ @click.command("inspect")
225
+ @click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True))
226
+ @click.option(
227
+ "--ext",
228
+ default=None,
229
+ help=(
230
+ "Comma-separated file extensions to inspect (e.g. py,js,ts). "
231
+ "Inspects all files if omitted."
232
+ ),
233
+ )
234
+ @click.option(
235
+ "--output-dir",
236
+ default=None,
237
+ type=click.Path(),
238
+ help="Output directory for reports. Defaults to <cwd>/reports.",
239
+ )
240
+ @click.option(
241
+ "--report",
242
+ default="vulguard-report",
243
+ show_default=True,
244
+ help="Base filename for the report (no suffix appended).",
245
+ )
246
+ @click.option(
247
+ "--format",
248
+ "fmt",
249
+ default="json",
250
+ show_default=True,
251
+ type=click.Choice(["json", "html"]),
252
+ help="Report format. Choosing 'html' also produces a JSON file.",
253
+ )
254
+ @click.option(
255
+ "--db-dir",
256
+ default=None,
257
+ type=click.Path(),
258
+ help="Directory for the vulguard SQLite database. Defaults to ~/.vulguard.",
259
+ )
260
+ def inspect_command( # pylint: disable=too-many-arguments,too-many-positional-arguments
261
+ paths: tuple[str, ...],
262
+ ext: str | None,
263
+ output_dir: str | None,
264
+ report: str,
265
+ fmt: str,
266
+ db_dir: str | None,
267
+ ) -> None:
268
+ """Inspect source files or directories for security vulnerabilities.
269
+
270
+ PATHS are one or more file or directory paths to inspect.
271
+ Directories are walked recursively. Each file is inspected in an
272
+ isolated GitHub Copilot session.
273
+ """
274
+ extensions: list[str] = [e.strip().lower() for e in ext.split(",")] if ext else []
275
+ effective_output_dir = output_dir or str(Path.cwd() / "reports")
276
+ asyncio.run(
277
+ _run_inspection(paths, extensions, effective_output_dir, report, fmt, db_dir)
278
+ )
279
+
280
+
281
+ @click.group()
282
+ @click.version_option(
283
+ __version__, "--version", "-V", prog_name="vulguard", message="%(prog)s %(version)s"
284
+ )
285
+ def main() -> None:
286
+ """vulguard - AI-powered source code security inspector."""
287
+ banner = Text.assemble(
288
+ ("vulguard", "bold cyan"),
289
+ (" v", "dim"),
290
+ (__version__, "bold white"),
291
+ " - ",
292
+ ("AI-powered source code security inspector", "dim"),
293
+ )
294
+ _console.print(Panel(banner, expand=False, border_style="cyan"))
295
+
296
+
297
+ main.add_command(inspect_command)
vulguard/config.ini ADDED
@@ -0,0 +1,8 @@
1
+ [model]
2
+ model = claude-sonnet-4.6
3
+ timeout = 300
4
+
5
+ [retry]
6
+ max-attempts = 5
7
+ base-delay = 0.5
8
+ max-delay = 10.0
vulguard/config.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ vulguard.config - Configuration management module for vulguard.
3
+
4
+ Reads settings from the bootstrapped config.ini file and provides
5
+ typed accessors with sensible defaults.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import configparser
12
+ from pathlib import Path
13
+
14
+ from . import CONF_DIR
15
+
16
+ _DEFAULT_MODEL = "claude-sonnet-4.6"
17
+ _DEFAULT_TIMEOUT = 300
18
+ _DEFAULT_MAX_ATTEMPTS = 5
19
+ _DEFAULT_BASE_DELAY = 0.5
20
+ _DEFAULT_MAX_DELAY = 10.0
21
+
22
+
23
+ class Config:
24
+ """
25
+ Manages the vulguard configuration from config.ini.
26
+
27
+ Reads from the bootstrapped configuration directory (``CONF_DIR``) and
28
+ provides typed accessors for configuration values with sensible defaults
29
+ so that the application continues to function even when the file is absent.
30
+
31
+ :author: Ron Webb
32
+ :since: 1.0.0
33
+ """
34
+
35
+ def __init__(self, conf_dir: str | None = None) -> None:
36
+ """Initialises the configuration by reading config.ini from CONF_DIR.
37
+
38
+ :param conf_dir: Optional directory override for the config file location.
39
+ Falls back to the bootstrapped ``CONF_DIR`` when omitted.
40
+ """
41
+ self._config = configparser.ConfigParser()
42
+ config_path = Path(conf_dir or CONF_DIR) / "config.ini"
43
+ self._config.read(config_path)
44
+
45
+ def get_model(self) -> str:
46
+ """Returns the configured Copilot model name.
47
+
48
+ Falls back to ``claude-sonnet-4.6`` if the key is absent.
49
+
50
+ :return: The model identifier string.
51
+ """
52
+ return self._config.get("model", "model", fallback=_DEFAULT_MODEL)
53
+
54
+ def get_timeout(self) -> int:
55
+ """Returns the configured inspection timeout in seconds.
56
+
57
+ Falls back to 300 seconds if the key is absent.
58
+
59
+ :return: Timeout as a positive integer number of seconds.
60
+ """
61
+ return self._config.getint("model", "timeout", fallback=_DEFAULT_TIMEOUT)
62
+
63
+ def get_max_attempts(self) -> int:
64
+ """Returns the maximum number of retry attempts.
65
+
66
+ Falls back to 5 if the key is absent.
67
+
68
+ :return: Maximum retry attempt count as a positive integer.
69
+ """
70
+ return self._config.getint(
71
+ "retry", "max-attempts", fallback=_DEFAULT_MAX_ATTEMPTS
72
+ )
73
+
74
+ def get_base_delay(self) -> float:
75
+ """Returns the base delay in seconds for exponential back-off.
76
+
77
+ Falls back to 0.5 seconds if the key is absent.
78
+
79
+ :return: Base delay as a float number of seconds.
80
+ """
81
+ return self._config.getfloat(
82
+ "retry", "base-delay", fallback=_DEFAULT_BASE_DELAY
83
+ )
84
+
85
+ def get_max_delay(self) -> float:
86
+ """Returns the maximum delay cap in seconds for exponential back-off.
87
+
88
+ Falls back to 10.0 seconds if the key is absent.
89
+
90
+ :return: Maximum delay as a float number of seconds.
91
+ """
92
+ return self._config.getfloat("retry", "max-delay", fallback=_DEFAULT_MAX_DELAY)
vulguard/db.py ADDED
@@ -0,0 +1,130 @@
1
+ """
2
+ vulguard.db - SQLite persistence layer for vulguard inspection sessions.
3
+
4
+ Provides functions to initialise the database, insert per-file inspection
5
+ records, query results by session, and delete records after report generation.
6
+
7
+ The default database location is ``~/.vulguard/vulguard.db``. A custom
8
+ directory can be supplied to override the default.
9
+
10
+ :author: Ron Webb
11
+ :since: 1.1.0
12
+ """
13
+
14
+ import sqlite3
15
+ import uuid
16
+ from collections.abc import Generator
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+
20
+ _DB_NAME = "vulguard.db"
21
+ _DEFAULT_DIR = Path.home() / ".vulguard"
22
+
23
+
24
+ def get_db_path(db_dir: str | None = None) -> str:
25
+ """Returns the absolute path to the vulguard SQLite database file.
26
+
27
+ Uses *db_dir* when provided, otherwise falls back to ``~/.vulguard``.
28
+
29
+ :param db_dir: Optional directory that overrides the default location.
30
+ :return: Absolute path to the ``vulguard.db`` file.
31
+ """
32
+ directory = Path(db_dir) if db_dir else _DEFAULT_DIR
33
+ return str(directory.resolve() / _DB_NAME)
34
+
35
+
36
+ @contextmanager
37
+ def _connect(db_path: str) -> Generator[sqlite3.Connection, None, None]:
38
+ """Context manager that opens and closes a SQLite connection.
39
+
40
+ :param db_path: Absolute path to the database file.
41
+ :yields: An open :class:`sqlite3.Connection`.
42
+ """
43
+ conn = sqlite3.connect(db_path)
44
+ try:
45
+ yield conn
46
+ finally:
47
+ conn.close()
48
+
49
+
50
+ def init_db(db_path: str) -> None:
51
+ """Creates the parent directory and the ``inspections`` table if absent.
52
+
53
+ :param db_path: Absolute path to the database file.
54
+ """
55
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
56
+ with _connect(db_path) as conn:
57
+ conn.execute("""
58
+ CREATE TABLE IF NOT EXISTS inspections (
59
+ id TEXT PRIMARY KEY,
60
+ session_id TEXT NOT NULL,
61
+ file_path TEXT NOT NULL,
62
+ severity TEXT NOT NULL,
63
+ details TEXT NOT NULL
64
+ )
65
+ """)
66
+ conn.commit()
67
+
68
+
69
+ def insert_result(
70
+ db_path: str,
71
+ session_id: str,
72
+ file_path: str,
73
+ severity: str,
74
+ details: str,
75
+ ) -> None:
76
+ """Inserts a single file inspection result into the database.
77
+
78
+ A new UUID is generated automatically and used as the record's primary key.
79
+
80
+ :param db_path: Absolute path to the database file.
81
+ :param session_id: UUID string identifying the current inspection run.
82
+ :param file_path: Absolute path to the inspected file.
83
+ :param severity: Severity level (``CRITICAL``, ``MAJOR``, ``MINOR``, or ``NONE``).
84
+ :param details: Human-readable description of the finding.
85
+ """
86
+ record_id = str(uuid.uuid4())
87
+ with _connect(db_path) as conn:
88
+ conn.execute(
89
+ "INSERT INTO inspections (id, session_id, file_path, severity, details) "
90
+ "VALUES (?, ?, ?, ?, ?)",
91
+ (record_id, session_id, file_path, severity, details),
92
+ )
93
+ conn.commit()
94
+
95
+
96
+ def get_results_by_session(
97
+ db_path: str,
98
+ session_id: str,
99
+ ) -> list[dict[str, str]]:
100
+ """Returns all inspection records for a given session as a list of dicts.
101
+
102
+ :param db_path: Absolute path to the database file.
103
+ :param session_id: UUID string identifying the inspection run.
104
+ :return: List of dicts with ``file``, ``severity``, and ``details`` keys,
105
+ ordered by file path.
106
+ """
107
+ with _connect(db_path) as conn:
108
+ cursor = conn.execute(
109
+ "SELECT file_path, severity, details FROM inspections "
110
+ "WHERE session_id = ? ORDER BY file_path",
111
+ (session_id,),
112
+ )
113
+ return [
114
+ {"file": row[0], "severity": row[1], "details": row[2]}
115
+ for row in cursor.fetchall()
116
+ ]
117
+
118
+
119
+ def delete_results_by_session(db_path: str, session_id: str) -> None:
120
+ """Deletes all inspection records associated with a session UUID.
121
+
122
+ :param db_path: Absolute path to the database file.
123
+ :param session_id: UUID string identifying the inspection run to remove.
124
+ """
125
+ with _connect(db_path) as conn:
126
+ conn.execute(
127
+ "DELETE FROM inspections WHERE session_id = ?",
128
+ (session_id,),
129
+ )
130
+ conn.commit()
vulguard/inspector.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ vulguard.inspector - GitHub Copilot SDK integration for security file inspection.
3
+
4
+ Loads the system prompt, sends each file to an isolated Copilot session,
5
+ and parses the model response into a structured vulnerability dict.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import json
12
+ from importlib import resources
13
+ from pathlib import Path
14
+
15
+ from copilot import CopilotClient # pylint: disable=import-error
16
+ from copilot.session import PermissionHandler # pylint: disable=import-error
17
+ from copilot.session_events import AssistantMessageData # pylint: disable=import-error
18
+
19
+ from .config import Config
20
+ from .retry import retry_async
21
+
22
+
23
+ def load_system_prompt() -> str:
24
+ """Loads the security inspection system prompt from the package resources.
25
+
26
+ :return: The full system prompt text.
27
+ """
28
+ prompt_ref = resources.files("vulguard").joinpath("prompts/system-prompt.md")
29
+ return prompt_ref.read_text(encoding="utf-8")
30
+
31
+
32
+ def _parse_sdk_response(content: str, file_path: str) -> dict[str, str]:
33
+ """Parses the JSON response returned by the Copilot model.
34
+
35
+ Strips markdown code fences if present and falls back to a safe ``NONE``
36
+ entry on any parse failure.
37
+
38
+ :param content: Raw text response from the model.
39
+ :param file_path: The inspected file path used as the ``file`` field value.
40
+ :return: Dict with ``file``, ``severity``, and ``details`` keys.
41
+ """
42
+ cleaned = content.strip()
43
+ if cleaned.startswith("```"):
44
+ lines = cleaned.splitlines()
45
+ cleaned = "\n".join(lines[1:-1]).strip()
46
+ try:
47
+ data = json.loads(cleaned)
48
+ return {
49
+ "file": file_path,
50
+ "severity": str(data.get("severity", "NONE")).upper(),
51
+ "details": str(data.get("details", "The code is safe.")),
52
+ }
53
+ except json.JSONDecodeError, ValueError:
54
+ return {
55
+ "file": file_path,
56
+ "severity": "NONE",
57
+ "details": "The code is safe.",
58
+ }
59
+
60
+
61
+ async def inspect_file(
62
+ file_path: str,
63
+ system_prompt: str,
64
+ config: Config,
65
+ ) -> dict[str, str]:
66
+ """Inspects a single file using an isolated GitHub Copilot session.
67
+
68
+ A new ``CopilotClient`` and session are created for each file to ensure
69
+ full context isolation between inspections. The ``send_and_wait`` call is
70
+ wrapped with :func:`.retry.retry_async` to transparently retry transient
71
+ failures using exponential back-off with full jitter.
72
+
73
+ :param file_path: Absolute path to the file to inspect.
74
+ :param system_prompt: The security inspection system prompt text.
75
+ :param config: The vulguard configuration instance.
76
+ :return: Dict with ``file``, ``severity``, and ``details`` keys.
77
+ """
78
+ async with CopilotClient() as client:
79
+ async with await client.create_session(
80
+ on_permission_request=PermissionHandler.approve_all,
81
+ model=config.get_model(),
82
+ system_message={"mode": "replace", "content": system_prompt},
83
+ ) as session:
84
+ response = await retry_async(
85
+ session.send_and_wait,
86
+ f"Check for security vulnerability the {file_path}",
87
+ attachments=[
88
+ {
89
+ "type": "file",
90
+ "path": file_path,
91
+ "displayName": Path(file_path).name,
92
+ }
93
+ ],
94
+ timeout=float(config.get_timeout()),
95
+ max_attempts=config.get_max_attempts(),
96
+ base_delay=config.get_base_delay(),
97
+ max_delay=config.get_max_delay(),
98
+ )
99
+
100
+ if response is None:
101
+ return {"file": file_path, "severity": "NONE", "details": "The code is safe."}
102
+
103
+ content = ""
104
+ if isinstance(response.data, AssistantMessageData):
105
+ content = response.data.content or ""
106
+
107
+ return _parse_sdk_response(content, file_path)
vulguard/logging.ini ADDED
@@ -0,0 +1,35 @@
1
+ [loggers]
2
+ keys=root,copilotClient
3
+
4
+ [handlers]
5
+ keys=consoleHandler,fileHandler
6
+
7
+ [formatters]
8
+ keys=logFormatter,consoleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler,fileHandler
13
+
14
+ [logger_copilotClient]
15
+ level=WARN
16
+ handlers=consoleHandler,fileHandler
17
+ qualname=copilot.client
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=WARN
23
+ formatter=consoleFormatter
24
+ args=(sys.stderr,)
25
+
26
+ [handler_fileHandler]
27
+ class=FileHandler
28
+ formatter=logFormatter
29
+ args=('vulguard.log', 'a')
30
+
31
+ [formatter_logFormatter]
32
+ format=%(asctime)s [%(levelname)s] %(name)s - %(message)s
33
+
34
+ [formatter_consoleFormatter]
35
+ format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
@@ -0,0 +1,40 @@
1
+ # Security Vulnerability Inspector
2
+
3
+ You are an expert security code analyst. Your sole task is to inspect the provided source code for security vulnerabilities.
4
+
5
+ ## Response Format
6
+
7
+ You MUST respond with ONLY a single valid JSON object — no prose, no markdown, no code fences, no explanation before or after the JSON.
8
+
9
+ The JSON object must follow this exact schema:
10
+
11
+ {"file": "<fully-qualified filename>", "severity": "<CRITICAL|MAJOR|MINOR|NONE>", "details": "<description>"}
12
+
13
+ ## Severity Levels
14
+
15
+ - **CRITICAL**: Immediate exploitation risk (e.g., SQL injection, command injection, hardcoded secrets/passwords/tokens, authentication bypass, remote code execution).
16
+ - **MAJOR**: High-risk issues that can lead to data exposure or unauthorized access (e.g., logging entire request payloads or sensitive user data, logging full exception objects or stack traces, insecure deserialization, path traversal, cross-site scripting without output encoding, CSRF on state-changing endpoints).
17
+ - **MINOR**: Lower-risk issues that could become vulnerabilities under certain conditions (e.g., overly verbose error messages, weak cryptography such as MD5 or SHA1 for security purposes, missing input validation, use of deprecated security APIs, storing sensitive data in cookies without Secure/HttpOnly flags).
18
+ - **NONE**: No security vulnerabilities detected. Set `details` to `"The code is safe."`.
19
+
20
+ ## Vulnerability Categories to Check
21
+
22
+ 1. **Payload and Exception Logging** — Code that logs entire HTTP request bodies, responses, user-submitted data, passwords, tokens, or full exception tracebacks to any logging output. This exposes sensitive data in log files.
23
+ 2. **Hardcoded Secrets** — API keys, database passwords, private keys, tokens, or credentials embedded as string literals in source code.
24
+ 3. **SQL Injection** — Dynamic SQL queries constructed with string concatenation, `%` formatting, or f-strings without parameterized queries or prepared statements.
25
+ 4. **Command Injection** — Calls to `os.system`, `subprocess` with `shell=True`, or `eval`/`exec` on user-controlled input without sanitization.
26
+ 5. **Path Traversal** — File system operations where the path is derived from user input without stripping `..` components or canonicalizing the path.
27
+ 6. **Cross-Site Scripting (XSS)** — Rendering user-controlled input as raw HTML without output escaping (e.g., using `Markup(user_input)` in Flask/Jinja without `| e`).
28
+ 7. **Insecure Deserialization** — Use of `pickle.loads`, `yaml.load` without a safe Loader, `marshal`, or `eval`/`exec` to deserialize untrusted data.
29
+ 8. **Weak Cryptography** — Use of MD5, SHA1, DES, or RC4 for security-critical operations; use of `random` instead of `secrets` for generating tokens; hardcoded IVs or salts.
30
+ 9. **Sensitive Data Exposure** — Passwords, secrets, or PII returned in API responses or written to error messages accessible to end users.
31
+ 10. **Missing Authentication or Authorization Checks** — Endpoints or functions that perform privileged operations without verifying the caller's identity or permissions.
32
+ 11. **XML External Entity (XXE)** — XML parsing with external entity expansion enabled (e.g., `lxml` without `resolve_entities=False`).
33
+ 12. **CSRF** — State-changing web endpoints (POST, PUT, DELETE) without CSRF token validation.
34
+
35
+ ## Rules
36
+
37
+ 1. Identify the **single most severe** vulnerability present in the file.
38
+ 2. If multiple vulnerabilities exist, report only the most critical one and briefly mention the others in the `details` field.
39
+ 3. Set `file` to the fully-qualified path of the file as provided in the user message.
40
+ 4. Your **entire response** must be exactly one valid JSON object and nothing else — no leading text, no trailing text.
vulguard/report.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ vulguard.report - JSON and HTML report generation for vulguard security scan results.
3
+
4
+ Provides functions to build the report data structure from raw inspection results
5
+ and to serialise it to JSON and self-contained HTML output files.
6
+
7
+ :author: Ron Webb
8
+ :since: 1.0.0
9
+ """
10
+
11
+ import html
12
+ import json
13
+ import re
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ _APPLICATION_NAME = "vulguard"
18
+
19
+ _HTML_TEMPLATE = """<!DOCTYPE html>
20
+ <html lang="en">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
24
+ <title>Vulguard Security Report</title>
25
+ <style>
26
+ * { box-sizing: border-box; margin: 0; padding: 0; }
27
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
+ background: #f4f6f8; color: #333; padding: 32px; }
29
+ h1 { font-size: 24px; margin-bottom: 4px; }
30
+ .header { background: #1a1a2e; color: #fff; padding: 28px 32px;
31
+ border-radius: 10px; margin-bottom: 24px; }
32
+ .header .sub { font-size: 13px; opacity: 0.65; margin-top: 6px; }
33
+ .summary { display: flex; gap: 16px; margin-bottom: 28px; }
34
+ .card { background: #fff; border-radius: 10px; padding: 20px; flex: 1;
35
+ text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
36
+ .card .num { font-size: 34px; font-weight: 700; }
37
+ .card .lbl { font-size: 12px; color: #777; margin-top: 4px;
38
+ text-transform: uppercase; letter-spacing: .5px; }
39
+ .card { cursor: pointer; transition: box-shadow .15s, transform .1s; }
40
+ .card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.13); transform: translateY(-2px); }
41
+ .card.active { outline: 2px solid #1a1a2e; }
42
+ .c-red { color: #c0392b; } .c-orange { color: #e67e22; } .c-yellow { color: #d4ac0d; } .c-grey { color: #7f8c8d; }
43
+ .vuln { background: #fff; border-radius: 10px; margin-bottom: 16px;
44
+ overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); display: flex; }
45
+ .bar { width: 6px; flex-shrink: 0; }
46
+ .bar.CRITICAL { background: #c0392b; }
47
+ .bar.MAJOR { background: #e67e22; }
48
+ .bar.MINOR { background: #d4ac0d; }
49
+ .bar.ERROR { background: #7f8c8d; }
50
+ .body { padding: 18px 20px; flex: 1; }
51
+ .badge { display: inline-block; padding: 2px 10px; border-radius: 12px;
52
+ font-size: 11px; font-weight: 700; text-transform: uppercase; margin-right: 10px; }
53
+ .badge.CRITICAL { background: #fdecea; color: #c0392b; }
54
+ .badge.MAJOR { background: #fef0e6; color: #e67e22; }
55
+ .badge.MINOR { background: #fef9e7; color: #d4ac0d; }
56
+ .badge.ERROR { background: #eaecee; color: #7f8c8d; }
57
+ .file { font-size: 12px; font-family: monospace; color: #555; }
58
+ .details { margin-top: 8px; font-size: 14px; color: #555; line-height: 1.55; }
59
+ .no-vulns { text-align: center; padding: 48px; color: #888; font-size: 16px;
60
+ background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <div class="header">
65
+ <h1>Vulguard Security Report</h1>
66
+ <div class="sub">%%APPLICATION%% &bull; v%%VERSION%% &bull; %%TIMESTAMP%%</div>
67
+ </div>
68
+ <div class="summary">
69
+ <div class="card active" data-filter="ALL" onclick="filterCards(this)"><div class="num">%%TOTAL%%</div><div class="lbl">Vulnerabilities</div></div>
70
+ <div class="card" data-filter="CRITICAL" onclick="filterCards(this)"><div class="num c-red">%%CRITICAL%%</div><div class="lbl">Critical</div></div>
71
+ <div class="card" data-filter="MAJOR" onclick="filterCards(this)"><div class="num c-orange">%%MAJOR%%</div><div class="lbl">Major</div></div>
72
+ <div class="card" data-filter="MINOR" onclick="filterCards(this)"><div class="num c-yellow">%%MINOR%%</div><div class="lbl">Minor</div></div>
73
+ <div class="card" data-filter="ERROR" onclick="filterCards(this)"><div class="num c-grey">%%ERROR%%</div><div class="lbl">Error</div></div>
74
+ </div>
75
+ %%ROWS%%%%NO_VULNS%%
76
+ <script>
77
+ function filterCards(el) {
78
+ document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
79
+ el.classList.add('active');
80
+ var filter = el.getAttribute('data-filter');
81
+ document.querySelectorAll('.vuln').forEach(function(row) {
82
+ row.style.display = (filter === 'ALL' || row.getAttribute('data-severity') === filter) ? 'flex' : 'none';
83
+ });
84
+ }
85
+ </script>
86
+ </body>
87
+ </html>
88
+ """
89
+
90
+ _HTML_ROW_TEMPLATE = (
91
+ '<div class="vuln" data-severity="%%SEVERITY%%">'
92
+ '<div class="bar %%SEVERITY%%"></div>'
93
+ '<div class="body">'
94
+ '<span class="badge %%SEVERITY%%">%%SEVERITY%%</span>'
95
+ '<span class="file">%%FILE%%</span>'
96
+ '<div class="details">%%DETAILS%%</div>'
97
+ "</div>"
98
+ "</div>\n"
99
+ )
100
+
101
+
102
+ def build_report(results: list[dict[str, str]], version: str) -> dict[str, object]:
103
+ """Builds the final report dict, filtering out ``NONE``-severity entries.
104
+
105
+ :param results: List of per-file inspection result dicts.
106
+ :param version: The application version string.
107
+ :return: Structured report dict ready for JSON serialisation.
108
+ """
109
+ vulnerabilities: list[dict[str, str]] = [
110
+ r for r in results if r.get("severity") != "NONE"
111
+ ]
112
+ return {
113
+ "application": _APPLICATION_NAME,
114
+ "version": version,
115
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
116
+ "vulnerabilities": vulnerabilities,
117
+ }
118
+
119
+
120
+ def _render_html_vuln_row(vuln: dict[str, str]) -> str:
121
+ """Renders a single vulnerability as an HTML card row.
122
+
123
+ :param vuln: Vulnerability dict with ``file``, ``severity``, and ``details``.
124
+ :return: HTML string for the vulnerability card.
125
+ """
126
+ substitutions = {
127
+ "SEVERITY": str(vuln.get("severity", "MINOR")),
128
+ "FILE": html.escape(str(vuln.get("file", ""))),
129
+ "DETAILS": html.escape(str(vuln.get("details", ""))),
130
+ }
131
+ return re.sub(
132
+ r"%%(\w+)%%",
133
+ lambda m: substitutions.get(m.group(1), m.group(0)),
134
+ _HTML_ROW_TEMPLATE,
135
+ )
136
+
137
+
138
+ def _render_html_report(report: dict[str, object], vulns: list[dict[str, str]]) -> str:
139
+ """Renders the complete HTML report as a string.
140
+
141
+ :param report: The full report dict.
142
+ :param vulns: The list of vulnerability dicts to render.
143
+ :return: Rendered HTML string.
144
+ """
145
+ rows = "".join(_render_html_vuln_row(v) for v in vulns)
146
+ no_vulns = (
147
+ ""
148
+ if vulns
149
+ else '<p class="no-vulns">No vulnerabilities found. Your code looks safe!</p>'
150
+ )
151
+ substitutions = {
152
+ "APPLICATION": html.escape(str(report.get("application", _APPLICATION_NAME))),
153
+ "VERSION": html.escape(str(report.get("version", ""))),
154
+ "TIMESTAMP": html.escape(str(report.get("timestamp", ""))),
155
+ "TOTAL": str(len(vulns)),
156
+ "CRITICAL": str(sum(1 for v in vulns if v.get("severity") == "CRITICAL")),
157
+ "MAJOR": str(sum(1 for v in vulns if v.get("severity") == "MAJOR")),
158
+ "MINOR": str(sum(1 for v in vulns if v.get("severity") == "MINOR")),
159
+ "ERROR": str(sum(1 for v in vulns if v.get("severity") == "ERROR")),
160
+ "ROWS": rows,
161
+ "NO_VULNS": no_vulns,
162
+ }
163
+ return re.sub(
164
+ r"%%(\w+)%%",
165
+ lambda m: substitutions.get(m.group(1), m.group(0)),
166
+ _HTML_TEMPLATE,
167
+ )
168
+
169
+
170
+ def write_json(report: dict[str, object], path: Path) -> None:
171
+ """Writes the report as a JSON file.
172
+
173
+ :param report: The report dict to serialise.
174
+ :param path: Destination file path.
175
+ """
176
+ with open(path, "w", encoding="utf-8") as file_out:
177
+ json.dump(report, file_out, indent=2)
178
+
179
+
180
+ def write_html(report: dict[str, object], path: Path) -> None:
181
+ """Writes the report as a self-contained HTML file.
182
+
183
+ :param report: The report dict containing the ``vulnerabilities`` list.
184
+ :param path: Destination file path.
185
+ """
186
+ raw_vulns = report.get("vulnerabilities", [])
187
+ vulns: list[dict[str, str]] = []
188
+ if isinstance(raw_vulns, list):
189
+ vulns = [v for v in raw_vulns if isinstance(v, dict)]
190
+ html_content = _render_html_report(report, vulns)
191
+ with open(path, "w", encoding="utf-8") as file_out:
192
+ file_out.write(html_content)
vulguard/retry.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ vulguard.retry - Retry utilities with exponential back-off and full jitter.
3
+
4
+ Provides a generic async retry decorator and a helper that computes the
5
+ next sleep interval using the "Full Jitter" algorithm described in the
6
+ AWS Architecture Blog (exponential back-off + full random jitter).
7
+
8
+ :author: Ron Webb
9
+ :since: 1.0.0
10
+ """
11
+
12
+ import asyncio
13
+ import random
14
+ from collections.abc import Callable, Coroutine
15
+ from typing import Any
16
+
17
+ from logenrich import setup_logger
18
+
19
+ from . import CONF_DIR
20
+
21
+ _logger = setup_logger(__name__, conf_dir=CONF_DIR)
22
+
23
+
24
+ def _compute_delay(attempt: int, base_delay: float, max_delay: float) -> float:
25
+ """Computes the next sleep interval using full-jitter exponential back-off.
26
+
27
+ The formula is: ``random.uniform(0, min(max_delay, base_delay * 2 ** attempt))``.
28
+
29
+ :param attempt: Zero-based attempt index (0 = first retry).
30
+ :param base_delay: Initial delay in seconds before jitter is applied.
31
+ :param max_delay: Upper cap in seconds for the computed sleep interval.
32
+ :return: A non-negative float representing the number of seconds to sleep.
33
+ """
34
+ ceiling = min(max_delay, base_delay * (2**attempt))
35
+ return random.uniform(0, ceiling)
36
+
37
+
38
+ async def retry_async[T]( # pylint: disable=invalid-name
39
+ func: Callable[..., Coroutine[Any, Any, T]],
40
+ *args: Any,
41
+ max_attempts: int,
42
+ base_delay: float,
43
+ max_delay: float,
44
+ **kwargs: Any,
45
+ ) -> T:
46
+ """Calls an async coroutine function with retry and exponential-jitter back-off.
47
+
48
+ Retries on any :class:`Exception` up to *max_attempts* times. After each
49
+ failure the caller sleeps for a jittered duration before the next attempt.
50
+ If all attempts are exhausted the last exception is re-raised.
51
+
52
+ :param func: Async callable to invoke.
53
+ :param args: Positional arguments forwarded to *func*.
54
+ :param max_attempts: Total number of attempts (must be >= 1).
55
+ :param base_delay: Base delay in seconds used for back-off calculation.
56
+ :param max_delay: Maximum sleep duration cap in seconds.
57
+ :param kwargs: Keyword arguments forwarded to *func*.
58
+ :return: The return value of a successful *func* invocation.
59
+ :raises ValueError: If *max_attempts* is less than 1.
60
+ :raises Exception: Re-raises the last exception when all attempts fail.
61
+ """
62
+ if max_attempts < 1:
63
+ raise ValueError(f"max_attempts must be >= 1, got {max_attempts}")
64
+ last_exc: Exception | None = None
65
+ for attempt in range(max_attempts):
66
+ try:
67
+ return await func(*args, **kwargs)
68
+ except Exception as exc: # pylint: disable=broad-exception-caught
69
+ last_exc = exc
70
+ if attempt < max_attempts - 1:
71
+ delay = _compute_delay(attempt, base_delay, max_delay)
72
+ _logger.warning(
73
+ "Attempt %d/%d failed (%s). Retrying in %.2fs.",
74
+ attempt + 1,
75
+ max_attempts,
76
+ exc,
77
+ delay,
78
+ )
79
+ await asyncio.sleep(delay)
80
+ else:
81
+ _logger.error(
82
+ "Attempt %d/%d failed (%s). No more retries.",
83
+ attempt + 1,
84
+ max_attempts,
85
+ exc,
86
+ )
87
+ assert last_exc is not None # guaranteed when max_attempts >= 1
88
+ raise last_exc
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: vulguard
3
+ Version: 1.0.0
4
+ Summary: A lightweight security tool that automatically scans source code for vulnerabilities, highlights risky patterns, and guides developers toward safer implementations to strengthen their applications' overall security posture.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Ron Webb
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Author: Ron Webb
28
+ Author-email: ron@ronella.xyz
29
+ Requires-Python: >=3.14
30
+ Classifier: License :: Other/Proprietary License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.14
33
+ Requires-Dist: click (>=8.0.0,<9.0.0)
34
+ Requires-Dist: env-dir-bootstrap (>=1.0.0,<2.0.0)
35
+ Requires-Dist: github-copilot-sdk (>=1.0.1,<2.0.0)
36
+ Requires-Dist: logenrich (>=1.0.1,<2.0.0)
37
+ Requires-Dist: rich (>=15.0.0,<16.0.0)
38
+ Project-URL: Repository, https://github.com/rcw3bb/vulguard
39
+ Description-Content-Type: text/markdown
40
+
41
+ # vulguard 1.0.0
42
+
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
44
+ [![Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](CHANGELOG.md)
45
+
46
+ > A lightweight CLI security tool that automatically scans source code for vulnerabilities, highlights risky patterns, and guides developers toward safer implementations to strengthen their applications' overall security posture.
47
+
48
+ ## Prerequisites
49
+
50
+ - Python `>=3.14`
51
+ - An active [GitHub Copilot](https://github.com/features/copilot) subscription (used for AI-powered inspection)
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install vulguard
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ vulguard [OPTIONS] COMMAND [ARGS]...
63
+ ```
64
+
65
+ ### `inspect` — Scan files or directories
66
+
67
+ ```bash
68
+ vulguard inspect [OPTIONS] PATHS...
69
+ ```
70
+
71
+ | Option | Default | Description |
72
+ |---|---|---|
73
+ | `PATHS` | *(required)* | One or more files or directories to scan (recursive). |
74
+ | `--ext TEXT` | *(all files)* | Comma-separated extensions to inspect, e.g. `py,js,ts`. |
75
+ | `--output-dir PATH` | `<cwd>/reports` | Directory where reports are written. |
76
+ | `--report TEXT` | `vulguard-report` | Base filename for the report (no extension appended). |
77
+ | `--format [json\|html]` | `json` | Report format. Selecting `html` also produces a JSON file. |
78
+ | `--db-dir PATH` | `~/.vulguard` | Directory for the SQLite session database. |
79
+
80
+ #### Examples
81
+
82
+ ```bash
83
+ # Scan all Python files in src/ and write a JSON report to ./reports
84
+ vulguard inspect src/ --ext py
85
+
86
+ # Scan multiple paths and produce an HTML report
87
+ vulguard inspect src/ tests/ --ext py,js --format html --output-dir reports
88
+
89
+ # Use a custom report name and database directory
90
+ vulguard inspect src/ --report my-scan --db-dir /tmp/vg-db
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ On first run, vulguard bootstraps a configuration directory and copies its default `config.ini` and `logging.ini` there. You can override the location with the `VULGUARD_CONFIG_DIR` environment variable:
96
+
97
+ ```bash
98
+ # Windows (PowerShell)
99
+ $env:VULGUARD_CONFIG_DIR = "C:\Users\you\.vulguard"
100
+
101
+ # macOS / Linux
102
+ export VULGUARD_CONFIG_DIR="$HOME/.vulguard"
103
+ ```
104
+
105
+ ### `config.ini` settings
106
+
107
+ | Section | Key | Default | Description |
108
+ |---|---|---|---|
109
+ | `model` | `model` | `claude-sonnet-4.6` | GitHub Copilot model used for inspection. |
110
+ | `model` | `timeout` | `300` | Per-file inspection timeout in seconds. |
111
+ | `retry` | `max-attempts` | `5` | Maximum number of retry attempts on transient errors. |
112
+ | `retry` | `base-delay` | `0.5` | Initial back-off delay in seconds. |
113
+ | `retry` | `max-delay` | `10.0` | Maximum back-off delay in seconds. |
114
+
115
+ ## Development
116
+
117
+ ### Prerequisites
118
+
119
+ - Poetry `2.2+`
120
+
121
+ ### Architecture
122
+
123
+ ```mermaid
124
+ graph TD
125
+ CLI["cli.py\n(Click entry point)"]
126
+ Inspector["inspector.py\n(GitHub Copilot SDK)"]
127
+ DB["db.py\n(SQLite persistence)"]
128
+ Report["report.py\n(JSON / HTML output)"]
129
+ Config["config.py\n(config.ini reader)"]
130
+ Prompt["prompts/system-prompt.md\n(security prompt)"]
131
+
132
+ CLI -->|"collects files\norchestrates"| Inspector
133
+ CLI --> Config
134
+ Inspector --> Prompt
135
+ CLI -->|"persists results"| DB
136
+ CLI -->|"reads session"| DB
137
+ CLI -->|"builds & writes"| Report
138
+ ```
139
+
140
+ ### Setup
141
+
142
+ ```bash
143
+ poetry install
144
+ ```
145
+
146
+ ### Format and Lint
147
+
148
+ ```bash
149
+ poetry run black vulguard; poetry run pylint vulguard
150
+ ```
151
+
152
+ Pylint must score **10.00/10** before committing.
153
+
154
+ ### Run Tests
155
+
156
+ ```bash
157
+ poetry run pytest --cov=vulguard tests --cov-report html
158
+ ```
159
+
160
+ Maintain **≥80 %** coverage.
161
+
162
+ ### Fixture-based integration smoke test
163
+
164
+ ```bash
165
+ poetry run vulguard inspect tests/fixtures --ext py --format html
166
+ ```
167
+
168
+ ## Publishing to PyPI
169
+
170
+ ### Prerequisites
171
+
172
+ - A [PyPI](https://pypi.org/) account with an API token.
173
+
174
+ ### Configure the token
175
+
176
+ ```bash
177
+ poetry config pypi-token.pypi <your-token>
178
+ ```
179
+
180
+ ### Build and publish
181
+
182
+ ```bash
183
+ poetry publish --build
184
+ ```
185
+
186
+ This builds the source distribution and wheel, then uploads them to PyPI in one step.
187
+
188
+ > **Note:** PyPI releases are immutable. Once a version is published, it cannot be overwritten.
189
+ > To fix a mistake, yank the release via the PyPI web UI and publish a new version.
190
+
191
+ ## [Changelog](CHANGELOG.md)
192
+
193
+ See [CHANGELOG.md](CHANGELOG.md) for the full release history.
194
+
195
+ ## License
196
+
197
+ This project is licensed under the [MIT License](LICENSE).
198
+
199
+ ## Author
200
+
201
+ Ron Webb
202
+
@@ -0,0 +1,15 @@
1
+ vulguard/__init__.py,sha256=_bdGgRjrgyhAoKfI6kdXopUu9l5WsJDIQxdoJeo2o-Y,550
2
+ vulguard/cli.py,sha256=2CHVp5-idh1yKs7rwFPe-3mIgry7whwIXlRO4vWa1zI,10422
3
+ vulguard/config.ini,sha256=C6fBsMwGP_wty6uShpbdMdeTqQT6XkGEpfoGrkpsChM,116
4
+ vulguard/config.py,sha256=h-Omy5DdxUTOWqscZO3cZWHJUqHGkUMO6B11pU5a_kg,2889
5
+ vulguard/db.py,sha256=MX788skP-F1WlIrYP5_UQv_ecpAIspZbxp7L1ChIgss,4206
6
+ vulguard/inspector.py,sha256=qFVxcpXOqlUmvGRENyK65riXz5YNWImE94-xKUHK65Q,3876
7
+ vulguard/logging.ini,sha256=5bWfmimR3CwjFNSMeEMQ2lqKoA0t2EMame1nJgEnTvo,699
8
+ vulguard/prompts/system-prompt.md,sha256=6bzJBzPiE9t65pvLWtHdeGxuRxvc2c40D5GyN1YNWSU,3777
9
+ vulguard/report.py,sha256=hxXDTnwTP4hazn2xsSM9ezOvclNHF49gEJ29XIX4AWU,8361
10
+ vulguard/retry.py,sha256=UI3fs9lP3f-Hj4o9U2bHfNmoETVNV_6n2xyM-IO86p4,3338
11
+ vulguard-1.0.0.dist-info/entry_points.txt,sha256=Yi7QisXgzoOywf0mBAt-oUrcWY-2ittSPsAYRgDihS4,46
12
+ vulguard-1.0.0.dist-info/licenses/LICENSE,sha256=2cmui6TBSfJF5E2Qr0xNS8x4ZY0grz8qLEBEW1CgpjA,1086
13
+ vulguard-1.0.0.dist-info/METADATA,sha256=5reCi3bXX5AgoKck198t0l2mWgxb8m2qZOIn6nlS66k,6357
14
+ vulguard-1.0.0.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
15
+ vulguard-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ vulguard=vulguard.cli:main
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ron Webb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.