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.
- secure_code_agent-0.2.0.dist-info/METADATA +328 -0
- secure_code_agent-0.2.0.dist-info/RECORD +33 -0
- secure_code_agent-0.2.0.dist-info/WHEEL +5 -0
- secure_code_agent-0.2.0.dist-info/entry_points.txt +3 -0
- secure_code_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- secure_code_agent-0.2.0.dist-info/top_level.txt +1 -0
- secure_code_audit/__init__.py +3 -0
- secure_code_audit/baseline.py +110 -0
- secure_code_audit/cli.py +258 -0
- secure_code_audit/config.py +115 -0
- secure_code_audit/findings.py +165 -0
- secure_code_audit/git_tools.py +82 -0
- secure_code_audit/instructions.py +141 -0
- secure_code_audit/remediation.py +168 -0
- secure_code_audit/renderers.py +253 -0
- secure_code_audit/sarif.py +221 -0
- secure_code_audit/scanners/__init__.py +50 -0
- secure_code_audit/scanners/bandit_scanner.py +83 -0
- secure_code_audit/scanners/base.py +194 -0
- secure_code_audit/scanners/builtin_rules.py +183 -0
- secure_code_audit/scanners/checkov_scanner.py +69 -0
- secure_code_audit/scanners/gitleaks_scanner.py +86 -0
- secure_code_audit/scanners/hadolint_scanner.py +107 -0
- secure_code_audit/scanners/npm_audit_scanner.py +101 -0
- secure_code_audit/scanners/osv_scanner.py +108 -0
- secure_code_audit/scanners/pip_audit_scanner.py +83 -0
- secure_code_audit/scanners/scorecard_scanner.py +156 -0
- secure_code_audit/scanners/semgrep_scanner.py +119 -0
- secure_code_audit/scanners/trivy_scanner.py +87 -0
- secure_code_audit/scanners/trufflehog_scanner.py +91 -0
- secure_code_audit/scoring.py +280 -0
- secure_code_audit/standards.py +391 -0
- secure_code_audit/suppressions.py +175 -0
secure_code_audit/cli.py
ADDED
|
@@ -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
|