Coverage for tests / unit / tools / osv_scanner / test_osv_scanner_plugin.py: 100%

134 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for OSV-Scanner plugin.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import os 

7import subprocess 

8from pathlib import Path 

9from typing import cast 

10from unittest.mock import patch 

11 

12import pytest 

13from assertpy import assert_that 

14 

15from lintro.enums.severity_level import SeverityLevel 

16from lintro.enums.tool_type import ToolType 

17from lintro.parsers.osv_scanner.osv_scanner_issue import OsvScannerIssue 

18from lintro.tools.definitions.osv_scanner import ( 

19 OSV_SCANNER_DEFAULT_TIMEOUT, 

20 OsvScannerPlugin, 

21) 

22 

23# ============================================================================= 

24# Tests for definition 

25# ============================================================================= 

26 

27 

28def test_definition_name(osv_scanner_plugin: OsvScannerPlugin) -> None: 

29 """Definition has correct name.""" 

30 assert_that(osv_scanner_plugin.definition.name).is_equal_to("osv_scanner") 

31 

32 

33def test_definition_type(osv_scanner_plugin: OsvScannerPlugin) -> None: 

34 """Definition has correct tool type.""" 

35 assert_that(osv_scanner_plugin.definition.tool_type).is_equal_to(ToolType.SECURITY) 

36 

37 

38def test_definition_cannot_fix(osv_scanner_plugin: OsvScannerPlugin) -> None: 

39 """Definition reports no fix support.""" 

40 assert_that(osv_scanner_plugin.definition.can_fix).is_false() 

41 

42 

43def test_default_timeout(osv_scanner_plugin: OsvScannerPlugin) -> None: 

44 """Default timeout has correct value.""" 

45 assert_that(osv_scanner_plugin.options.get("timeout")).is_equal_to( 

46 OSV_SCANNER_DEFAULT_TIMEOUT, 

47 ) 

48 

49 

50# ============================================================================= 

51# Tests for set_options validation 

52# ============================================================================= 

53 

54 

55def test_set_options_validates_timeout_type( 

56 osv_scanner_plugin: OsvScannerPlugin, 

57) -> None: 

58 """set_options rejects non-integer timeout.""" 

59 with pytest.raises(ValueError, match="timeout must be an integer"): 

60 osv_scanner_plugin.set_options(timeout="fast") 

61 

62 

63def test_set_options_validates_timeout_negative( 

64 osv_scanner_plugin: OsvScannerPlugin, 

65) -> None: 

66 """set_options rejects negative timeout.""" 

67 with pytest.raises(ValueError, match="timeout must be positive"): 

68 osv_scanner_plugin.set_options(timeout=-1) 

69 

70 

71def test_set_options_validates_timeout_zero( 

72 osv_scanner_plugin: OsvScannerPlugin, 

73) -> None: 

74 """set_options rejects zero timeout.""" 

75 with pytest.raises(ValueError, match="timeout must be positive"): 

76 osv_scanner_plugin.set_options(timeout=0) 

77 

78 

79def test_set_options_validates_timeout_bool( 

80 osv_scanner_plugin: OsvScannerPlugin, 

81) -> None: 

82 """set_options rejects boolean timeout.""" 

83 with pytest.raises(ValueError, match="timeout must be an integer"): 

84 osv_scanner_plugin.set_options(timeout=True) 

85 

86 

87# ============================================================================= 

88# Tests for check method 

89# ============================================================================= 

90 

91 

92def test_check_no_vulnerabilities( 

93 osv_scanner_plugin: OsvScannerPlugin, 

94 tmp_path: Path, 

95) -> None: 

96 """Check returns success when no vulnerabilities found. 

97 

98 Args: 

99 osv_scanner_plugin: The OsvScannerPlugin instance to test. 

100 tmp_path: Temporary directory path for test files. 

101 """ 

102 lockfile = tmp_path / "requirements.txt" 

103 lockfile.write_text("requests==2.32.3\n") 

104 

105 with patch.object( 

106 osv_scanner_plugin, 

107 "_run_subprocess", 

108 return_value=(True, ""), 

109 ): 

110 result = osv_scanner_plugin.check([str(lockfile)], {}) 

111 

112 assert_that(result.success).is_true() 

113 assert_that(result.issues_count).is_equal_to(0) 

114 

115 

116def test_check_with_vulnerabilities( 

117 osv_scanner_plugin: OsvScannerPlugin, 

118 tmp_path: Path, 

119) -> None: 

120 """Check returns issues when vulnerabilities found. 

121 

122 Args: 

123 osv_scanner_plugin: The OsvScannerPlugin instance to test. 

124 tmp_path: Temporary directory path for test files. 

125 """ 

126 lockfile = tmp_path / "requirements.txt" 

127 lockfile.write_text("requests==2.25.0\n") 

128 

129 osv_output = json.dumps( 

130 { 

131 "results": [ 

132 { 

133 "source": {"path": str(lockfile)}, 

134 "packages": [ 

135 { 

136 "package": { 

137 "name": "requests", 

138 "version": "2.25.0", 

139 "ecosystem": "PyPI", 

140 }, 

141 "groups": [ 

142 { 

143 "ids": ["GHSA-9wx4-h78v-vm56"], 

144 "max_severity": "HIGH", 

145 }, 

146 ], 

147 "vulnerabilities": [ 

148 { 

149 "id": "GHSA-9wx4-h78v-vm56", 

150 "summary": "Session verify bypass", 

151 "affected": [ 

152 { 

153 "package": { 

154 "name": "requests", 

155 "ecosystem": "PyPI", 

156 }, 

157 "ranges": [ 

158 { 

159 "type": "ECOSYSTEM", 

160 "events": [ 

161 {"introduced": "0"}, 

162 {"fixed": "2.32.0"}, 

163 ], 

164 }, 

165 ], 

166 }, 

167 ], 

168 }, 

169 ], 

170 }, 

171 ], 

172 }, 

173 ], 

174 }, 

175 ) 

176 

177 with patch.object( 

178 osv_scanner_plugin, 

179 "_run_subprocess", 

180 return_value=(False, osv_output), 

181 ): 

182 result = osv_scanner_plugin.check([str(lockfile)], {}) 

183 

184 assert_that(result.success).is_false() 

185 assert_that(result.issues_count).is_equal_to(1) 

186 assert_that(result.issues).is_not_none() 

187 issues = cast(list[OsvScannerIssue], result.issues) 

188 assert_that(issues[0].vuln_id).is_equal_to("GHSA-9wx4-h78v-vm56") 

189 assert_that(issues[0].package_name).is_equal_to("requests") 

190 assert_that(issues[0].severity).is_equal_to("HIGH") 

191 assert_that(issues[0].fixed_version).is_equal_to("2.32.0") 

192 

193 

194def test_check_timeout( 

195 osv_scanner_plugin: OsvScannerPlugin, 

196 tmp_path: Path, 

197) -> None: 

198 """Check handles timeout correctly. 

199 

200 Args: 

201 osv_scanner_plugin: The OsvScannerPlugin instance to test. 

202 tmp_path: Temporary directory path for test files. 

203 """ 

204 lockfile = tmp_path / "requirements.txt" 

205 lockfile.write_text("requests==2.25.0\n") 

206 

207 with patch.object( 

208 osv_scanner_plugin, 

209 "_run_subprocess", 

210 side_effect=subprocess.TimeoutExpired( 

211 cmd=["osv-scanner"], 

212 timeout=120, 

213 ), 

214 ): 

215 result = osv_scanner_plugin.check([str(lockfile)], {}) 

216 

217 assert_that(result.success).is_false() 

218 assert_that(result.output).contains("timed out") 

219 

220 

221def test_check_empty_paths( 

222 osv_scanner_plugin: OsvScannerPlugin, 

223) -> None: 

224 """Check returns early when given no paths. 

225 

226 Args: 

227 osv_scanner_plugin: The OsvScannerPlugin instance to test. 

228 """ 

229 result = osv_scanner_plugin.check([], {}) 

230 

231 assert_that(result.success).is_true() 

232 assert_that(result.issues_count).is_equal_to(0) 

233 

234 

235# ============================================================================= 

236# Tests for fix method 

237# ============================================================================= 

238 

239 

240def test_fix_raises_not_implemented(osv_scanner_plugin: OsvScannerPlugin) -> None: 

241 """Fix method raises NotImplementedError. 

242 

243 Args: 

244 osv_scanner_plugin: The OsvScannerPlugin instance to test. 

245 """ 

246 with pytest.raises(NotImplementedError, match="cannot automatically fix"): 

247 osv_scanner_plugin.fix(["src/"], {}) 

248 

249 

250# ============================================================================= 

251# Tests for OsvScannerIssue DEFAULT_SEVERITY 

252# ============================================================================= 

253 

254 

255def test_issue_default_severity_is_error() -> None: 

256 """OsvScannerIssue falls back to ERROR severity when severity is empty.""" 

257 issue = OsvScannerIssue( 

258 vuln_id="GHSA-test-1234", 

259 severity="", 

260 package_name="foo", 

261 package_version="1.0.0", 

262 ) 

263 assert_that(issue.get_severity()).is_equal_to(SeverityLevel.ERROR) 

264 

265 

266# ============================================================================= 

267# Tests for suppression staleness detection 

268# ============================================================================= 

269 

270 

271def test_check_with_suppressions_detects_stale( 

272 osv_scanner_plugin: OsvScannerPlugin, 

273 tmp_path: Path, 

274) -> None: 

275 """Check classifies suppressions when .osv-scanner.toml exists.""" 

276 lockfile = tmp_path / "requirements.txt" 

277 lockfile.write_text("requests==2.32.3\n") 

278 

279 # Create a config with one suppression 

280 config = tmp_path / ".osv-scanner.toml" 

281 config.write_text( 

282 "[[IgnoredVulns]]\n" 

283 'id = "GHSA-stale-1234"\n' 

284 "ignoreUntil = 2027-12-31\n" 

285 'reason = "Test suppression"\n', 

286 ) 

287 

288 # Gating scan: no issues (vuln is suppressed) 

289 # Probe scan: also no issues (vuln was fixed upstream → stale) 

290 with patch.object( 

291 osv_scanner_plugin, 

292 "_run_subprocess", 

293 side_effect=[ 

294 (True, ""), # gating scan 

295 (True, ""), # probe scan 

296 ], 

297 ): 

298 result = osv_scanner_plugin.check([str(lockfile)], {}) 

299 

300 assert_that(result.success).is_true() 

301 assert_that(result.ai_metadata).is_not_none() 

302 assert result.ai_metadata is not None # narrow type for mypy 

303 suppressions = result.ai_metadata["suppressions"] 

304 assert_that(suppressions).is_length(1) 

305 assert_that(suppressions[0]["id"]).is_equal_to("GHSA-stale-1234") 

306 assert_that(suppressions[0]["status"]).is_equal_to("stale") 

307 

308 

309def test_check_without_config_no_metadata( 

310 osv_scanner_plugin: OsvScannerPlugin, 

311 tmp_path: Path, 

312) -> None: 

313 """Check returns no ai_metadata when no .osv-scanner.toml exists.""" 

314 lockfile = tmp_path / "requirements.txt" 

315 lockfile.write_text("requests==2.32.3\n") 

316 

317 with patch.object( 

318 osv_scanner_plugin, 

319 "_run_subprocess", 

320 return_value=(True, ""), 

321 ): 

322 result = osv_scanner_plugin.check([str(lockfile)], {}) 

323 

324 assert_that(result.success).is_true() 

325 assert_that(result.ai_metadata).is_none() 

326 

327 

328def test_check_suppressions_disabled( 

329 osv_scanner_plugin: OsvScannerPlugin, 

330 tmp_path: Path, 

331) -> None: 

332 """No probe scan when check_suppressions is False.""" 

333 lockfile = tmp_path / "requirements.txt" 

334 lockfile.write_text("requests==2.32.3\n") 

335 

336 config = tmp_path / ".osv-scanner.toml" 

337 config.write_text( 

338 "[[IgnoredVulns]]\n" 

339 'id = "GHSA-1111-aaaa-bbbb"\n' 

340 "ignoreUntil = 2027-12-31\n" 

341 'reason = "Test"\n', 

342 ) 

343 

344 with patch.object( 

345 osv_scanner_plugin, 

346 "_run_subprocess", 

347 return_value=(True, ""), 

348 ) as mock_run: 

349 result = osv_scanner_plugin.check( 

350 [str(lockfile)], 

351 {"check_suppressions": False}, 

352 ) 

353 

354 # Only one subprocess call (gating scan, no probe) 

355 assert_that(mock_run.call_count).is_equal_to(1) 

356 assert_that(result.ai_metadata).is_none() 

357 

358 

359def test_check_suppressions_probe_timeout( 

360 osv_scanner_plugin: OsvScannerPlugin, 

361 tmp_path: Path, 

362) -> None: 

363 """Graceful fallback when probe scan times out.""" 

364 lockfile = tmp_path / "requirements.txt" 

365 lockfile.write_text("requests==2.32.3\n") 

366 

367 config = tmp_path / ".osv-scanner.toml" 

368 config.write_text( 

369 "[[IgnoredVulns]]\n" 

370 'id = "GHSA-1111-aaaa-bbbb"\n' 

371 "ignoreUntil = 2027-12-31\n" 

372 'reason = "Test"\n', 

373 ) 

374 

375 with patch.object( 

376 osv_scanner_plugin, 

377 "_run_subprocess", 

378 side_effect=[ 

379 (True, ""), # gating scan succeeds 

380 subprocess.TimeoutExpired(cmd=["osv-scanner"], timeout=120), # probe 

381 ], 

382 ): 

383 result = osv_scanner_plugin.check([str(lockfile)], {}) 

384 

385 assert_that(result.success).is_true() 

386 assert_that(result.ai_metadata).is_none() 

387 

388 

389def test_build_probe_command_internal( 

390 osv_scanner_plugin: OsvScannerPlugin, 

391 tmp_path: Path, 

392) -> None: 

393 """Probe command includes --recursive and --config with null device. 

394 

395 Tests the private _build_probe_command directly because exercising it 

396 through check() requires complex subprocess mocking with two sequential 

397 calls (gating scan + probe scan) that obscures the command structure 

398 being verified. 

399 """ 

400 cmd = osv_scanner_plugin._build_probe_command(tmp_path) 

401 

402 assert_that(cmd).contains("--recursive") 

403 assert_that(cmd).contains("--config") 

404 assert_that(cmd).contains(os.devnull) 

405 assert_that(cmd).contains(str(tmp_path)) 

406 

407 

408def test_find_config_file_in_scan_root(tmp_path: Path) -> None: 

409 """Finds .osv-scanner.toml in the scan root.""" 

410 config = tmp_path / ".osv-scanner.toml" 

411 config.write_text("") 

412 

413 result = OsvScannerPlugin._find_config_file(tmp_path) 

414 assert_that(result).is_equal_to(config) 

415 

416 

417def test_find_config_file_in_parent(tmp_path: Path) -> None: 

418 """Finds .osv-scanner.toml in a parent directory.""" 

419 config = tmp_path / ".osv-scanner.toml" 

420 config.write_text("") 

421 

422 child = tmp_path / "frontend" 

423 child.mkdir() 

424 

425 result = OsvScannerPlugin._find_config_file(child) 

426 assert_that(result).is_equal_to(config) 

427 

428 

429def test_find_config_file_not_found(tmp_path: Path) -> None: 

430 """Returns None when no .osv-scanner.toml exists.""" 

431 result = OsvScannerPlugin._find_config_file(tmp_path) 

432 assert_that(result).is_none() 

433 

434 

435def test_set_options_validates_check_suppressions( 

436 osv_scanner_plugin: OsvScannerPlugin, 

437) -> None: 

438 """set_options rejects non-boolean check_suppressions.""" 

439 with pytest.raises(ValueError, match="check_suppressions must be a boolean"): 

440 osv_scanner_plugin.set_options(check_suppressions="yes")