secure-code-agent 0.6.0__tar.gz → 0.8.0__tar.gz
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.6.0/src/secure_code_agent.egg-info → secure_code_agent-0.8.0}/PKG-INFO +1 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0/src/secure_code_agent.egg-info}/PKG-INFO +1 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/cli.py +44 -10
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/config.py +7 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/findings.py +64 -14
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/git_tools.py +18 -3
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/renderers.py +6 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/sarif.py +67 -6
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/base.py +12 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/checkov_scanner.py +9 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/semgrep_scanner.py +14 -1
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scoring.py +21 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/standards.py +16 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/LICENSE +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/README.md +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/pyproject.toml +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/setup.cfg +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/remediation.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/suppressions.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/triage.py +0 -0
- {secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/verify.py +0 -0
|
@@ -56,6 +56,7 @@ from secure_code_audit.scoring import (
|
|
|
56
56
|
)
|
|
57
57
|
from secure_code_audit.scoring import score as score_findings
|
|
58
58
|
from secure_code_audit.scoring import verdict as build_verdict
|
|
59
|
+
from secure_code_audit.standards import categories_for_scanner
|
|
59
60
|
|
|
60
61
|
|
|
61
62
|
def _parser() -> argparse.ArgumentParser:
|
|
@@ -501,6 +502,12 @@ def _prepare_audit(
|
|
|
501
502
|
raise ValueError("multiple scan roots are not supported; provide one repository root")
|
|
502
503
|
# The target is resolved first because the default config belongs to it.
|
|
503
504
|
target = Path(args.paths[0]).resolve()
|
|
505
|
+
# A typo in the path used to reach the scanners, where the first adapter
|
|
506
|
+
# passed the missing directory as a subprocess `cwd` and the run died
|
|
507
|
+
# with a raw `FileNotFoundError` traceback. An operator who mistypes a
|
|
508
|
+
# path should be told so, not handed a stack trace from `subprocess.py`.
|
|
509
|
+
if not target.exists():
|
|
510
|
+
raise ValueError(f"scan root does not exist: {target}")
|
|
504
511
|
cfg = config_mod.load(args.config, default_root=target)
|
|
505
512
|
# Set from the command line only. Threading it through the loaded config
|
|
506
513
|
# would let a repository-supplied file assert its own trustworthiness.
|
|
@@ -623,14 +630,24 @@ def _measurable_categories(
|
|
|
623
630
|
domains.add(policy.domain)
|
|
624
631
|
|
|
625
632
|
measurable = {category for category in Category if category.value in domains}
|
|
626
|
-
|
|
627
|
-
#
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
633
|
+
for execution in executions:
|
|
634
|
+
# A scanner declaring `multiple` reads several categories and names
|
|
635
|
+
# none of them, so ask the standards map which rules it actually has.
|
|
636
|
+
#
|
|
637
|
+
# This list used to be written out by hand and was wrong in both
|
|
638
|
+
# directions: it claimed `builtin_rules` covered `secrets` and
|
|
639
|
+
# `config_iac` — its rules are Python and shell language primitives,
|
|
640
|
+
# it has never had one of either — and omitted `supply_chain`, which
|
|
641
|
+
# it does cover. An IaC repository carrying a `public-read` S3
|
|
642
|
+
# bucket, a 0.0.0.0/0 ingress rule, `USER root` and `chmod 777`
|
|
643
|
+
# scored `config_iac` **5.0** with neither checkov nor hadolint
|
|
644
|
+
# installed. A category graded perfectly because nothing could read
|
|
645
|
+
# it is the absence-as-value defect this function exists to prevent.
|
|
646
|
+
if execution.outcome not in COVERING_OUTCOMES:
|
|
647
|
+
continue
|
|
648
|
+
policy = floor.policy(execution.name)
|
|
649
|
+
if policy is not None and policy.domain == "multiple":
|
|
650
|
+
measurable |= categories_for_scanner(execution.name)
|
|
634
651
|
measurable |= {finding.category for finding in findings if not finding.suppressed}
|
|
635
652
|
return measurable
|
|
636
653
|
|
|
@@ -787,7 +804,7 @@ def _write_outputs(
|
|
|
787
804
|
if paths.json_out is not None:
|
|
788
805
|
renderers.write_json(findings, score, gate, paths.json_out, coverage, verdict, axes)
|
|
789
806
|
if paths.sarif is not None:
|
|
790
|
-
sarif.write(findings, paths.sarif, coverage)
|
|
807
|
+
sarif.write(findings, paths.sarif, coverage, lambda f: renderers.axis_of(f, axes))
|
|
791
808
|
if paths.comment is not None:
|
|
792
809
|
renderers.write_pr_comment(findings, score, gate, paths.comment, coverage, verdict)
|
|
793
810
|
if paths.prompt is not None:
|
|
@@ -880,7 +897,15 @@ def _under_root(root: Path, value: str) -> Path:
|
|
|
880
897
|
def _print_summary(
|
|
881
898
|
verdict, score, gate, ran, unavailable, coverage, paths, axes=(), trend=None
|
|
882
899
|
) -> None:
|
|
883
|
-
|
|
900
|
+
# "PASS" is a claim that something was checked. With no gate configured
|
|
901
|
+
# nothing was, and saying so is the difference between a report and a
|
|
902
|
+
# false assurance.
|
|
903
|
+
if not gate.passed:
|
|
904
|
+
status = "FAIL"
|
|
905
|
+
elif gate.enforced:
|
|
906
|
+
status = "PASS"
|
|
907
|
+
else:
|
|
908
|
+
status = "NOT CONFIGURED"
|
|
884
909
|
print(f"secure-code-agent · score {verdict.headline()} · gate {status}")
|
|
885
910
|
for reason in verdict.reasons:
|
|
886
911
|
print(f" ! grade withheld: {reason}")
|
|
@@ -904,6 +929,15 @@ def _print_summary(
|
|
|
904
929
|
if not gate.passed:
|
|
905
930
|
for reason in gate.reasons:
|
|
906
931
|
print(f" ✗ {reason}")
|
|
932
|
+
elif not gate.enforced:
|
|
933
|
+
print(
|
|
934
|
+
" ! no gate is configured, so nothing here could have failed. "
|
|
935
|
+
"This audit is a report, not a check."
|
|
936
|
+
)
|
|
937
|
+
print(
|
|
938
|
+
" Set gates.fail_on_severity in secure-code-agent.json to make "
|
|
939
|
+
"findings block a build; read the work order either way."
|
|
940
|
+
)
|
|
907
941
|
written = [
|
|
908
942
|
str(p)
|
|
909
943
|
for p in (
|
|
@@ -75,6 +75,13 @@ DEFAULT_INCLUDE_EXTS: tuple[str, ...] = (
|
|
|
75
75
|
".yaml",
|
|
76
76
|
".yml",
|
|
77
77
|
".json",
|
|
78
|
+
# Terraform. checkov is in the floor and reads `.tf`, so its findings
|
|
79
|
+
# were being scored against a line count that excluded every file it
|
|
80
|
+
# had read — an IaC repository measured six lines for a Dockerfile and
|
|
81
|
+
# counted none of its ten lines of Terraform. Numerator and denominator
|
|
82
|
+
# have to describe the same tree.
|
|
83
|
+
".tf",
|
|
84
|
+
".tfvars",
|
|
78
85
|
"Dockerfile",
|
|
79
86
|
)
|
|
80
87
|
|
|
@@ -207,6 +207,46 @@ RULE_ALIASES: dict[str, dict[str, str]] = {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
def _canonical_rule(finding: Finding) -> str:
|
|
211
|
+
"""A rule id with aliases resolved, scoped to its scanner."""
|
|
212
|
+
alias = RULE_ALIASES.get(finding.scanner, {})
|
|
213
|
+
return f"{finding.scanner}:{alias.get(finding.rule_id, finding.rule_id)}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _same_weakness(a: Finding, b: Finding) -> bool:
|
|
217
|
+
"""Are these two reports of one weakness, or two weaknesses at one line?
|
|
218
|
+
|
|
219
|
+
Two conditions, and a CWE match alone is not enough.
|
|
220
|
+
|
|
221
|
+
**Two different rules from the same scanner are two different checks.**
|
|
222
|
+
Bandit files `B602` (shell=True), `B603` (subprocess call) and `B607`
|
|
223
|
+
(partial executable path) all under CWE-78, and they are not the same
|
|
224
|
+
finding: one is fixed with an argument list, one with an absolute path.
|
|
225
|
+
Keying on the CWE merged them and the work order lost a finding: a
|
|
226
|
+
one-line shell-enabled subprocess call reported `B607` alone, with the
|
|
227
|
+
more serious `B602` hidden inside it as a footnote.
|
|
228
|
+
|
|
229
|
+
(The example is described rather than quoted. Writing the offending
|
|
230
|
+
call out verbatim put a real `B602` on the primary axis of this file
|
|
231
|
+
and failed the repository's own gate — the second time in two days that
|
|
232
|
+
documenting a vulnerable pattern created one. Prose is scanned too.)
|
|
233
|
+
|
|
234
|
+
An earlier test asserted exactly this must not happen and passed anyway,
|
|
235
|
+
because its fixtures carried no CWE. Reading Bandit's CWEs gave them one
|
|
236
|
+
and turned a passing test into a false assurance.
|
|
237
|
+
|
|
238
|
+
So: the same scanner merges only through the hand-checked alias table.
|
|
239
|
+
Different scanners merge on a shared CWE, which is the corroboration
|
|
240
|
+
this function exists for — bandit and our own rule catching one
|
|
241
|
+
`shell=True` is one finding with two witnesses.
|
|
242
|
+
"""
|
|
243
|
+
if a.file_path != b.file_path or a.line_start != b.line_start:
|
|
244
|
+
return False
|
|
245
|
+
if a.scanner == b.scanner:
|
|
246
|
+
return _canonical_rule(a) == _canonical_rule(b)
|
|
247
|
+
return bool(a.canonical_cwe) and a.canonical_cwe == b.canonical_cwe
|
|
248
|
+
|
|
249
|
+
|
|
210
250
|
def _merge_key(finding: Finding) -> tuple:
|
|
211
251
|
"""What makes two reports the same report.
|
|
212
252
|
|
|
@@ -250,25 +290,35 @@ def merge_corroborating(findings: Iterable[Finding]) -> list[Finding]:
|
|
|
250
290
|
because a report that lists one line twice is wrong about the code, and
|
|
251
291
|
a work order derived from it would ask for the same fix twice.
|
|
252
292
|
"""
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
# Keyed by line so the pairwise test only runs against plausible
|
|
294
|
+
# neighbours; `_same_weakness` decides the rest.
|
|
295
|
+
by_line: dict[tuple, list[int]] = {}
|
|
296
|
+
kept: list[Finding] = []
|
|
297
|
+
witnesses: list[list[str]] = []
|
|
255
298
|
|
|
256
299
|
for finding in findings:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
300
|
+
locus = (finding.file_path.as_posix(), finding.line_start)
|
|
301
|
+
slot = None
|
|
302
|
+
for index in by_line.get(locus, []):
|
|
303
|
+
if _same_weakness(kept[index], finding):
|
|
304
|
+
slot = index
|
|
305
|
+
break
|
|
306
|
+
if slot is None:
|
|
307
|
+
by_line.setdefault(locus, []).append(len(kept))
|
|
308
|
+
kept.append(finding)
|
|
309
|
+
witnesses.append([])
|
|
262
310
|
continue
|
|
263
311
|
label = f"{finding.scanner}:{finding.rule_id}"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
312
|
+
existing = kept[slot]
|
|
313
|
+
if label not in witnesses[slot] and label != f"{existing.scanner}:{existing.rule_id}":
|
|
314
|
+
witnesses[slot].append(label)
|
|
315
|
+
if finding.severity.rank > existing.severity.rank or (
|
|
316
|
+
finding.severity.rank == existing.severity.rank
|
|
317
|
+
and CONFIDENCE_RANK[finding.confidence] > CONFIDENCE_RANK[existing.confidence]
|
|
269
318
|
):
|
|
270
|
-
|
|
319
|
+
kept[slot] = finding
|
|
271
320
|
|
|
272
321
|
return [
|
|
273
|
-
replace(f, corroborated_by=tuple(
|
|
322
|
+
replace(f, corroborated_by=tuple(seen)) if seen else f
|
|
323
|
+
for f, seen in zip(kept, witnesses, strict=True)
|
|
274
324
|
]
|
|
@@ -8,9 +8,19 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def find_repo_root(start: Path) -> Path:
|
|
11
|
-
"""Walk up from `start` until a .git directory is found.
|
|
12
|
-
|
|
11
|
+
"""Walk up from `start` until a .git directory is found.
|
|
12
|
+
|
|
13
|
+
Returns the nearest directory when no git repo is present, so a tarball
|
|
14
|
+
or archive run is still supported.
|
|
15
|
+
|
|
16
|
+
**A root is always a directory.** Auditing a single file outside a git
|
|
17
|
+
repository used to return the file itself, and every path built under it
|
|
18
|
+
became nonsense — `secure-code-agent one.py` died writing its report to
|
|
19
|
+
`one.py/secure-code-report.md`.
|
|
20
|
+
"""
|
|
13
21
|
cur = start.resolve()
|
|
22
|
+
if not cur.is_dir():
|
|
23
|
+
cur = cur.parent
|
|
14
24
|
for parent in (cur, *cur.parents):
|
|
15
25
|
if (parent / ".git").exists():
|
|
16
26
|
return parent
|
|
@@ -107,7 +117,12 @@ def loc_under(
|
|
|
107
117
|
skip = {p.resolve() for p in skip}
|
|
108
118
|
primary = 0
|
|
109
119
|
test = 0
|
|
110
|
-
|
|
120
|
+
# `rglob` on a file yields nothing, so a single-file audit reported zero
|
|
121
|
+
# lines — and a zero denominator is not normalised at all, so the grade
|
|
122
|
+
# became the raw subtotal. Auditing one file is supported; it should be
|
|
123
|
+
# measured against that file.
|
|
124
|
+
candidates = root.rglob("*") if root.is_dir() else [root]
|
|
125
|
+
for path in candidates:
|
|
111
126
|
if not path.is_file():
|
|
112
127
|
continue
|
|
113
128
|
if path.resolve() in skip:
|
|
@@ -51,6 +51,12 @@ def to_json(
|
|
|
51
51
|
},
|
|
52
52
|
"gate": {
|
|
53
53
|
"passed": gate.passed,
|
|
54
|
+
# Which gates could actually have failed. Empty means no policy
|
|
55
|
+
# was configured, and `passed: true` then says only that nothing
|
|
56
|
+
# tripped — not that anything was checked. A consumer treating
|
|
57
|
+
# `passed` as a security signal needs to see this.
|
|
58
|
+
"configured": list(gate.configured),
|
|
59
|
+
"enforced": gate.enforced,
|
|
54
60
|
"reasons": list(gate.reasons),
|
|
55
61
|
"tripped": list(gate.tripped),
|
|
56
62
|
},
|
|
@@ -29,7 +29,11 @@ _SARIF_LEVEL = {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def emit(
|
|
32
|
+
def emit(
|
|
33
|
+
findings: Iterable[Finding],
|
|
34
|
+
coverage: CoverageReport | None = None,
|
|
35
|
+
axis_of=lambda _f: "primary",
|
|
36
|
+
) -> dict:
|
|
33
37
|
"""Build a SARIF 2.1.0 document from canonical findings."""
|
|
34
38
|
findings = list(findings)
|
|
35
39
|
|
|
@@ -41,7 +45,7 @@ def emit(findings: Iterable[Finding], coverage: CoverageReport | None = None) ->
|
|
|
41
45
|
rid = f.rule_id
|
|
42
46
|
if rid not in rule_meta:
|
|
43
47
|
rule_meta[rid] = _rule(f)
|
|
44
|
-
results.append(_result(f))
|
|
48
|
+
results.append(_result(f, axis_of(f)))
|
|
45
49
|
|
|
46
50
|
run = {
|
|
47
51
|
"tool": {
|
|
@@ -114,14 +118,63 @@ def _rule(f: Finding) -> dict:
|
|
|
114
118
|
return rule
|
|
115
119
|
|
|
116
120
|
|
|
117
|
-
|
|
121
|
+
#: Axes whose findings are reported but are not defects to raise an alert
|
|
122
|
+
#: for. Same set the work order files under §ACCEPT.
|
|
123
|
+
_SIDE_AXES = frozenset({"test tree", "documentation"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _suppressions(f: Finding, axis: str) -> list[dict] | None:
|
|
127
|
+
"""The SARIF-standard suppression array, or None to raise an alert.
|
|
128
|
+
|
|
129
|
+
SARIF is consumed by code-scanning platforms that turn each result into
|
|
130
|
+
an alert, and `suppressions` is the field they honour. We were writing
|
|
131
|
+
`properties.suppressed` instead — a field of our own invention that no
|
|
132
|
+
consumer reads — so two kinds of finding raised alerts they should not:
|
|
133
|
+
|
|
134
|
+
* findings the operator had explicitly suppressed in `.scignore.yaml`,
|
|
135
|
+
with a reason and an expiry, which is as clear a "do not alert me
|
|
136
|
+
about this" as exists;
|
|
137
|
+
* and every test-tree and documentation finding, which the product
|
|
138
|
+
itself reports as "not scored" and the work order files under §ACCEPT
|
|
139
|
+
with "do not patch these".
|
|
140
|
+
|
|
141
|
+
Auditing `maintainability-agent` produced 4,929 SARIF results of which
|
|
142
|
+
**4,847 were test fixtures**. Uploaded to code scanning that is 4,847
|
|
143
|
+
alerts for deliberately-vulnerable test data, burying the 82 findings in
|
|
144
|
+
the shipped source.
|
|
145
|
+
|
|
146
|
+
Suppressed, not omitted. The finding stays in the file with its
|
|
147
|
+
location and justification, so nothing is hidden from a reader — it
|
|
148
|
+
simply does not become someone's ticket.
|
|
149
|
+
"""
|
|
150
|
+
if f.suppressed:
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
"kind": "external",
|
|
154
|
+
"justification": f.suppression_note or "suppressed by operator configuration",
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
if axis in _SIDE_AXES:
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
"kind": "external",
|
|
161
|
+
"justification": (
|
|
162
|
+
f"reported on the {axis} axis: outside the shipped source, "
|
|
163
|
+
f"not scored as code condition, and not a patch target"
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _result(f: Finding, axis: str = "primary") -> dict:
|
|
118
171
|
region: dict = {"startLine": max(1, f.line_start)}
|
|
119
172
|
if f.line_end and f.line_end != f.line_start:
|
|
120
173
|
region["endLine"] = f.line_end
|
|
121
174
|
if f.code_snippet:
|
|
122
175
|
region["snippet"] = {"text": f.code_snippet}
|
|
123
176
|
|
|
124
|
-
|
|
177
|
+
result = {
|
|
125
178
|
"ruleId": f.rule_id,
|
|
126
179
|
"level": _SARIF_LEVEL[f.severity],
|
|
127
180
|
"message": {"text": f.message},
|
|
@@ -131,6 +184,7 @@ def _result(f: Finding) -> dict:
|
|
|
131
184
|
"category": f.category.value,
|
|
132
185
|
"is_new": f.is_new,
|
|
133
186
|
"suppressed": f.suppressed,
|
|
187
|
+
"axis": axis,
|
|
134
188
|
},
|
|
135
189
|
"locations": [
|
|
136
190
|
{
|
|
@@ -141,12 +195,19 @@ def _result(f: Finding) -> dict:
|
|
|
141
195
|
}
|
|
142
196
|
],
|
|
143
197
|
}
|
|
198
|
+
suppressions = _suppressions(f, axis)
|
|
199
|
+
if suppressions:
|
|
200
|
+
result["suppressions"] = suppressions
|
|
201
|
+
return result
|
|
144
202
|
|
|
145
203
|
|
|
146
204
|
def write(
|
|
147
|
-
findings: Iterable[Finding],
|
|
205
|
+
findings: Iterable[Finding],
|
|
206
|
+
output: Path,
|
|
207
|
+
coverage: CoverageReport | None = None,
|
|
208
|
+
axis_of=lambda _f: "primary",
|
|
148
209
|
) -> None:
|
|
149
|
-
output.write_text(json.dumps(emit(findings, coverage), indent=2), encoding="utf-8")
|
|
210
|
+
output.write_text(json.dumps(emit(findings, coverage, axis_of), indent=2), encoding="utf-8")
|
|
150
211
|
|
|
151
212
|
|
|
152
213
|
# ----- ingest -------------------------------------------------------------
|
|
@@ -156,7 +156,18 @@ class Scanner(ABC):
|
|
|
156
156
|
"""Run a scanner subprocess with sanitized env, no shell.
|
|
157
157
|
|
|
158
158
|
Some scanners (npm audit, pip-audit) exit nonzero on findings; pass
|
|
159
|
-
`allowed_exits` to mark those as success.
|
|
159
|
+
`allowed_exits` to mark those as success.
|
|
160
|
+
|
|
161
|
+
`cwd` is coerced to a directory. Auditing a single file is supported
|
|
162
|
+
— the CLI and several adapters carry `target if target.is_dir() else
|
|
163
|
+
target.parent` for exactly that — but every adapter passed the raw
|
|
164
|
+
target here, so `secure-code-agent path/to/one.py` died with
|
|
165
|
+
`NotADirectoryError` out of `subprocess.py` before any scanner ran.
|
|
166
|
+
Fixing it once here is better than asking fifteen adapters to
|
|
167
|
+
remember.
|
|
168
|
+
"""
|
|
169
|
+
if cwd is not None and not cwd.is_dir():
|
|
170
|
+
cwd = cwd.parent
|
|
160
171
|
env = self._sanitized_env()
|
|
161
172
|
try:
|
|
162
173
|
r = subprocess.run(
|
|
@@ -24,7 +24,15 @@ from secure_code_audit.scanners.base import Scanner
|
|
|
24
24
|
class CheckovScanner(Scanner):
|
|
25
25
|
name = "checkov"
|
|
26
26
|
binary = "checkov"
|
|
27
|
-
python_module
|
|
27
|
+
#: **No `python_module` fallback.** checkov ships no `__main__`, so
|
|
28
|
+
#: `python -m checkov` fails with "No module named checkov.__main__".
|
|
29
|
+
#: Declaring the fallback meant that with the package installed but the
|
|
30
|
+
#: console script off PATH, checkov resolved to a command that could
|
|
31
|
+
#: never run — reported as FAILED rather than the honest UNAVAILABLE.
|
|
32
|
+
#:
|
|
33
|
+
#: Caught by the test added alongside the identical semgrep defect,
|
|
34
|
+
#: running in CI where the full floor is installed. It passed locally
|
|
35
|
+
#: only because checkov was not installed on this machine.
|
|
28
36
|
default_category = Category.CONFIG_IAC
|
|
29
37
|
install_hint = "pip install 'secure-code-agent[python-scanners]'"
|
|
30
38
|
|
|
@@ -54,7 +54,20 @@ def _cwe_from_rule(rule: dict) -> str | None:
|
|
|
54
54
|
class SemgrepScanner(Scanner):
|
|
55
55
|
name = "semgrep"
|
|
56
56
|
binary = "semgrep"
|
|
57
|
-
python_module
|
|
57
|
+
#: **No `python_module` fallback.** Semgrep deprecated `python -m
|
|
58
|
+
#: semgrep` in 1.38.0: it now prints a notice, exits 0, and analyses
|
|
59
|
+
#: nothing. The fallback fires whenever the module is importable but the
|
|
60
|
+
#: binary is off PATH — which is the normal state after installing
|
|
61
|
+
#: njsscan, since that pulls semgrep in as a dependency.
|
|
62
|
+
#:
|
|
63
|
+
#: The outcome was FAILED rather than a silent COMPLETED, so coverage
|
|
64
|
+
#: caught it and no grade was claimed on an unrun scanner. But "semgrep
|
|
65
|
+
#: failed" is the wrong thing to tell an operator whose actual situation
|
|
66
|
+
#: is "semgrep is not on PATH", and it sent five of this project's own
|
|
67
|
+
#: tests from skipped to failing the moment njsscan was installed.
|
|
68
|
+
#:
|
|
69
|
+
#: bandit, njsscan, checkov and pip-audit all still run correctly under
|
|
70
|
+
#: `python -m`, so the mechanism stays; semgrep simply opts out.
|
|
58
71
|
default_category = Category.CODE_VULNERABILITIES
|
|
59
72
|
install_hint = "pip install 'secure-code-agent[python-scanners]'"
|
|
60
73
|
|
|
@@ -551,6 +551,26 @@ class GateResult:
|
|
|
551
551
|
passed: bool
|
|
552
552
|
reasons: tuple[str, ...] # human-readable trip reasons
|
|
553
553
|
tripped: tuple[str, ...] = field(default_factory=tuple)
|
|
554
|
+
#: Gates that could actually have failed this run. Empty means no
|
|
555
|
+
#: policy was configured, which is **not** the same as passing one.
|
|
556
|
+
#:
|
|
557
|
+
#: `passed` stays True in that case — no gate tripped, which is
|
|
558
|
+
#: accurate — but reporting it as PASS is a claim nothing checked. A
|
|
559
|
+
#: 200,000-line repository carrying SQL injection, `shell=True`,
|
|
560
|
+
#: `pickle.loads` and `eval` printed "gate PASS" out of the box, with
|
|
561
|
+
#: no configuration, because an absent gate cannot trip. The work
|
|
562
|
+
#: order listed all seven findings correctly at the same time.
|
|
563
|
+
#:
|
|
564
|
+
#: `_require_configured_gates` already refuses `--fail-on-gate` in this
|
|
565
|
+
#: situation, calling it "a green build with no security floor". This
|
|
566
|
+
#: is the same fact, carried far enough to reach the summary line a
|
|
567
|
+
#: person actually reads.
|
|
568
|
+
configured: tuple[str, ...] = field(default_factory=tuple)
|
|
569
|
+
|
|
570
|
+
@property
|
|
571
|
+
def enforced(self) -> bool:
|
|
572
|
+
"""Did any gate actually stand between these findings and a pass?"""
|
|
573
|
+
return bool(self.configured)
|
|
554
574
|
|
|
555
575
|
|
|
556
576
|
# Every gate, with the predicate that decides whether it can actually trip.
|
|
@@ -611,6 +631,7 @@ def evaluate_gates(
|
|
|
611
631
|
passed=not tripped,
|
|
612
632
|
reasons=tuple(reasons),
|
|
613
633
|
tripped=tuple(tripped),
|
|
634
|
+
configured=active_gates(gate_config),
|
|
614
635
|
)
|
|
615
636
|
|
|
616
637
|
|
|
@@ -630,3 +630,19 @@ def owasp_for_cwe(canonical_cwe: str | None) -> str | None:
|
|
|
630
630
|
if canonical_cwe is None:
|
|
631
631
|
return None
|
|
632
632
|
return _CWE_TO_OWASP.get(canonical_cwe)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def categories_for_scanner(scanner: str) -> frozenset[Category]:
|
|
636
|
+
"""Which categories this scanner has mapped rules for.
|
|
637
|
+
|
|
638
|
+
Used to work out what a run could have measured. Derived from the map
|
|
639
|
+
rather than written down a second time: the hand-maintained copy claimed
|
|
640
|
+
`builtin_rules` covered `secrets` and `config_iac`, which it never has —
|
|
641
|
+
its rules are Python and shell language primitives — and omitted
|
|
642
|
+
`supply_chain`, which it does cover. An IaC repository with a
|
|
643
|
+
`public-read` S3 bucket and an open security group therefore scored
|
|
644
|
+
`config_iac` 5.0 with no IaC scanner installed.
|
|
645
|
+
"""
|
|
646
|
+
return frozenset(
|
|
647
|
+
entry.category for (name, _rule), entry in _MAP.items() if name == scanner.lower()
|
|
648
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/requires.txt
RENAMED
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_agent.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/data/semgrep-offline.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/__init__.py
RENAMED
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/bandit_scanner.py
RENAMED
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/builtin_rules.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/gosec_scanner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/osv_scanner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.6.0 → secure_code_agent-0.8.0}/src/secure_code_audit/scanners/trivy_scanner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|