secure-code-agent 0.2.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.
Files changed (33) hide show
  1. secure_code_agent-0.2.0.dist-info/METADATA +328 -0
  2. secure_code_agent-0.2.0.dist-info/RECORD +33 -0
  3. secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
  4. secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
  5. secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
  7. secure_code_audit/__init__.py +3 -0
  8. secure_code_audit/baseline.py +110 -0
  9. secure_code_audit/cli.py +258 -0
  10. secure_code_audit/config.py +115 -0
  11. secure_code_audit/findings.py +165 -0
  12. secure_code_audit/git_tools.py +82 -0
  13. secure_code_audit/instructions.py +141 -0
  14. secure_code_audit/remediation.py +168 -0
  15. secure_code_audit/renderers.py +253 -0
  16. secure_code_audit/sarif.py +221 -0
  17. secure_code_audit/scanners/__init__.py +50 -0
  18. secure_code_audit/scanners/bandit_scanner.py +83 -0
  19. secure_code_audit/scanners/base.py +194 -0
  20. secure_code_audit/scanners/builtin_rules.py +183 -0
  21. secure_code_audit/scanners/checkov_scanner.py +69 -0
  22. secure_code_audit/scanners/gitleaks_scanner.py +86 -0
  23. secure_code_audit/scanners/hadolint_scanner.py +107 -0
  24. secure_code_audit/scanners/npm_audit_scanner.py +101 -0
  25. secure_code_audit/scanners/osv_scanner.py +108 -0
  26. secure_code_audit/scanners/pip_audit_scanner.py +83 -0
  27. secure_code_audit/scanners/scorecard_scanner.py +156 -0
  28. secure_code_audit/scanners/semgrep_scanner.py +119 -0
  29. secure_code_audit/scanners/trivy_scanner.py +87 -0
  30. secure_code_audit/scanners/trufflehog_scanner.py +91 -0
  31. secure_code_audit/scoring.py +280 -0
  32. secure_code_audit/standards.py +391 -0
  33. secure_code_audit/suppressions.py +175 -0
@@ -0,0 +1,221 @@
1
+ """SARIF 2.1.0 emit + ingest.
2
+
3
+ Spec: https://www.oasis-open.org/standard/sarif-v2-1-0/
4
+ SARIF JSON schema: https://github.com/oasis-tcs/sarif-spec/blob/main/sarif-2.1/schemas/sarif-schema-2.1.0.json
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Iterable
11
+
12
+ from secure_code_audit import __version__
13
+ from secure_code_audit.findings import Confidence, Finding, Severity
14
+ from secure_code_audit.standards import cwe_url
15
+
16
+ _SARIF_LEVEL = {
17
+ Severity.CRITICAL: "error",
18
+ Severity.HIGH: "error",
19
+ Severity.MEDIUM: "warning",
20
+ Severity.LOW: "note",
21
+ Severity.INFORMATIONAL: "none",
22
+ }
23
+
24
+
25
+ def emit(findings: Iterable[Finding]) -> dict:
26
+ """Build a SARIF 2.1.0 document from canonical findings."""
27
+ findings = list(findings)
28
+
29
+ # Build the rules array per-canonical-rule (rule_id is local; dedupe).
30
+ rule_meta: dict[str, dict] = {}
31
+ results: list[dict] = []
32
+
33
+ for f in findings:
34
+ rid = f.rule_id
35
+ if rid not in rule_meta:
36
+ rule_meta[rid] = _rule(f)
37
+ results.append(_result(f))
38
+
39
+ return {
40
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schemas/sarif-schema-2.1.0.json",
41
+ "version": "2.1.0",
42
+ "runs": [{
43
+ "tool": {
44
+ "driver": {
45
+ "name": "secure-code-agent",
46
+ "informationUri": "https://github.com/marshallguillory86/secure-code-agent",
47
+ "version": __version__,
48
+ "rules": list(rule_meta.values()),
49
+ }
50
+ },
51
+ "results": results,
52
+ }],
53
+ }
54
+
55
+
56
+ def _rule(f: Finding) -> dict:
57
+ rule: dict = {
58
+ "id": f.rule_id,
59
+ "name": f.rule_id,
60
+ "shortDescription": {"text": f.short_desc or f.message[:120]},
61
+ "defaultConfiguration": {"level": _SARIF_LEVEL[f.severity]},
62
+ "properties": {
63
+ "category": f.category.value,
64
+ "scanner": f.scanner,
65
+ "owasp_top10": f.owasp_top10,
66
+ "asvs": f.asvs_section,
67
+ "nist_ssdf": f.nist_ssdf,
68
+ "cwe_top25": f.cwe_top25,
69
+ },
70
+ }
71
+ if f.canonical_cwe:
72
+ rule["properties"]["cwe"] = [f.canonical_cwe]
73
+ rule["helpUri"] = cwe_url(f.canonical_cwe)
74
+ return rule
75
+
76
+
77
+ def _result(f: Finding) -> dict:
78
+ region: dict = {"startLine": max(1, f.line_start)}
79
+ if f.line_end and f.line_end != f.line_start:
80
+ region["endLine"] = f.line_end
81
+ if f.code_snippet:
82
+ region["snippet"] = {"text": f.code_snippet}
83
+
84
+ return {
85
+ "ruleId": f.rule_id,
86
+ "level": _SARIF_LEVEL[f.severity],
87
+ "message": {"text": f.message},
88
+ "fingerprints": {"secure-code-agent/v1": f.fingerprint},
89
+ "properties": {
90
+ "confidence": f.confidence.value,
91
+ "category": f.category.value,
92
+ "is_new": f.is_new,
93
+ "suppressed": f.suppressed,
94
+ },
95
+ "locations": [{
96
+ "physicalLocation": {
97
+ "artifactLocation": {"uri": f.file_path.as_posix()},
98
+ "region": region,
99
+ }
100
+ }],
101
+ }
102
+
103
+
104
+ def write(findings: Iterable[Finding], output: Path) -> None:
105
+ output.write_text(json.dumps(emit(findings), indent=2), encoding="utf-8")
106
+
107
+
108
+ # ----- ingest -------------------------------------------------------------
109
+
110
+ _SARIF_LEVEL_TO_SEVERITY: dict[str, Severity] = {
111
+ "error": Severity.HIGH,
112
+ "warning": Severity.MEDIUM,
113
+ "note": Severity.LOW,
114
+ "none": Severity.INFORMATIONAL,
115
+ }
116
+
117
+
118
+ def ingest(sarif_path: Path, default_scanner: str = "external_sarif") -> list[Finding]:
119
+ """Parse an external SARIF file (CodeQL, Snyk, Trivy, etc.) into canonical
120
+ Findings. We trust the SARIF's own rule metadata for severity + CWE;
121
+ standards.lookup() may still enhance via the local map."""
122
+ payload = _read_sarif(sarif_path)
123
+ if payload is None:
124
+ return []
125
+
126
+ out: list[Finding] = []
127
+ for run in payload.get("runs", []):
128
+ out.extend(_findings_from_run(run, default_scanner))
129
+ return out
130
+
131
+
132
+ def _read_sarif(path: Path) -> dict | None:
133
+ try:
134
+ return json.loads(path.read_text(encoding="utf-8"))
135
+ except (OSError, json.JSONDecodeError):
136
+ return None
137
+
138
+
139
+ def _findings_from_run(run: dict, default_scanner: str) -> list[Finding]:
140
+ driver = (run.get("tool") or {}).get("driver") or {}
141
+ scanner = (driver.get("name") or default_scanner).lower().replace(" ", "_")
142
+ rules = {r.get("id"): r for r in (driver.get("rules") or [])}
143
+
144
+ findings: list[Finding] = []
145
+ for result in run.get("results", []):
146
+ findings.append(_finding_from_result(result, rules, scanner))
147
+ return findings
148
+
149
+
150
+ def _finding_from_result(result: dict, rules: dict, scanner: str) -> Finding:
151
+ from secure_code_audit.findings import Category
152
+ from secure_code_audit.standards import is_top25, lookup
153
+
154
+ rule_id = result.get("ruleId") or "unknown"
155
+ rule = rules.get(rule_id, {})
156
+ msg = (result.get("message") or {}).get("text") or rule_id
157
+
158
+ severity = _severity_from_result(result, rule)
159
+ cwe = _cwe_from_rule(rule)
160
+ file_path, line_start, line_end, snippet = _location_from_result(result)
161
+
162
+ entry = lookup(scanner, rule_id)
163
+ canonical_cwe = cwe or (entry.canonical_cwe if entry else None)
164
+
165
+ fingerprint = Finding.make_fingerprint(
166
+ canonical_cwe=canonical_cwe,
167
+ rule_id=rule_id,
168
+ file_path=file_path,
169
+ code_snippet=snippet,
170
+ )
171
+ return Finding(
172
+ rule_id=rule_id,
173
+ scanner=scanner,
174
+ fingerprint=fingerprint,
175
+ canonical_cwe=canonical_cwe,
176
+ owasp_top10=entry.owasp_top10 if entry else None,
177
+ asvs_section=entry.asvs_section if entry else None,
178
+ nist_ssdf=entry.nist_ssdf if entry else None,
179
+ category=entry.category if entry else Category.CODE_VULNERABILITIES,
180
+ severity=severity,
181
+ confidence=Confidence.MEDIUM,
182
+ file_path=file_path,
183
+ line_start=line_start,
184
+ line_end=line_end if line_end and line_end != line_start else None,
185
+ code_snippet=snippet,
186
+ message=msg,
187
+ short_desc=entry.short_desc if entry else None,
188
+ fix_hint=entry.fix_hint if entry else None,
189
+ cwe_top25=is_top25(canonical_cwe),
190
+ )
191
+
192
+
193
+ def _severity_from_result(result: dict, rule: dict) -> Severity:
194
+ level = (
195
+ result.get("level")
196
+ or rule.get("defaultConfiguration", {}).get("level")
197
+ or "warning"
198
+ )
199
+ return _SARIF_LEVEL_TO_SEVERITY.get(level, Severity.MEDIUM)
200
+
201
+
202
+ def _cwe_from_rule(rule: dict) -> str | None:
203
+ props = rule.get("properties") or {}
204
+ cwe = props.get("cwe")
205
+ if isinstance(cwe, list) and cwe:
206
+ return cwe[0]
207
+ if isinstance(cwe, str):
208
+ return cwe
209
+ return None
210
+
211
+
212
+ def _location_from_result(result: dict) -> tuple[Path, int, int | None, str | None]:
213
+ locs = result.get("locations") or [{}]
214
+ phys = (locs[0].get("physicalLocation") or {}) if locs else {}
215
+ file_uri = (phys.get("artifactLocation") or {}).get("uri") or ""
216
+ region = phys.get("region") or {}
217
+ line_start = int(region.get("startLine") or 0)
218
+ line_end = int(region.get("endLine") or line_start) if region else None
219
+ snippet = (region.get("snippet") or {}).get("text")
220
+ file_path = Path(file_uri) if file_uri else Path(".")
221
+ return file_path, line_start, line_end, snippet
@@ -0,0 +1,50 @@
1
+ """Scanner subprocesses. Each module is a single-purpose adapter:
2
+ in: scanner invocation + target path
3
+ out: list[Finding] in the canonical schema (see secure_code_audit.findings).
4
+
5
+ The registry below is the lookup the CLI uses to enumerate active scanners.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Callable, Iterable
10
+
11
+ from secure_code_audit.findings import Finding
12
+
13
+ from secure_code_audit.scanners.base import Scanner
14
+ from secure_code_audit.scanners.bandit_scanner import BanditScanner
15
+ from secure_code_audit.scanners.builtin_rules import BuiltinRulesScanner
16
+ from secure_code_audit.scanners.checkov_scanner import CheckovScanner
17
+ from secure_code_audit.scanners.gitleaks_scanner import GitleaksScanner
18
+ from secure_code_audit.scanners.hadolint_scanner import HadolintScanner
19
+ from secure_code_audit.scanners.npm_audit_scanner import NpmAuditScanner
20
+ from secure_code_audit.scanners.osv_scanner import OsvScanner
21
+ from secure_code_audit.scanners.pip_audit_scanner import PipAuditScanner
22
+ from secure_code_audit.scanners.scorecard_scanner import ScorecardScanner
23
+ from secure_code_audit.scanners.semgrep_scanner import SemgrepScanner
24
+ from secure_code_audit.scanners.trivy_scanner import TrivyScanner
25
+ from secure_code_audit.scanners.trufflehog_scanner import TruffleHogScanner
26
+
27
+ SCANNERS: dict[str, type[Scanner]] = {
28
+ # Tier-1 — Python / Node / secrets / SAST
29
+ "bandit": BanditScanner,
30
+ "builtin_rules": BuiltinRulesScanner,
31
+ "gitleaks": GitleaksScanner,
32
+ "npm_audit": NpmAuditScanner,
33
+ "pip_audit": PipAuditScanner,
34
+ "semgrep": SemgrepScanner,
35
+ # Tier-2 — IaC / containers / supply chain / multi-ecosystem SCA / verified secrets
36
+ "checkov": CheckovScanner,
37
+ "hadolint": HadolintScanner,
38
+ "osv_scanner": OsvScanner,
39
+ "scorecard": ScorecardScanner,
40
+ "trivy": TrivyScanner,
41
+ "trufflehog": TruffleHogScanner,
42
+ }
43
+
44
+
45
+ def all_scanner_names() -> list[str]:
46
+ return list(SCANNERS.keys())
47
+
48
+
49
+ def get(name: str) -> type[Scanner] | None:
50
+ return SCANNERS.get(name)
@@ -0,0 +1,83 @@
1
+ """Bandit — Python SAST.
2
+
3
+ Invocation:
4
+ bandit -r <target> -f json -ll -ii [--exclude <pattern>]
5
+
6
+ Output: JSON with results[] array. Each result has filename, line_number,
7
+ test_id (B102, B608, ...), issue_text, issue_severity, issue_confidence,
8
+ and code (the offending snippet).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from secure_code_audit.config import Config
16
+ from secure_code_audit.findings import Confidence, Finding, Severity
17
+ from secure_code_audit.scanners.base import Scanner
18
+
19
+
20
+ class BanditScanner(Scanner):
21
+ name = "bandit"
22
+ binary = "bandit"
23
+
24
+ def run(self, target: Path, config: Config) -> list[Finding]:
25
+ if not self.is_available():
26
+ return [self._unavailable_finding(target)]
27
+
28
+ sc_cfg = self.cfg(config)
29
+ args = [
30
+ self.binary, "-r", str(target),
31
+ "-f", "json",
32
+ "--severity-level", "low",
33
+ "--confidence-level", "low",
34
+ ]
35
+ for pat in config.exclude_patterns:
36
+ args.extend(["--exclude", pat])
37
+ args.extend(sc_cfg.extra_args)
38
+
39
+ # Bandit exits nonzero on findings — accept 0 + 1.
40
+ r = self._exec(args, cwd=target, timeout_seconds=sc_cfg.timeout_seconds,
41
+ allowed_exits=(0, 1))
42
+ if r.returncode == 124:
43
+ return [self._timeout_finding(target, r.stderr)]
44
+ if not r.stdout.strip():
45
+ return [self._error_finding(target, "bandit emitted no output")]
46
+
47
+ try:
48
+ payload = json.loads(r.stdout)
49
+ except json.JSONDecodeError as e:
50
+ return [self._error_finding(target, f"bandit JSON parse failure: {e}")]
51
+
52
+ findings: list[Finding] = []
53
+ for result in payload.get("results", []):
54
+ rule_id = result.get("test_id") or result.get("test_name") or "unknown"
55
+ findings.append(self._make_finding(
56
+ rule_id=rule_id,
57
+ message=str(result.get("issue_text") or "").strip(),
58
+ file_path=Path(result.get("filename", "")),
59
+ line_start=int(result.get("line_number") or 0),
60
+ line_end=int(result.get("line_range", [0])[-1] or 0)
61
+ if isinstance(result.get("line_range"), list)
62
+ else None,
63
+ code_snippet=str(result.get("code") or "").strip() or None,
64
+ severity=Severity.from_string(result.get("issue_severity", "")),
65
+ confidence=Confidence.from_string(result.get("issue_confidence", "")),
66
+ ))
67
+ return findings
68
+
69
+ def _timeout_finding(self, target: Path, stderr: str) -> Finding:
70
+ return self._make_finding(
71
+ rule_id=f"{self.name}.tool_timeout",
72
+ message=f"bandit timed out: {stderr}",
73
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
74
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
75
+ )
76
+
77
+ def _error_finding(self, target: Path, message: str) -> Finding:
78
+ return self._make_finding(
79
+ rule_id=f"{self.name}.tool_error",
80
+ message=message,
81
+ file_path=target, line_start=0, line_end=None, code_snippet=None,
82
+ severity=Severity.INFORMATIONAL, confidence=Confidence.HIGH,
83
+ )
@@ -0,0 +1,194 @@
1
+ """Scanner protocol + subprocess helpers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from abc import ABC, abstractmethod
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from secure_code_audit.config import Config, scanner_cfg
12
+ from secure_code_audit.findings import Category, Confidence, Finding, Severity
13
+ from secure_code_audit.standards import StandardsEntry, is_top25, lookup
14
+
15
+
16
+ class Scanner(ABC):
17
+ """Base class for all scanner adapters.
18
+
19
+ Subclasses implement `_run_subprocess()` and `_parse_output()` (or
20
+ override `run()` entirely for built-in / in-process scanners).
21
+ """
22
+
23
+ name: str # canonical id used in config + reports
24
+ binary: str # name of the executable on PATH
25
+ version_flag: str = "--version"
26
+ default_category: Category = Category.CODE_VULNERABILITIES
27
+
28
+ # ----- availability ----------------------------------------------------
29
+
30
+ def is_available(self) -> bool:
31
+ return shutil.which(self.binary) is not None
32
+
33
+ def binary_version(self) -> Optional[str]:
34
+ if not self.is_available():
35
+ return None
36
+ try:
37
+ r = subprocess.run(
38
+ [self.binary, self.version_flag],
39
+ check=False, capture_output=True, text=True, timeout=10,
40
+ )
41
+ except (FileNotFoundError, subprocess.TimeoutExpired):
42
+ return None
43
+ out = (r.stdout or r.stderr or "").strip().splitlines()
44
+ return out[0] if out else None
45
+
46
+ # ----- main entrypoint ------------------------------------------------
47
+
48
+ @abstractmethod
49
+ def run(self, target: Path, config: Config) -> list[Finding]:
50
+ """Execute the scanner against target. MUST NOT raise.
51
+
52
+ Errors are converted to a single informational finding so the
53
+ audit pipeline never dies on a single scanner failing.
54
+ """
55
+ ...
56
+
57
+ # ----- subprocess helpers ---------------------------------------------
58
+
59
+ def _exec(
60
+ self,
61
+ args: list[str],
62
+ cwd: Path,
63
+ timeout_seconds: int,
64
+ allowed_exits: tuple[int, ...] = (0,),
65
+ ) -> subprocess.CompletedProcess:
66
+ """Run a scanner subprocess with sanitized env, no shell.
67
+
68
+ Some scanners (npm audit, pip-audit) exit nonzero on findings; pass
69
+ `allowed_exits` to mark those as success."""
70
+ env = self._sanitized_env()
71
+ try:
72
+ r = subprocess.run(
73
+ args,
74
+ cwd=str(cwd),
75
+ env=env,
76
+ shell=False,
77
+ check=False,
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=timeout_seconds,
81
+ )
82
+ except subprocess.TimeoutExpired as exc:
83
+ return subprocess.CompletedProcess(
84
+ args=exc.cmd or args,
85
+ returncode=124,
86
+ stdout="",
87
+ stderr=f"timeout after {timeout_seconds}s",
88
+ )
89
+ if r.returncode not in allowed_exits and r.returncode != 0:
90
+ return r # caller decides how to handle
91
+ return r
92
+
93
+ @staticmethod
94
+ def _sanitized_env() -> dict[str, str]:
95
+ """A minimal env for subprocesses — keep PATH and locale, drop the rest.
96
+ Prevents accidental secret-leak into the scanner process via env."""
97
+ keep = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP")
98
+ return {k: v for k, v in os.environ.items() if k in keep}
99
+
100
+ # ----- finding construction helper ------------------------------------
101
+
102
+ def _make_finding(
103
+ self,
104
+ *,
105
+ rule_id: str,
106
+ message: str,
107
+ file_path: Path,
108
+ line_start: int,
109
+ line_end: Optional[int],
110
+ code_snippet: Optional[str],
111
+ severity: Optional[Severity] = None,
112
+ confidence: Optional[Confidence] = None,
113
+ category: Optional[Category] = None,
114
+ cwe_override: Optional[str] = None,
115
+ scanner_name: Optional[str] = None,
116
+ ) -> Finding:
117
+ """Construct a canonical Finding from scanner-emitted bits, layering
118
+ in the standards mapping. Scanner-emitted severity wins over the
119
+ map's default; confidence falls back to the map; category is set
120
+ per the map unless explicitly overridden."""
121
+
122
+ scanner_label = scanner_name or self.name
123
+ entry: Optional[StandardsEntry] = lookup(scanner_label, rule_id)
124
+
125
+ # Mapping fallback to wildcard (handled inside lookup).
126
+ canonical_cwe = cwe_override or (entry.canonical_cwe if entry else None)
127
+ owasp_top10 = entry.owasp_top10 if entry else None
128
+ asvs_section = entry.asvs_section if entry else None
129
+ nist_ssdf = entry.nist_ssdf if entry else None
130
+ chosen_cat = category or (entry.category if entry else self.default_category)
131
+ chosen_sev = severity or (entry.severity if entry else Severity.MEDIUM)
132
+ chosen_conf = confidence or (entry.confidence if entry else Confidence.MEDIUM)
133
+ short_desc = entry.short_desc if entry else None
134
+ fix_hint = entry.fix_hint if entry else None
135
+
136
+ fingerprint = Finding.make_fingerprint(
137
+ canonical_cwe=canonical_cwe,
138
+ rule_id=rule_id,
139
+ file_path=file_path,
140
+ code_snippet=code_snippet,
141
+ )
142
+
143
+ return Finding(
144
+ rule_id=rule_id,
145
+ scanner=scanner_label,
146
+ fingerprint=fingerprint,
147
+ canonical_cwe=canonical_cwe,
148
+ owasp_top10=owasp_top10,
149
+ asvs_section=asvs_section,
150
+ nist_ssdf=nist_ssdf,
151
+ category=chosen_cat,
152
+ severity=chosen_sev,
153
+ confidence=chosen_conf,
154
+ file_path=file_path,
155
+ line_start=line_start,
156
+ line_end=line_end,
157
+ code_snippet=code_snippet,
158
+ message=message,
159
+ short_desc=short_desc,
160
+ fix_hint=fix_hint,
161
+ cwe_top25=is_top25(canonical_cwe),
162
+ )
163
+
164
+ def _unavailable_finding(self, target: Path) -> Finding:
165
+ """Informational finding emitted when the binary isn't on PATH."""
166
+ return Finding(
167
+ rule_id=f"{self.name}.tool_unavailable",
168
+ scanner=self.name,
169
+ fingerprint=f"unavailable.{self.name}",
170
+ canonical_cwe=None,
171
+ owasp_top10=None,
172
+ asvs_section=None,
173
+ nist_ssdf=None,
174
+ category=Category.POLICY_DOCS,
175
+ severity=Severity.INFORMATIONAL,
176
+ confidence=Confidence.HIGH,
177
+ file_path=target,
178
+ line_start=0,
179
+ line_end=None,
180
+ code_snippet=None,
181
+ message=f"{self.binary} not on PATH; {self.name} scan skipped.",
182
+ short_desc=None,
183
+ fix_hint=f"Install {self.binary} to enable {self.name} coverage.",
184
+ )
185
+
186
+ # ----- shared utility -------------------------------------------------
187
+
188
+ def cfg(self, config: Config) -> "ScannerConfig":
189
+ """Convenience accessor."""
190
+ return scanner_cfg(config, self.name)
191
+
192
+
193
+ # Re-export so the registry import in __init__.py is clean.
194
+ from secure_code_audit.config import ScannerConfig # noqa: E402