Skip to content

Commit e02ffc6

Browse files
authored
feat(security): add vulnerability intelligence review (#77)
* feat(security): add vulnerability intelligence review * docs(changelog): note security intelligence review * fix(security): block intel client redirects * fix(security-review): harden intel client, sources, CLI, and scan pipeline - Wrap asyncio.run() calls in intel CLI commands with try/except; print error to stderr and exit 1 on failure (cve + package sub-commands) - Catch ValueError from malformed Content-Length header in IntelHttpClient and raise IntelClientError instead of leaking the raw exception - Skip score_cve() and set risk=None in lookup_cve_bundle when all four sources (NVD, EPSS, KEV, exploit) return None, preventing a synthetic LOW risk from being reported when no data is available - Validate NVD search_cves response is a dict with a list vulnerabilities field before building CVERecord instances; skip non-dict items - Validate KEV catalog response shape before building entries; raise on malformed payload so the fallback URL is tried instead of caching empty - Validate GitHub exploit response shape; return uncached NONE result when the response is not a dict with a list 'items' field; guard individual repo items with isinstance(repo, dict) - Run _msrc/_redhat/_ubuntu concurrently via asyncio.gather in get_vendor_advisory; add isinstance(entry/release, dict) guards in both helpers to skip malformed list items - Update _REQUIREMENT_RE and _BARE_REQUIREMENT_RE to accept package extras (e.g. requests[socks]==2.31.0) - Update _find_line to skip comment lines and use whole-word regex match to avoid false matches on comments or unrelated tokens - Compute project_info once before spawning batch workers in process_project to ensure identical prompt context across concurrent runs - Cap source_errors at 10 in markdown report; append "and N more errors" when truncated - Expand _DEP_LINE_RE to cover >=, <=, !=, ~=, >, < operators, three-part semver, and JSON-style pinned entries - Extend redirect-handler test to also assert IntelHttpClient wires _NoRedirectHandler into its opener - Add docstring to parse_dependency_manifests noting root-only limitation - Add inline comment to README intel cve example * fix(security-review): fix ruff E501/B904 and strip extras from requirement names - Break long asyncio.run() call lines in intel_cve and intel_package to stay under the 100-char limit (E501) - Add `from exc` to all three bare `raise` statements inside except blocks in cli/security_scan.py and client.py (B904) - Strip package extras (e.g. requests[socks]) from the name in _parse_requirements before constructing PackageRef so OSV lookups receive a plain package name; bare requirements use the same stripping - Add test asserting requests[socks]==2.31.0 and urllib3[secure] parse to PackageRef.name without brackets
1 parent dadbbbf commit e02ffc6

28 files changed

Lines changed: 1877 additions & 12 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Security review vulnerability intelligence.** `pythinker security-scan` can now parse dependency manifests, query OSV package advisories, look up CVE intelligence from NVD/EPSS/CISA KEV/GitHub/vendor feeds, and carry those leads into security-review prompts and reports as evidence-checked context.
19+
1820
## 0.34.0 (2026-06-03)
1921

2022
### What changed in this release

packages/pythinker-review/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pythinker-secscan diff --format sarif --fail-on critical
5555
pythinker-security-scan init --root .
5656
pythinker-security-scan scan --json
5757
pythinker-security-scan process --limit 10
58+
pythinker-security-scan deps scan --json # OSV dependency intelligence, cached locally
59+
pythinker-security-scan intel cve CVE-2024-3094 # fetches NVD/EPSS/KEV/PoC/vendor details for the CVE
5860
pythinker-security-scan report --write
5961

6062
# Root-cause debugger over a captured failure log
@@ -114,9 +116,11 @@ Phase 1 now ports the highest-value behavior from the mounted blackbox repos:
114116
- Code-reviewr PR assistant parity adds read-only `describe`, `improve`/`suggest`, `ask`,
115117
`labels`, `changelog`, and `docs` artifact commands with strict JSON schemas.
116118
- Pythinker Security Scan deterministic signals include CWE/severity hints, expanded vulnerability anchors,
117-
technology detection, and batch-scoped security advisor context.
119+
CVE/dependency-change leads, technology detection, and batch-scoped security advisor context.
118120
- Python-native Pythinker Security Scan repo-wide commands (`pythinker-security-scan` / `pythinker security-scan`) port the
119-
scan/process/revalidate/triage/report/export/status workflow without Node or pnpm runtime glue.
121+
scan/process/revalidate/triage/report/export/status workflow without Node or pnpm runtime glue, plus
122+
first-class vulnerability intelligence commands for OSV dependency scans and CVE enrichment from NVD,
123+
EPSS, CISA KEV, GitHub PoC metadata, and vendor advisory feeds.
120124
- Fenced/prose-wrapped JSON is cleaned safely, while truly malformed output remains fail-closed.
121125

122126
## Phase 1

packages/pythinker-review/src/pythinker_review/cli/security_scan.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313

1414
from pythinker_review.llm.fake import FakeReviewLLM
1515
from pythinker_review.llm.protocol import ReviewLLM
16+
from pythinker_review.security_intel.models import PackageRef
17+
from pythinker_review.security_intel.service import lookup_cve_bundle, lookup_package
18+
from pythinker_review.security_scan.dependencies import (
19+
parse_dependency_manifests,
20+
read_dependency_report,
21+
scan_project_dependencies,
22+
)
1623
from pythinker_review.security_scan.matchers import create_default_registry
1724
from pythinker_review.security_scan.paths import DEFAULT_STATE_DIR, get_data_root
1825
from pythinker_review.security_scan.processor import (
@@ -42,6 +49,14 @@
4249
from pythinker_review.security_scan.tech import detect_tech, read_tech_json, write_tech_json
4350

4451
app = typer.Typer(add_completion=False, no_args_is_help=True)
52+
deps_app = typer.Typer(
53+
add_completion=False, no_args_is_help=True, help="Dependency vulnerability intelligence."
54+
)
55+
intel_app = typer.Typer(
56+
add_completion=False, no_args_is_help=True, help="CVE and package intelligence lookups."
57+
)
58+
app.add_typer(deps_app, name="deps")
59+
app.add_typer(intel_app, name="intel")
4560

4661

4762
def _resolve_llm() -> ReviewLLM:
@@ -74,6 +89,110 @@ def _project_id(root: Path, project_id: str | None) -> str:
7489
return "project" if name in {"", "/"} else name.replace(" ", "-")
7590

7691

92+
@deps_app.command("list")
93+
def deps_list(
94+
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),
95+
json_output: bool = typer.Option(False, "--json"),
96+
) -> None:
97+
"""List dependency manifest entries Pythinker can enrich via OSV."""
98+
packages = parse_dependency_manifests(root.resolve())
99+
if json_output:
100+
typer.echo(json.dumps([pkg.model_dump(exclude_none=True) for pkg in packages], indent=2))
101+
return
102+
if not packages:
103+
typer.echo("No supported dependency manifests found.")
104+
return
105+
for pkg in packages:
106+
loc = f" ({pkg.manifest_path}:{pkg.line})" if pkg.manifest_path and pkg.line else ""
107+
typer.echo(f"{pkg.ecosystem}/{pkg.name} {pkg.version or '(unversioned)'}{loc}")
108+
109+
110+
@deps_app.command("scan")
111+
def deps_scan(
112+
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),
113+
project_id: str | None = typer.Option(None, "--project-id"),
114+
state_dir: str = typer.Option(DEFAULT_STATE_DIR, "--state-dir"),
115+
json_output: bool = typer.Option(False, "--json"),
116+
) -> None:
117+
"""Scan dependency manifests with OSV and store dependency intelligence."""
118+
root = root.resolve()
119+
pid = _project_id(root, project_id)
120+
report = asyncio.run(
121+
scan_project_dependencies(project_id=pid, root=root, data_root=_data_root(root, state_dir))
122+
)
123+
payload = report.model_dump(by_alias=True)
124+
if json_output:
125+
typer.echo(json.dumps(payload, indent=2))
126+
return
127+
typer.echo(
128+
f"Dependency scan complete: {report.package_count} packages, "
129+
f"{report.vulnerable_count} vulnerable dependencies"
130+
)
131+
for item in report.dependencies:
132+
vulns = ", ".join(v.id for v in item.vulns[:3])
133+
typer.echo(f"- {item.package.ecosystem}/{item.package.name}: {vulns}")
134+
for error in report.source_errors:
135+
typer.secho(error, fg=typer.colors.YELLOW, err=True)
136+
137+
138+
@deps_app.command("report")
139+
def deps_report(
140+
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),
141+
project_id: str | None = typer.Option(None, "--project-id"),
142+
state_dir: str = typer.Option(DEFAULT_STATE_DIR, "--state-dir"),
143+
) -> None:
144+
"""Print the stored dependency-intelligence report."""
145+
root = root.resolve()
146+
report = read_dependency_report(
147+
_project_id(root, project_id), data_root=_data_root(root, state_dir)
148+
)
149+
if report is None:
150+
typer.secho(
151+
"No dependency report found. Run `pythinker security-scan deps scan` first.",
152+
fg=typer.colors.YELLOW,
153+
err=True,
154+
)
155+
raise typer.Exit(code=2)
156+
typer.echo(report.model_dump_json(by_alias=True, indent=2))
157+
158+
159+
@intel_app.command("cve")
160+
def intel_cve(
161+
cve_id: str = typer.Argument(...),
162+
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),
163+
state_dir: str = typer.Option(DEFAULT_STATE_DIR, "--state-dir"),
164+
) -> None:
165+
"""Look up CVE intelligence from NVD, EPSS, KEV, GitHub PoC, and vendor feeds."""
166+
try:
167+
bundle = asyncio.run(
168+
lookup_cve_bundle(cve_id, data_root=_data_root(root.resolve(), state_dir))
169+
)
170+
except Exception as exc:
171+
typer.echo(f"Error: {exc}", err=True)
172+
raise typer.Exit(1) from exc
173+
typer.echo(bundle.model_dump_json(exclude_none=True, indent=2))
174+
175+
176+
@intel_app.command("package")
177+
def intel_package(
178+
name: str = typer.Argument(...),
179+
ecosystem: str = typer.Option(..., "--ecosystem"),
180+
version: str = typer.Option("", "--version"),
181+
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),
182+
state_dir: str = typer.Option(DEFAULT_STATE_DIR, "--state-dir"),
183+
) -> None:
184+
"""Look up package vulnerability intelligence via OSV."""
185+
package = PackageRef(name=name, ecosystem=ecosystem, version=version)
186+
try:
187+
result = asyncio.run(
188+
lookup_package(package, data_root=_data_root(root.resolve(), state_dir))
189+
)
190+
except Exception as exc:
191+
typer.echo(f"Error: {exc}", err=True)
192+
raise typer.Exit(1) from exc
193+
typer.echo(result.model_dump_json(indent=2))
194+
195+
77196
@app.command()
78197
def init(
79198
root: Path = typer.Option(Path.cwd(), "--root", "--repo", exists=True, file_okay=False),

packages/pythinker-review/src/pythinker_review/reviewers/prompts/security_review.system.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@ You are a world-class static security reviewer.
22

33
Rules:
44
- Review only security issues introduced or made reachable by this diff.
5-
- Deterministic signals and Pythinker Security Scan tech/slug notes are starting points; verify them in code before emitting a finding.
5+
- Deterministic signals, vulnerability-intelligence leads, and Pythinker Security Scan tech/slug notes are starting points; verify them in code before emitting a finding.
66
- Think like an attacker: trace sources, sinks, mitigations, imports, auth boundaries, tenant boundaries, and abuse controls.
7-
- Static analysis only. Do not ask to run the target code, send requests, or exploit anything.
7+
- Static analysis only. Do not ask to run the target code, send requests, exploit anything, clone PoC repositories, or probe targets.
88
- Prefer no finding over unvalidated speculation. If fully mitigated, return no finding.
99
- For auth checks, only handler-local middleware/guards/decorators or directly wrapped route checks count as strong evidence. Edge/proxy/WAF rules are not sufficient on their own.
1010
- Anchor findings to post-change lines where possible.
1111
- Use category security, secret, dependency, or correctness only when justified.
1212
- Include `evidence_snippet` when possible; it must quote code visible in the diff/context.
1313
- Include `exploitability`, `confidence_reason`, and `minimum_fix_scope` when useful.
14+
- CVE/OSV/EPSS/KEV/PoC intelligence can raise urgency, but it is not proof by itself. For dependency findings, require changed manifest/lockfile evidence for the affected package and version.
1415
- Output strict JSON only.
1516

1617
Severity guide:
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Public vulnerability-intelligence helpers for Pythinker security review.
2+
3+
This package is Python-native and intentionally independent of the blackbox MCP server runtime.
4+
"""
5+
6+
from pythinker_review.security_intel.models import CVEIntelBundle, DependencyIntel, RiskScore
7+
8+
__all__ = ["CVEIntelBundle", "DependencyIntel", "RiskScore"]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Small JSON TTL cache for public security-intelligence responses."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import time
7+
from pathlib import Path
8+
from typing import Any
9+
10+
TTL_CVE = 14_400
11+
TTL_SEARCH = 600
12+
TTL_OSV = 1_800
13+
TTL_EPSS = 3_600
14+
TTL_KEV = 21_600
15+
TTL_EXPLOIT = 3_600
16+
TTL_VENDOR = 14_400
17+
TTL_ATTACK = 86_400
18+
19+
_MAX_ENTRIES = 10_000
20+
21+
22+
class IntelCache:
23+
def __init__(self, root: Path) -> None:
24+
self.root = root
25+
self.path = root / "cache.json"
26+
self._data: dict[str, dict[str, Any]] | None = None
27+
28+
def get(self, key: str) -> Any | None:
29+
data = self._load()
30+
entry = data.get(key)
31+
if not entry:
32+
return None
33+
if float(entry.get("expires_at", 0)) < time.time():
34+
data.pop(key, None)
35+
self._save(data)
36+
return None
37+
return entry.get("value")
38+
39+
def set(self, key: str, value: Any, ttl: int) -> None:
40+
data = self._load()
41+
if len(data) >= _MAX_ENTRIES:
42+
# Keep the entries that expire latest; this is deterministic and cheap for our size cap.
43+
survivors = sorted(data.items(), key=lambda item: item[1].get("expires_at", 0))[
44+
-(_MAX_ENTRIES - 1) :
45+
]
46+
data = dict(survivors)
47+
data[key] = {"value": value, "expires_at": time.time() + ttl}
48+
self._save(data)
49+
50+
def _load(self) -> dict[str, dict[str, Any]]:
51+
if self._data is not None:
52+
return self._data
53+
try:
54+
raw = json.loads(self.path.read_text(encoding="utf-8"))
55+
except (FileNotFoundError, OSError, json.JSONDecodeError):
56+
raw = {}
57+
self._data = raw if isinstance(raw, dict) else {}
58+
return self._data
59+
60+
def _save(self, data: dict[str, dict[str, Any]]) -> None:
61+
self.root.mkdir(parents=True, exist_ok=True)
62+
tmp = self.path.with_suffix(".tmp")
63+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
64+
tmp.replace(self.path)
65+
self._data = data

0 commit comments

Comments
 (0)