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 +22 -0
- vulguard/cli.py +297 -0
- vulguard/config.ini +8 -0
- vulguard/config.py +92 -0
- vulguard/db.py +130 -0
- vulguard/inspector.py +107 -0
- vulguard/logging.ini +35 -0
- vulguard/prompts/system-prompt.md +40 -0
- vulguard/report.py +192 -0
- vulguard/retry.py +88 -0
- vulguard-1.0.0.dist-info/METADATA +202 -0
- vulguard-1.0.0.dist-info/RECORD +15 -0
- vulguard-1.0.0.dist-info/WHEEL +4 -0
- vulguard-1.0.0.dist-info/entry_points.txt +3 -0
- vulguard-1.0.0.dist-info/licenses/LICENSE +21 -0
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
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%% • v%%VERSION%% • %%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)
|
|
44
|
+
[](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,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.
|