Skip to content

Commit 3410ef2

Browse files
authored
Merge pull request #1 from VulnerabilityHistoryProject/copilot/add-osv-data-dump-scripts
Add OSV recidivism enrichment pipeline, repository mirror tooling, and INI-based local configuration
2 parents 52cd734 + d146354 commit 3410ef2

9 files changed

Lines changed: 626 additions & 1 deletion

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,4 @@ __marimo__/
216216

217217
# Streamlit
218218
.streamlit/secrets.toml
219+
recidivism.ini

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,43 @@
1-
# recidivism
1+
# recidivism
2+
3+
Utilities for downloading OSV data, enriching vulnerabilities with a recidivism
4+
metric, and cloning referenced source repositories locally.
5+
6+
## Configuration
7+
8+
Copy the default config and edit your local paths:
9+
10+
```bash
11+
cp recidivism.default.ini recidivism.ini
12+
```
13+
14+
Both scripts read settings from `recidivism.ini`. If that file is missing, the
15+
scripts print guidance and fall back to `recidivism.default.ini`.
16+
17+
## Scripts
18+
19+
### 1) Download + enrich OSV vulnerabilities
20+
21+
```bash
22+
python scripts/enrich_osv_recidivism.py \
23+
--output data/osv_recidivism.jsonl
24+
```
25+
26+
This script:
27+
- downloads the OSV dump (`OSV-all.zip` by default),
28+
- extracts all vulnerabilities,
29+
- computes a recidivism metric using CWE recurrence and repository/fix history,
30+
- appends recidivism details to each vulnerability and writes JSONL output.
31+
32+
### 2) Clone OSV referenced repositories
33+
34+
```bash
35+
python scripts/clone_osv_repositories.py \
36+
--osv-dir data/osv_dump \
37+
--target-dir data/repos \
38+
--update-existing
39+
```
40+
41+
This script scans OSV vulnerabilities for GitHub source references and
42+
clones/updates local copies for research workflows (organized as
43+
`<target-dir>/<owner>/<repo>`).

recidivism.default.ini

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[enrich]
2+
dump_url = https://osv-vulnerabilities.storage.googleapis.com/OSV-all.zip
3+
archive_path = data/OSV-all.zip
4+
extract_dir = data/osv_dump
5+
output = data/osv_recidivism.jsonl
6+
force_download = false
7+
force_extract = false
8+
9+
[clone]
10+
osv_dir = data/osv_dump
11+
target_dir = data/repos
12+
max_repos =
13+
update_existing = false

scripts/clone_osv_repositories.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import subprocess
4+
from pathlib import Path
5+
from urllib.parse import urlparse
6+
7+
from osv_common import extract_repo_urls, iter_vulnerability_files, load_vulnerability
8+
from recidivism_config import get_required_value, load_config_with_source, resolve_config_path
9+
10+
11+
def clone_or_update(repo_url: str, target_dir: Path, update_existing: bool) -> None:
12+
parsed = urlparse(repo_url)
13+
parts = [part for part in parsed.path.split("/") if part]
14+
if len(parts) < 2:
15+
print(f"Warning: skipping malformed repository URL: {repo_url}")
16+
return
17+
owner = parts[-2]
18+
repo_name = parts[-1][:-4] if parts[-1].endswith(".git") else parts[-1]
19+
destination = target_dir / owner / repo_name
20+
destination.parent.mkdir(parents=True, exist_ok=True)
21+
if destination.exists():
22+
if update_existing:
23+
result = subprocess.run(
24+
["git", "-C", str(destination), "pull", "--ff-only"],
25+
check=False,
26+
capture_output=True,
27+
text=True,
28+
)
29+
if result.returncode != 0:
30+
stderr = result.stderr.strip() if result.stderr else f"git pull exited with code {result.returncode}"
31+
print(f"Warning: failed to update {destination} ({repo_url}): {stderr}")
32+
return
33+
34+
result = subprocess.run(
35+
["git", "clone", repo_url, str(destination)],
36+
check=False,
37+
capture_output=True,
38+
text=True,
39+
)
40+
if result.returncode != 0:
41+
stderr = result.stderr.strip() if result.stderr else f"git clone exited with code {result.returncode}"
42+
print(f"Warning: failed to clone {repo_url}: {stderr}")
43+
44+
45+
def main() -> None:
46+
config, config_source = load_config_with_source("clone")
47+
48+
parser = argparse.ArgumentParser(description="Clone all repositories referenced by OSV vulnerabilities.")
49+
parser.add_argument(
50+
"--osv-dir",
51+
help="Directory containing extracted OSV JSON files (overrides clone.osv_dir in recidivism.ini)",
52+
)
53+
parser.add_argument(
54+
"--target-dir",
55+
help="Directory to place local repository clones (overrides clone.target_dir in recidivism.ini)",
56+
)
57+
parser.add_argument(
58+
"--max-repos",
59+
type=int,
60+
default=None,
61+
help="Optional limit for number of repositories",
62+
)
63+
parser.add_argument(
64+
"--update-existing",
65+
action=argparse.BooleanOptionalAction,
66+
default=config.getboolean("update_existing", fallback=False),
67+
help="Run git pull on existing clones",
68+
)
69+
args = parser.parse_args()
70+
71+
try:
72+
osv_dir = resolve_config_path(args.osv_dir or get_required_value(config, "clone", "osv_dir"))
73+
target_dir = resolve_config_path(args.target_dir or get_required_value(config, "clone", "target_dir"))
74+
except ValueError as error:
75+
parser.error(f"{error} (config: {config_source})")
76+
max_repos = args.max_repos
77+
if max_repos is None:
78+
max_repos_str = config.get("max_repos", fallback="").strip()
79+
if max_repos_str:
80+
try:
81+
max_repos = int(max_repos_str)
82+
except ValueError as error:
83+
parser.error(f"Invalid clone.max_repos value '{max_repos_str}' in {config_source}: {error}")
84+
target_dir.mkdir(parents=True, exist_ok=True)
85+
86+
repo_urls = set()
87+
for path in iter_vulnerability_files(osv_dir):
88+
vulnerability = load_vulnerability(path)
89+
repo_urls.update(extract_repo_urls(vulnerability))
90+
91+
ordered_repos = sorted(repo_urls)
92+
if max_repos is not None:
93+
ordered_repos = ordered_repos[:max_repos]
94+
95+
for repo_url in ordered_repos:
96+
clone_or_update(repo_url, target_dir, args.update_existing)
97+
98+
print(f"Processed {len(ordered_repos)} repositories into {target_dir}")
99+
100+
101+
if __name__ == "__main__":
102+
main()

scripts/enrich_osv_recidivism.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import json
4+
import shutil
5+
import tarfile
6+
import zipfile
7+
from pathlib import Path
8+
from urllib.request import urlretrieve
9+
10+
from osv_common import collect_history, iter_vulnerability_files, load_vulnerability, recidivism_for_vulnerability
11+
from recidivism_config import get_required_value, load_config_with_source, resolve_config_path
12+
13+
14+
def download_dump(url: str, destination: Path, force: bool) -> None:
15+
if destination.exists() and not force:
16+
return
17+
destination.parent.mkdir(parents=True, exist_ok=True)
18+
urlretrieve(url, destination)
19+
20+
21+
def extract_dump(archive: Path, extract_dir: Path, force: bool) -> None:
22+
if extract_dir.exists() and force:
23+
shutil.rmtree(extract_dir)
24+
extract_dir.mkdir(parents=True, exist_ok=True)
25+
26+
if archive.suffix == ".zip":
27+
with zipfile.ZipFile(archive, "r") as zf:
28+
zf.extractall(extract_dir)
29+
elif archive.name.endswith(".tar.gz") or archive.suffix == ".tgz":
30+
with tarfile.open(archive, "r:gz") as tf:
31+
tf.extractall(extract_dir)
32+
else:
33+
raise ValueError(f"Unsupported archive format: {archive}")
34+
35+
36+
def main() -> None:
37+
config, config_source = load_config_with_source("enrich")
38+
39+
parser = argparse.ArgumentParser(description="Download OSV dump and enrich with recidivism metrics.")
40+
parser.add_argument("--dump-url", help="Override enrich.dump_url from recidivism.ini")
41+
parser.add_argument("--archive-path", help="Override enrich.archive_path from recidivism.ini")
42+
parser.add_argument("--extract-dir", help="Override enrich.extract_dir from recidivism.ini")
43+
parser.add_argument("--output", help="Override enrich.output from recidivism.ini")
44+
parser.add_argument(
45+
"--force-download",
46+
action=argparse.BooleanOptionalAction,
47+
default=config.getboolean("force_download", fallback=False),
48+
)
49+
parser.add_argument(
50+
"--force-extract",
51+
action=argparse.BooleanOptionalAction,
52+
default=config.getboolean("force_extract", fallback=False),
53+
)
54+
args = parser.parse_args()
55+
56+
try:
57+
dump_url = args.dump_url or get_required_value(config, "enrich", "dump_url")
58+
archive_path = resolve_config_path(args.archive_path or get_required_value(config, "enrich", "archive_path"))
59+
extract_dir = resolve_config_path(args.extract_dir or get_required_value(config, "enrich", "extract_dir"))
60+
output_path = resolve_config_path(args.output or get_required_value(config, "enrich", "output"))
61+
except ValueError as error:
62+
parser.error(f"{error} (config: {config_source})")
63+
64+
download_dump(dump_url, archive_path, args.force_download)
65+
extract_dump(archive_path, extract_dir, args.force_extract)
66+
67+
vulnerability_files = list(iter_vulnerability_files(extract_dir))
68+
cwe_counts, repo_counts = collect_history(load_vulnerability(path) for path in vulnerability_files)
69+
70+
output_path.parent.mkdir(parents=True, exist_ok=True)
71+
enriched_count = 0
72+
with output_path.open("w", encoding="utf-8") as handle:
73+
for path in vulnerability_files:
74+
vulnerability = load_vulnerability(path)
75+
metric = recidivism_for_vulnerability(vulnerability, cwe_counts, repo_counts)
76+
dbs = vulnerability.setdefault("database_specific", {})
77+
if "recidivism" in dbs:
78+
print(f"Overwriting existing recidivism metric for vulnerability {vulnerability.get('id', 'UNKNOWN')}")
79+
dbs["recidivism"] = metric
80+
81+
severity = [
82+
item
83+
for item in vulnerability.setdefault("severity", [])
84+
if item.get("type") not in {"RECIDIVISM", "RECIDIVISM_ADJUSTED"}
85+
]
86+
severity.append({"type": "RECIDIVISM", "score": f"{metric['score']:.2f}"})
87+
adjusted = metric["adjusted_severity_score"]
88+
if adjusted is not None:
89+
severity.append({"type": "RECIDIVISM_ADJUSTED", "score": f"{adjusted:.2f}"})
90+
vulnerability["severity"] = severity
91+
92+
handle.write(json.dumps(vulnerability, sort_keys=True))
93+
handle.write("\n")
94+
enriched_count += 1
95+
96+
print(f"Enriched {enriched_count} vulnerabilities -> {output_path}")
97+
98+
99+
if __name__ == "__main__":
100+
main()

0 commit comments

Comments
 (0)