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,258 @@
1
+ """CLI entrypoint — `secure-code-agent` and `secure-code-audit`.
2
+
3
+ Wires together: config load → scanner runs → suppressions → baseline diff →
4
+ scoring → gate evaluation → render outputs.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from dataclasses import replace
12
+ from pathlib import Path
13
+
14
+ from secure_code_audit import __version__
15
+ from secure_code_audit import baseline as baseline_mod
16
+ from secure_code_audit import config as config_mod
17
+ from secure_code_audit import instructions
18
+ from secure_code_audit import remediation
19
+ from secure_code_audit import renderers
20
+ from secure_code_audit import sarif
21
+ from secure_code_audit import scanners
22
+ from secure_code_audit import suppressions
23
+ from secure_code_audit.findings import Category, Finding, Severity
24
+ from secure_code_audit.git_tools import find_repo_root, loc_under
25
+ from secure_code_audit.scoring import evaluate_gates, score as score_findings
26
+
27
+
28
+ def _parser() -> argparse.ArgumentParser:
29
+ p = argparse.ArgumentParser(
30
+ prog="secure-code-agent",
31
+ description="Deterministic security gate + bounded AI remediation prompts.",
32
+ )
33
+ p.add_argument("paths", nargs="*", default=["."],
34
+ help="Paths to scan. Defaults to the current directory.")
35
+ p.add_argument("--config", default="secure-code-agent.json",
36
+ help="Path to config (default: secure-code-agent.json).")
37
+
38
+ p.add_argument("--output", help="Markdown report output path.")
39
+ p.add_argument("--json-output", help="Canonical JSON output path.")
40
+ p.add_argument("--sarif-output", help="SARIF 2.1.0 output path.")
41
+ p.add_argument("--comment-output", help="PR-comment markdown output path.")
42
+ p.add_argument("--prompt-output", help="Remediation prompt output path.")
43
+ p.add_argument("--baseline", help="Baseline file path (read).")
44
+ p.add_argument("--bump-baseline", action="store_true",
45
+ help="Rewrite the baseline from current findings.")
46
+
47
+ p.add_argument("--fail-on-gate", action="store_true",
48
+ help="Exit nonzero if any gate trips.")
49
+ p.add_argument("--fail-on-new", action="store_true",
50
+ help="Exit nonzero on findings not in baseline.")
51
+
52
+ p.add_argument("--changed-only",
53
+ help="Audit only files changed since REF (e.g. main...HEAD).")
54
+ p.add_argument("--skip-scanners",
55
+ help="Comma-separated scanner names to skip.")
56
+ p.add_argument("--only-scanners",
57
+ help="Comma-separated scanner names — only these run.")
58
+ p.add_argument("--severity-threshold", default="informational",
59
+ choices=[s.value for s in Severity],
60
+ help="Filter findings below this severity.")
61
+
62
+ p.add_argument("--sarif-import", action="append", default=[],
63
+ help="Path to an external SARIF file to ingest. May be passed multiple times.")
64
+
65
+ p.add_argument("--init-agent-standards", action="store_true",
66
+ help="Emit per-agent standards files instead of running an audit.")
67
+ p.add_argument("--target", action="append", default=[],
68
+ help="Target for --init-agent-standards (codex, claude-code, cursor, copilot, windsurf, generic).")
69
+ p.add_argument("--instructions-output-dir", default=".",
70
+ help="Output directory for --init-agent-standards.")
71
+
72
+ p.add_argument("--json", action="store_true",
73
+ help="Print canonical JSON to stdout (suppresses other terminal output).")
74
+ p.add_argument("--version", action="version", version=f"secure-code-agent {__version__}")
75
+ return p
76
+
77
+
78
+ def main(argv: list[str] | None = None) -> int:
79
+ args = _parser().parse_args(argv)
80
+
81
+ if args.init_agent_standards:
82
+ return _do_init_standards(args)
83
+
84
+ return _do_audit(args)
85
+
86
+
87
+ def _do_init_standards(args: argparse.Namespace) -> int:
88
+ targets = args.target or instructions.known_targets()
89
+ out_dir = Path(args.instructions_output_dir).resolve()
90
+ out_dir.mkdir(parents=True, exist_ok=True)
91
+ for t in targets:
92
+ path = instructions.write_for_target(t, out_dir)
93
+ print(f"wrote {path}")
94
+ return 0
95
+
96
+
97
+ def _do_audit(args: argparse.Namespace) -> int:
98
+ cfg = config_mod.load(args.config)
99
+ target = Path(args.paths[0]).resolve()
100
+ root = find_repo_root(target)
101
+
102
+ # ----- scanners -----
103
+ skip = set(filter(None, (args.skip_scanners or "").split(",")))
104
+ only = set(filter(None, (args.only_scanners or "").split(",")))
105
+ all_findings: list[Finding] = []
106
+ ran: list[str] = []
107
+ unavailable: list[str] = []
108
+
109
+ for name, klass in scanners.SCANNERS.items():
110
+ if only and name not in only:
111
+ continue
112
+ if name in skip:
113
+ continue
114
+ sc_cfg = cfg.scanners.get(name) or config_mod.ScannerConfig()
115
+ if not sc_cfg.enabled:
116
+ continue
117
+ scanner = klass()
118
+ if not scanner.is_available():
119
+ unavailable.append(name)
120
+ all_findings.extend(scanner.run(target, cfg)) # emits tool_unavailable info
121
+ continue
122
+ ran.append(name)
123
+ all_findings.extend(scanner.run(target, cfg))
124
+
125
+ # ----- SARIF imports -----
126
+ for sarif_path_str in args.sarif_import:
127
+ all_findings.extend(sarif.ingest(Path(sarif_path_str)))
128
+
129
+ # ----- overrides from config -----
130
+ all_findings = _apply_overrides(all_findings, cfg)
131
+
132
+ # ----- suppressions -----
133
+ sup_rules, sup_errors = suppressions.load(Path(cfg.suppressions_file))
134
+ if sup_errors:
135
+ for err in sup_errors:
136
+ sys.stderr.write(f"WARN: {err}\n")
137
+ all_findings = suppressions.apply(all_findings, sup_rules)
138
+ all_findings.extend(suppressions.expired_findings(sup_rules, Path(cfg.suppressions_file)))
139
+
140
+ # ----- severity threshold filter -----
141
+ threshold = Severity.from_string(args.severity_threshold)
142
+ all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
143
+
144
+ # ----- baseline -----
145
+ baseline_path = Path(args.baseline or cfg.outputs["baseline_path"])
146
+ baseline = baseline_mod.load(baseline_path)
147
+ all_findings = baseline_mod.mark_new(all_findings, baseline)
148
+
149
+ # ----- scoring -----
150
+ if cfg.loc_for_scoring:
151
+ loc = int(cfg.loc_for_scoring.get("value", 0))
152
+ else:
153
+ loc = loc_under(target, cfg.include_extensions, cfg.exclude_patterns)
154
+ score = score_findings(all_findings, loc)
155
+ gate = evaluate_gates(all_findings, score, cfg.gates)
156
+
157
+ # ----- write outputs -----
158
+ paths = _resolve_outputs(args, cfg, root)
159
+
160
+ if paths.markdown is not None:
161
+ renderers.write_markdown(all_findings, score, gate, paths.markdown, ran, unavailable)
162
+ if paths.json_out is not None:
163
+ renderers.write_json(all_findings, score, gate, paths.json_out)
164
+ if paths.sarif is not None:
165
+ sarif.write(all_findings, paths.sarif)
166
+ if paths.comment is not None:
167
+ renderers.write_pr_comment(all_findings, score, gate, paths.comment)
168
+ if paths.prompt is not None:
169
+ remediation.write(all_findings, paths.prompt)
170
+ if args.bump_baseline:
171
+ baseline_mod.write(baseline_path, all_findings, baseline)
172
+
173
+ # ----- terminal output -----
174
+ if args.json:
175
+ sys.stdout.write(json.dumps(renderers.to_json(all_findings, score, gate), indent=2))
176
+ sys.stdout.write("\n")
177
+ else:
178
+ _print_summary(score, gate, ran, unavailable, paths)
179
+
180
+ # ----- exit code -----
181
+ if args.fail_on_gate and not gate.passed:
182
+ return 1
183
+ if args.fail_on_new and any(f.is_new and not f.suppressed for f in all_findings):
184
+ return 1
185
+ return 0
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Helpers
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def _apply_overrides(findings: list[Finding], cfg: "config_mod.Config") -> list[Finding]:
193
+ """Apply config-level severity_overrides + category_overrides."""
194
+ if not cfg.severity_overrides and not cfg.category_overrides:
195
+ return findings
196
+ out: list[Finding] = []
197
+ for f in findings:
198
+ new_severity = f.severity
199
+ if f.rule_id in cfg.severity_overrides:
200
+ new_severity = Severity.from_string(cfg.severity_overrides[f.rule_id])
201
+ new_category = f.category
202
+ if f.rule_id in cfg.category_overrides:
203
+ try:
204
+ new_category = Category(cfg.category_overrides[f.rule_id])
205
+ except ValueError:
206
+ pass
207
+ out.append(replace(f, severity=new_severity, category=new_category))
208
+ return out
209
+
210
+
211
+ from dataclasses import dataclass
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class _OutputPaths:
216
+ markdown: Path | None
217
+ json_out: Path | None
218
+ sarif: Path | None
219
+ comment: Path | None
220
+ prompt: Path | None
221
+
222
+
223
+ def _resolve_outputs(args: argparse.Namespace, cfg: "config_mod.Config", root: Path) -> _OutputPaths:
224
+ """CLI flags override config defaults. A flag value of None means
225
+ 'don't emit this format' — by default we emit Markdown only, and
226
+ other outputs are opt-in via CLI flag or explicit config."""
227
+ def _p(flag_value, default_key) -> Path | None:
228
+ if flag_value is not None:
229
+ return (root / flag_value).resolve()
230
+ return None
231
+
232
+ return _OutputPaths(
233
+ markdown=_p(args.output or cfg.outputs.get("markdown_path"), "markdown_path"),
234
+ json_out=_p(args.json_output, "json_path"),
235
+ sarif= _p(args.sarif_output, "sarif_path"),
236
+ comment= _p(args.comment_output, "comment_path"),
237
+ prompt= _p(args.prompt_output, "prompt_path"),
238
+ )
239
+
240
+
241
+ def _print_summary(score, gate, ran, unavailable, paths) -> None:
242
+ status = "PASS" if gate.passed else "FAIL"
243
+ print(f"secure-code-agent · score {score.overall:.2f} ({score.letter}) · gate {status}")
244
+ print(f" scanned LOC: {score.loc_scanned:,}")
245
+ print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
246
+ if unavailable:
247
+ print(f" unavailable: {', '.join(unavailable)}")
248
+ if not gate.passed:
249
+ for reason in gate.reasons:
250
+ print(f" ✗ {reason}")
251
+ written = [str(p) for p in (paths.markdown, paths.json_out, paths.sarif,
252
+ paths.comment, paths.prompt) if p is not None]
253
+ if written:
254
+ print(f" wrote: {', '.join(written)}")
255
+
256
+
257
+ if __name__ == "__main__":
258
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """Config loader. Reads secure-code-agent.json (or operator-pointed path),
2
+ validates against the schema, and exposes a Config dataclass."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ DEFAULT_CONFIG_PATH = Path("secure-code-agent.json")
11
+
12
+ DEFAULT_EXCLUDES: tuple[str, ...] = (
13
+ ".git/", "node_modules/", ".venv/", "venv/", "dist/", "build/",
14
+ "__pycache__/", ".pytest_cache/", ".ruff_cache/", ".mypy_cache/",
15
+ "**/*.min.js", "**/*.lock",
16
+ )
17
+
18
+ DEFAULT_INCLUDE_EXTS: tuple[str, ...] = (
19
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java", ".rb",
20
+ ".sh", ".yaml", ".yml", ".json", "Dockerfile",
21
+ )
22
+
23
+ DEFAULT_OUTPUTS: dict[str, str] = {
24
+ "markdown_path": "secure-code-report.md",
25
+ "json_path": "secure-code-report.json",
26
+ "sarif_path": "secure-code.sarif",
27
+ "comment_path": "secure-code-pr-comment.md",
28
+ "prompt_path": "secure-code-remediation-prompt.md",
29
+ "baseline_path": "secure-code-baseline.json",
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class ScannerConfig:
35
+ enabled: bool = True
36
+ timeout_seconds: int = 600
37
+ online: bool = False
38
+ extra_args: list[str] = field(default_factory=list)
39
+
40
+
41
+ @dataclass
42
+ class Config:
43
+ """Parsed config. Operator-facing access is via attribute names."""
44
+ version: int = 1
45
+ asvs_level: int = 2
46
+ include_extensions: tuple[str, ...] = DEFAULT_INCLUDE_EXTS
47
+ exclude_patterns: tuple[str, ...] = DEFAULT_EXCLUDES
48
+ scanners: dict[str, ScannerConfig] = field(default_factory=dict)
49
+ severity_overrides: dict[str, str] = field(default_factory=dict)
50
+ category_overrides: dict[str, str] = field(default_factory=dict)
51
+ gates: dict[str, Any] = field(default_factory=dict)
52
+ outputs: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_OUTPUTS))
53
+ suppressions_file: str = ".scignore.yaml"
54
+ loc_for_scoring: dict[str, Any] | None = None
55
+ raw: dict[str, Any] = field(default_factory=dict)
56
+
57
+
58
+ def load(path: Path | str | None = None) -> Config:
59
+ """Load config from path. Missing file → defaults. Malformed file → ValueError."""
60
+ p = Path(path) if path else DEFAULT_CONFIG_PATH
61
+ if not p.exists():
62
+ return Config()
63
+ try:
64
+ raw = json.loads(p.read_text(encoding="utf-8"))
65
+ except json.JSONDecodeError as e:
66
+ raise ValueError(f"{p}: invalid JSON: {e}") from e
67
+ return _from_dict(raw)
68
+
69
+
70
+ def _from_dict(raw: dict[str, Any]) -> Config:
71
+ cfg = Config(raw=raw)
72
+ cfg.version = int(raw.get("version", 1))
73
+ cfg.asvs_level = int(raw.get("asvs_level", 2))
74
+ if cfg.asvs_level not in (1, 2, 3):
75
+ raise ValueError(f"asvs_level must be 1, 2, or 3; got {cfg.asvs_level}")
76
+
77
+ paths = raw.get("paths") or {}
78
+ if isinstance(paths.get("include_extensions"), list):
79
+ cfg.include_extensions = tuple(paths["include_extensions"])
80
+ if isinstance(paths.get("exclude_patterns"), list):
81
+ cfg.exclude_patterns = tuple(paths["exclude_patterns"])
82
+
83
+ scanners_raw = raw.get("scanners") or {}
84
+ for name, sc_raw in scanners_raw.items():
85
+ cfg.scanners[name] = ScannerConfig(
86
+ enabled=bool(sc_raw.get("enabled", True)),
87
+ timeout_seconds=int(sc_raw.get("timeout_seconds", 600)),
88
+ online=bool(sc_raw.get("online", False)),
89
+ extra_args=list(sc_raw.get("extra_args") or []),
90
+ )
91
+
92
+ cfg.severity_overrides = {
93
+ str(k): str(v).lower() for k, v in (raw.get("severity_overrides") or {}).items()
94
+ }
95
+ cfg.category_overrides = {
96
+ str(k): str(v).lower() for k, v in (raw.get("category_overrides") or {}).items()
97
+ }
98
+ cfg.gates = dict(raw.get("gates") or {})
99
+
100
+ outputs = raw.get("outputs") or {}
101
+ for k, default_v in DEFAULT_OUTPUTS.items():
102
+ cfg.outputs[k] = outputs.get(k, default_v)
103
+
104
+ if "suppressions_file" in raw:
105
+ cfg.suppressions_file = str(raw["suppressions_file"])
106
+
107
+ if "loc_for_scoring" in raw:
108
+ cfg.loc_for_scoring = raw["loc_for_scoring"]
109
+
110
+ return cfg
111
+
112
+
113
+ def scanner_cfg(cfg: Config, name: str) -> ScannerConfig:
114
+ """Return the per-scanner config, defaulting to enabled."""
115
+ return cfg.scanners.get(name, ScannerConfig())
@@ -0,0 +1,165 @@
1
+ """Canonical Finding type — the lingua franca between scanners, scoring, and renderers.
2
+
3
+ Every scanner returns a list[Finding]. The scoring layer dedupes by `canonical_cwe`
4
+ + `fingerprint`. Renderers consume the same dataclass — markdown, JSON, SARIF,
5
+ PR-comment, remediation prompt all read these fields directly.
6
+
7
+ See docs/design.md §4.3 for the rationale + field semantics.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import enum
12
+ import hashlib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ class Severity(str, enum.Enum):
19
+ CRITICAL = "critical"
20
+ HIGH = "high"
21
+ MEDIUM = "medium"
22
+ LOW = "low"
23
+ INFORMATIONAL = "informational"
24
+
25
+ @classmethod
26
+ def from_string(cls, value: str) -> "Severity":
27
+ """Parse permissively — scanners use 'info', 'INFO', 'note', 'warning', etc."""
28
+ normalized = value.strip().lower()
29
+ return _SEVERITY_ALIASES.get(normalized, cls.INFORMATIONAL)
30
+
31
+ @property
32
+ def rank(self) -> int:
33
+ """Higher = worse. For sorting."""
34
+ return _SEVERITY_RANK[self]
35
+
36
+
37
+ _SEVERITY_ALIASES: dict[str, Severity] = {
38
+ "critical": Severity.CRITICAL,
39
+ "crit": Severity.CRITICAL,
40
+ "error": Severity.HIGH,
41
+ "high": Severity.HIGH,
42
+ "warning": Severity.MEDIUM,
43
+ "moderate": Severity.MEDIUM,
44
+ "medium": Severity.MEDIUM,
45
+ "med": Severity.MEDIUM,
46
+ "low": Severity.LOW,
47
+ "note": Severity.LOW,
48
+ "info": Severity.INFORMATIONAL,
49
+ "informational": Severity.INFORMATIONAL,
50
+ "none": Severity.INFORMATIONAL,
51
+ }
52
+
53
+ _SEVERITY_RANK: dict[Severity, int] = {
54
+ Severity.CRITICAL: 5,
55
+ Severity.HIGH: 4,
56
+ Severity.MEDIUM: 3,
57
+ Severity.LOW: 2,
58
+ Severity.INFORMATIONAL: 1,
59
+ }
60
+
61
+
62
+ class Confidence(str, enum.Enum):
63
+ HIGH = "high"
64
+ MEDIUM = "medium"
65
+ LOW = "low"
66
+
67
+ @classmethod
68
+ def from_string(cls, value: str) -> "Confidence":
69
+ normalized = (value or "").strip().lower()
70
+ if normalized in ("high", "h"):
71
+ return cls.HIGH
72
+ if normalized in ("medium", "med", "m"):
73
+ return cls.MEDIUM
74
+ if normalized in ("low", "l"):
75
+ return cls.LOW
76
+ return cls.MEDIUM # default when scanner doesn't emit confidence
77
+
78
+
79
+ class Category(str, enum.Enum):
80
+ SECRETS = "secrets"
81
+ DEPENDENCIES = "dependencies"
82
+ CODE_VULNERABILITIES = "code_vulnerabilities"
83
+ AUTH_AUTHZ = "auth_authz"
84
+ CRYPTO = "crypto"
85
+ SUPPLY_CHAIN = "supply_chain"
86
+ CONFIG_IAC = "config_iac"
87
+ LOGGING_OBSERVABILITY = "logging_observability"
88
+ POLICY_DOCS = "policy_docs"
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Finding:
93
+ """A normalized security finding."""
94
+
95
+ # --- identity ----------------------------------------------------------
96
+ rule_id: str # scanner-local id (e.g. "B608", "generic.sql.tainted")
97
+ scanner: str # which scanner emitted it
98
+ fingerprint: str # stable id for baseline + dedupe
99
+
100
+ # --- standards taxonomy (any/all may be None when unmapped) ------------
101
+ canonical_cwe: Optional[str] # e.g. "CWE-89" — primary dedupe key
102
+ owasp_top10: Optional[str] # e.g. "A03:2021-Injection"
103
+ asvs_section: Optional[str] # e.g. "V5.3"
104
+ nist_ssdf: Optional[str] # e.g. "PW.5.1"
105
+ category: Category
106
+
107
+ # --- severity ----------------------------------------------------------
108
+ severity: Severity
109
+ confidence: Confidence
110
+
111
+ # --- locus -------------------------------------------------------------
112
+ file_path: Path
113
+ line_start: int
114
+ line_end: Optional[int]
115
+ code_snippet: Optional[str]
116
+
117
+ # --- human-readable ----------------------------------------------------
118
+ message: str
119
+ short_desc: Optional[str] = None
120
+ full_desc: Optional[str] = None
121
+ fix_hint: Optional[str] = None
122
+ references: tuple[str, ...] = field(default_factory=tuple)
123
+
124
+ # --- lifecycle ---------------------------------------------------------
125
+ suppressed: bool = False
126
+ suppression_note: Optional[str] = None
127
+ is_new: bool = False # not in baseline
128
+
129
+ # --- flags -------------------------------------------------------------
130
+ cwe_top25: bool = False # set by scoring layer
131
+
132
+ # ------------------------------------------------------------------ ctor
133
+ @staticmethod
134
+ def make_fingerprint(
135
+ *,
136
+ canonical_cwe: Optional[str],
137
+ rule_id: str,
138
+ file_path: Path,
139
+ code_snippet: Optional[str],
140
+ ) -> str:
141
+ """Stable 16-hex-char fingerprint for dedupe + baseline.
142
+
143
+ Inputs:
144
+ - canonical_cwe falls back to rule_id when no CWE is mapped (so
145
+ unmapped findings still dedupe per-rule).
146
+ - file_path is POSIX-normalized for cross-platform stability.
147
+ - code_snippet is normalized (whitespace collapsed, max 512 chars)
148
+ so a reformat-only edit doesn't break the fingerprint.
149
+ """
150
+ key = canonical_cwe or rule_id
151
+ path = file_path.as_posix()
152
+ snippet_norm = (
153
+ " ".join((code_snippet or "").split())[:512]
154
+ )
155
+ material = f"{key}|{path}|{snippet_norm}".encode()
156
+ return hashlib.sha256(material).hexdigest()[:16]
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Helpers
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def severity_at_or_above(target: Severity) -> set[Severity]:
164
+ """Return the set of severities >= target. Useful for gate filters."""
165
+ return {s for s in Severity if s.rank >= target.rank}
@@ -0,0 +1,82 @@
1
+ """Git helpers — repo root detection, LOC count, changed-only diffing."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+
10
+ def find_repo_root(start: Path) -> Path:
11
+ """Walk up from `start` until a .git directory is found. Returns `start`
12
+ if no git repo is present (a tarball / archive run is still supported)."""
13
+ cur = start.resolve()
14
+ for parent in (cur, *cur.parents):
15
+ if (parent / ".git").exists():
16
+ return parent
17
+ return cur
18
+
19
+
20
+ def changed_files(root: Path, since_ref: str) -> list[Path]:
21
+ """Return files changed since `since_ref` (e.g. 'main...HEAD'). Suitable
22
+ for `--changed-only`. Empty list on git failure."""
23
+ try:
24
+ out = subprocess.run(
25
+ ["git", "diff", "--name-only", since_ref],
26
+ cwd=str(root),
27
+ check=True,
28
+ text=True,
29
+ capture_output=True,
30
+ timeout=30,
31
+ )
32
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
33
+ return []
34
+ return [root / line.strip() for line in out.stdout.splitlines() if line.strip()]
35
+
36
+
37
+ def is_excluded(path: Path, root: Path, patterns: Iterable[str]) -> bool:
38
+ """Check if `path` matches any exclude glob. Patterns are tested against
39
+ the POSIX-relative path from `root`."""
40
+ try:
41
+ rel = path.resolve().relative_to(root.resolve()).as_posix()
42
+ except ValueError:
43
+ return False
44
+ for pat in patterns:
45
+ if pat.endswith("/"):
46
+ if rel.startswith(pat) or f"/{pat}" in f"/{rel}/":
47
+ return True
48
+ elif fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(path.name, pat):
49
+ return True
50
+ return False
51
+
52
+
53
+ def in_scope(path: Path, include_exts: Iterable[str]) -> bool:
54
+ """Does this path match the configured include_extensions?
55
+ Dockerfile is matched by basename."""
56
+ name = path.name
57
+ for ext in include_exts:
58
+ if ext.startswith("."):
59
+ if name.endswith(ext):
60
+ return True
61
+ elif name == ext or name.startswith(f"{ext}."):
62
+ return True
63
+ return False
64
+
65
+
66
+ def loc_under(root: Path, include_exts: Iterable[str], excludes: Iterable[str]) -> int:
67
+ """Best-effort LOC count for the scoring normalizer. Counts non-blank
68
+ lines across in-scope files; skips binary content."""
69
+ total = 0
70
+ for path in root.rglob("*"):
71
+ if not path.is_file():
72
+ continue
73
+ if is_excluded(path, root, excludes):
74
+ continue
75
+ if not in_scope(path, include_exts):
76
+ continue
77
+ try:
78
+ text = path.read_text(encoding="utf-8", errors="ignore")
79
+ except OSError:
80
+ continue
81
+ total += sum(1 for line in text.splitlines() if line.strip())
82
+ return total