secure-code-agent 0.12.11__tar.gz → 0.12.12__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.12.11/src/secure_code_agent.egg-info → secure_code_agent-0.12.12}/PKG-INFO +3 -3
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/README.md +2 -2
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12/src/secure_code_agent.egg-info}/PKG-INFO +3 -3
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/cli.py +272 -123
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/config.py +88 -49
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/findings.py +22 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/practice.py +81 -42
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/remediation.py +1 -1
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/renderers.py +7 -1
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanner_status.py +10 -0
- secure_code_agent-0.12.12/src/secure_code_audit/scanners/_exclusions.py +94 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_finding_builder.py +32 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/bandit_scanner.py +27 -19
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/base.py +26 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/builtin_rules.py +3 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/checkov_scanner.py +19 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/gitleaks_scanner.py +7 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/gosec_scanner.py +7 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/hadolint_scanner.py +3 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/njsscan_scanner.py +6 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/npm_audit_scanner.py +14 -21
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/osv_scanner.py +13 -11
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/pip_audit_scanner.py +23 -18
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/rubocop_scanner.py +6 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/scorecard_scanner.py +6 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/semgrep_scanner.py +87 -51
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/trivy_scanner.py +45 -1
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/trufflehog_scanner.py +6 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scoring.py +92 -24
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/suppressions.py +63 -39
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/verify.py +20 -1
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/LICENSE +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/pyproject.toml +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/setup.cfg +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/requires.txt +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/capabilities.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/data/standards.yaml +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/demo.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/git_tools.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/mcp_server.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_execution.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/_resolution.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/standards.py +0 -0
- {secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_audit/triage.py +0 -0
{secure_code_agent-0.12.11/src/secure_code_agent.egg-info → secure_code_agent-0.12.12}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.12
|
|
4
4
|
Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
|
|
5
5
|
Author: Marshall Guillory
|
|
6
6
|
License: MIT
|
|
@@ -185,7 +185,7 @@ This is a constrained task, not a refactor.
|
|
|
185
185
|
unless a finding explicitly proves the current behavior is unsafe.
|
|
186
186
|
9. Add a focused test that exercises the specific security boundary
|
|
187
187
|
you fixed. The test must FAIL on the pre-fix code and PASS on
|
|
188
|
-
the post-fix code.
|
|
188
|
+
the post-fix code. Do not defer the test to a later change.
|
|
189
189
|
10. Keep the patch small. If you find yourself rewriting a function
|
|
190
190
|
rather than patching it, stop and report the structural issue.
|
|
191
191
|
|
|
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
554
554
|
## GitHub Action
|
|
555
555
|
|
|
556
556
|
```yaml
|
|
557
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
557
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.12
|
|
558
558
|
with:
|
|
559
559
|
config: secure-code-agent.json
|
|
560
560
|
fail-on-gate: true
|
|
@@ -136,7 +136,7 @@ This is a constrained task, not a refactor.
|
|
|
136
136
|
unless a finding explicitly proves the current behavior is unsafe.
|
|
137
137
|
9. Add a focused test that exercises the specific security boundary
|
|
138
138
|
you fixed. The test must FAIL on the pre-fix code and PASS on
|
|
139
|
-
the post-fix code.
|
|
139
|
+
the post-fix code. Do not defer the test to a later change.
|
|
140
140
|
10. Keep the patch small. If you find yourself rewriting a function
|
|
141
141
|
rather than patching it, stop and report the structural issue.
|
|
142
142
|
|
|
@@ -505,7 +505,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
505
505
|
## GitHub Action
|
|
506
506
|
|
|
507
507
|
```yaml
|
|
508
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
508
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.12
|
|
509
509
|
with:
|
|
510
510
|
config: secure-code-agent.json
|
|
511
511
|
fail-on-gate: true
|
{secure_code_agent-0.12.11 → secure_code_agent-0.12.12/src/secure_code_agent.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-code-agent
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.12
|
|
4
4
|
Summary: Deterministic security gate + bounded AI remediation prompt generator. NIST SSDF / OWASP ASVS / CWE Top 25 anchored.
|
|
5
5
|
Author: Marshall Guillory
|
|
6
6
|
License: MIT
|
|
@@ -185,7 +185,7 @@ This is a constrained task, not a refactor.
|
|
|
185
185
|
unless a finding explicitly proves the current behavior is unsafe.
|
|
186
186
|
9. Add a focused test that exercises the specific security boundary
|
|
187
187
|
you fixed. The test must FAIL on the pre-fix code and PASS on
|
|
188
|
-
the post-fix code.
|
|
188
|
+
the post-fix code. Do not defer the test to a later change.
|
|
189
189
|
10. Keep the patch small. If you find yourself rewriting a function
|
|
190
190
|
rather than patching it, stop and report the structural issue.
|
|
191
191
|
|
|
@@ -554,7 +554,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
554
554
|
## GitHub Action
|
|
555
555
|
|
|
556
556
|
```yaml
|
|
557
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
557
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.12
|
|
558
558
|
with:
|
|
559
559
|
config: secure-code-agent.json
|
|
560
560
|
fail-on-gate: true
|
{secure_code_agent-0.12.11 → secure_code_agent-0.12.12}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
@@ -33,6 +33,7 @@ src/secure_code_audit/verify.py
|
|
|
33
33
|
src/secure_code_audit/data/semgrep-offline.yaml
|
|
34
34
|
src/secure_code_audit/data/standards.yaml
|
|
35
35
|
src/secure_code_audit/scanners/__init__.py
|
|
36
|
+
src/secure_code_audit/scanners/_exclusions.py
|
|
36
37
|
src/secure_code_audit/scanners/_execution.py
|
|
37
38
|
src/secure_code_audit/scanners/_finding_builder.py
|
|
38
39
|
src/secure_code_audit/scanners/_resolution.py
|
|
@@ -10,7 +10,7 @@ import argparse
|
|
|
10
10
|
import contextlib
|
|
11
11
|
import json
|
|
12
12
|
import sys
|
|
13
|
-
from dataclasses import dataclass, replace
|
|
13
|
+
from dataclasses import MISSING, dataclass, fields, replace
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
from secure_code_audit import (
|
|
@@ -493,104 +493,46 @@ def _split_axes(
|
|
|
493
493
|
)
|
|
494
494
|
|
|
495
495
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
cfg = _refuse_target_policy(cfg, target)
|
|
500
|
-
_require_configured_gates(args, cfg)
|
|
501
|
-
if (failed := _install_standards_overlay(cfg, root)) is not None:
|
|
502
|
-
return failed
|
|
503
|
-
# Before anything is resolved or written, and before any scanner runs.
|
|
504
|
-
_assert_config_writes_are_contained(cfg, root, target)
|
|
505
|
-
# Resolved before the scan so the run can recognise its own artifacts.
|
|
506
|
-
paths = _resolve_outputs(args, cfg, root)
|
|
507
|
-
|
|
508
|
-
# ----- scanners -----
|
|
509
|
-
scan = _run_scanners(args, cfg, target)
|
|
510
|
-
all_findings = scan.findings
|
|
511
|
-
ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
|
|
512
|
-
|
|
513
|
-
# ----- SARIF imports -----
|
|
514
|
-
# An imported SARIF is coverage: a scanner someone else ran on our behalf.
|
|
515
|
-
imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
|
|
516
|
-
all_findings.extend(imported)
|
|
517
|
-
executions.extend(imported_executions)
|
|
518
|
-
|
|
519
|
-
# ----- one path convention, before anything reads a path -----
|
|
520
|
-
# Repository-relative from here on, whichever tool reported the finding.
|
|
521
|
-
# Suppressions, the baseline and every written output compare or publish
|
|
522
|
-
# this path, and none of them may depend on where the checkout lives.
|
|
523
|
-
scanned = target if target.is_dir() else target.parent
|
|
524
|
-
all_findings = anchor(all_findings, scanned=scanned, root=root)
|
|
525
|
-
|
|
526
|
-
# ----- scan scope, enforced once -----
|
|
527
|
-
own_artifacts = _own_artifacts(paths, cfg, root)
|
|
528
|
-
all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
|
|
529
|
-
|
|
530
|
-
# ----- one weakness, one finding -----
|
|
531
|
-
# Before overrides and suppressions, so an operator writing either one
|
|
532
|
-
# sees the same finding the report will show. Bandit's B308 and B703 are
|
|
533
|
-
# the same check under two ids and shared fifty lines in Django; a report
|
|
534
|
-
# that lists a line twice is wrong about the code, and a work order built
|
|
535
|
-
# from it would ask for the same fix twice.
|
|
536
|
-
all_findings = merge_corroborating(all_findings)
|
|
537
|
-
|
|
538
|
-
# ----- overrides from config -----
|
|
539
|
-
all_findings = _apply_overrides(all_findings, cfg)
|
|
496
|
+
@dataclass(frozen=True)
|
|
497
|
+
class _Scored:
|
|
498
|
+
"""What the scoring phase produces, and nothing else.
|
|
540
499
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
# instruction; ignoring it silently changes which findings are
|
|
547
|
-
# reported, and "my suppressions are working" then looks exactly
|
|
548
|
-
# like "my suppressions were skipped". This bit in practice: a
|
|
549
|
-
# missing PyYAML made an entire .scignore.yaml a no-op while the
|
|
550
|
-
# run still exited 0 and reported the suppressed finding as live.
|
|
551
|
-
#
|
|
552
|
-
# Only reachable when the file is present, so repositories that
|
|
553
|
-
# do not use suppressions are unaffected.
|
|
554
|
-
for err in sup_errors:
|
|
555
|
-
sys.stderr.write(f"ERROR: {err}\n")
|
|
556
|
-
sys.stderr.write(
|
|
557
|
-
f"ERROR: {suppression_path} exists but could not be applied; refusing to "
|
|
558
|
-
"report results that silently ignore it.\n"
|
|
559
|
-
)
|
|
560
|
-
return 1
|
|
561
|
-
all_findings = suppressions.apply(all_findings, sup_rules, root)
|
|
562
|
-
# Snapshot the scanner findings before the synthetic ones are appended.
|
|
563
|
-
# `unused_findings` asks whether each rule still has a subject, and a
|
|
564
|
-
# wildcard rule with `paths` would happily match the expired-suppression
|
|
565
|
-
# finding about `.scignore.yaml` itself — reporting itself as used
|
|
566
|
-
# because it matched a finding about suppressions.
|
|
567
|
-
scanner_findings = list(all_findings)
|
|
568
|
-
all_findings.extend(
|
|
569
|
-
anchor(
|
|
570
|
-
suppressions.expired_findings(sup_rules, suppression_path),
|
|
571
|
-
scanned=scanned,
|
|
572
|
-
root=root,
|
|
573
|
-
)
|
|
574
|
-
)
|
|
575
|
-
all_findings.extend(
|
|
576
|
-
anchor(
|
|
577
|
-
suppressions.unused_findings(sup_rules, scanner_findings, suppression_path, root),
|
|
578
|
-
scanned=scanned,
|
|
579
|
-
root=root,
|
|
580
|
-
)
|
|
581
|
-
)
|
|
500
|
+
The phase assigns 31 names and only these 8 are read afterwards —
|
|
501
|
+
measured, not guessed. The other 23 were locals in a 120-line stretch of
|
|
502
|
+
`_do_audit`, which is what `architecture.md` §6 means by "a straight-line
|
|
503
|
+
function threading a dozen locals". Same shape as `_Axes`.
|
|
504
|
+
"""
|
|
582
505
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
506
|
+
all_findings: list[Finding]
|
|
507
|
+
axes: object
|
|
508
|
+
coverage: object
|
|
509
|
+
declarations: object
|
|
510
|
+
gate: object
|
|
511
|
+
score: object
|
|
512
|
+
undeclared_score: object
|
|
513
|
+
verdict: object
|
|
586
514
|
|
|
587
|
-
# ----- baseline -----
|
|
588
|
-
baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
|
|
589
|
-
baseline = baseline_mod.load(baseline_path)
|
|
590
|
-
baseline_state = baseline_mod.state(baseline_path)
|
|
591
|
-
all_findings = baseline_mod.mark_new(all_findings, baseline, root)
|
|
592
515
|
|
|
593
|
-
|
|
516
|
+
def _score_everything(
|
|
517
|
+
all_findings: list[Finding],
|
|
518
|
+
args: argparse.Namespace,
|
|
519
|
+
cfg: config_mod.Config,
|
|
520
|
+
target: Path,
|
|
521
|
+
root: Path,
|
|
522
|
+
own_artifacts: object,
|
|
523
|
+
executions: list,
|
|
524
|
+
imported_executions: list,
|
|
525
|
+
baseline_state: object,
|
|
526
|
+
) -> _Scored:
|
|
527
|
+
"""Split the axes, score, gate, and build the verdict.
|
|
528
|
+
|
|
529
|
+
A pure move out of `_do_audit`: the same calls in the same order, with
|
|
530
|
+
the comments that record *why* each split exists kept where the code is.
|
|
531
|
+
Ordering is load-bearing throughout — classification happens before
|
|
532
|
+
suppression upstream, and the axis split happens before scoring here,
|
|
533
|
+
because a finding routed to the test tree must not be scored as shipped
|
|
534
|
+
code.
|
|
535
|
+
"""
|
|
594
536
|
_axes = _split_axes(all_findings, args, cfg, target, root, own_artifacts)
|
|
595
537
|
all_findings = _axes.all_findings
|
|
596
538
|
primary_findings, test_findings, docs_findings = _axes.primary, _axes.test, _axes.docs
|
|
@@ -710,6 +652,99 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
710
652
|
reasons=(*verdict.reasons, changed_note),
|
|
711
653
|
)
|
|
712
654
|
|
|
655
|
+
return _Scored(
|
|
656
|
+
all_findings=all_findings,
|
|
657
|
+
axes=axes,
|
|
658
|
+
coverage=coverage,
|
|
659
|
+
declarations=declarations,
|
|
660
|
+
gate=gate,
|
|
661
|
+
score=score,
|
|
662
|
+
undeclared_score=undeclared_score,
|
|
663
|
+
verdict=verdict,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _do_audit(args: argparse.Namespace) -> int:
|
|
668
|
+
cfg, target, root = _prepare_audit(args)
|
|
669
|
+
# Before any consumer reads them: the audited tree does not grade itself.
|
|
670
|
+
cfg = _refuse_target_policy(cfg, target)
|
|
671
|
+
_require_configured_gates(args, cfg)
|
|
672
|
+
if (failed := _install_standards_overlay(cfg, root)) is not None:
|
|
673
|
+
return failed
|
|
674
|
+
# Before anything is resolved or written, and before any scanner runs.
|
|
675
|
+
_assert_config_writes_are_contained(cfg, root, target)
|
|
676
|
+
# Resolved before the scan so the run can recognise its own artifacts.
|
|
677
|
+
paths = _resolve_outputs(args, cfg, root)
|
|
678
|
+
|
|
679
|
+
# ----- scanners -----
|
|
680
|
+
scan = _run_scanners(args, cfg, target)
|
|
681
|
+
all_findings = scan.findings
|
|
682
|
+
ran, unavailable, executions = scan.ran, scan.unavailable, scan.executions
|
|
683
|
+
|
|
684
|
+
# ----- SARIF imports -----
|
|
685
|
+
# An imported SARIF is coverage: a scanner someone else ran on our behalf.
|
|
686
|
+
imported, imported_executions = _ingest_sarif_imports(args.sarif_import)
|
|
687
|
+
all_findings.extend(imported)
|
|
688
|
+
executions.extend(imported_executions)
|
|
689
|
+
|
|
690
|
+
# ----- one path convention, before anything reads a path -----
|
|
691
|
+
# Repository-relative from here on, whichever tool reported the finding.
|
|
692
|
+
# Suppressions, the baseline and every written output compare or publish
|
|
693
|
+
# this path, and none of them may depend on where the checkout lives.
|
|
694
|
+
scanned = target if target.is_dir() else target.parent
|
|
695
|
+
all_findings = anchor(all_findings, scanned=scanned, root=root)
|
|
696
|
+
|
|
697
|
+
# ----- scan scope, enforced once -----
|
|
698
|
+
own_artifacts = _own_artifacts(paths, cfg, root)
|
|
699
|
+
all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
|
|
700
|
+
|
|
701
|
+
# ----- one weakness, one finding -----
|
|
702
|
+
# Before overrides and suppressions, so an operator writing either one
|
|
703
|
+
# sees the same finding the report will show. Bandit's B308 and B703 are
|
|
704
|
+
# the same check under two ids and shared fifty lines in Django; a report
|
|
705
|
+
# that lists a line twice is wrong about the code, and a work order built
|
|
706
|
+
# from it would ask for the same fix twice.
|
|
707
|
+
all_findings = merge_corroborating(all_findings)
|
|
708
|
+
|
|
709
|
+
# ----- overrides from config -----
|
|
710
|
+
all_findings = _apply_overrides(all_findings, cfg)
|
|
711
|
+
|
|
712
|
+
# ----- suppressions -----
|
|
713
|
+
suppressed = _apply_suppressions(all_findings, cfg, root, scanned)
|
|
714
|
+
if suppressed is None:
|
|
715
|
+
return 1
|
|
716
|
+
all_findings = suppressed
|
|
717
|
+
|
|
718
|
+
# ----- severity threshold filter -----
|
|
719
|
+
threshold = Severity.from_string(args.severity_threshold)
|
|
720
|
+
all_findings = [f for f in all_findings if f.severity.rank >= threshold.rank]
|
|
721
|
+
|
|
722
|
+
# ----- baseline -----
|
|
723
|
+
baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
|
|
724
|
+
baseline = baseline_mod.load(baseline_path)
|
|
725
|
+
baseline_state = baseline_mod.state(baseline_path)
|
|
726
|
+
all_findings = baseline_mod.mark_new(all_findings, baseline, root)
|
|
727
|
+
|
|
728
|
+
# ----- scoring -----
|
|
729
|
+
_scored = _score_everything(
|
|
730
|
+
all_findings,
|
|
731
|
+
args,
|
|
732
|
+
cfg,
|
|
733
|
+
target,
|
|
734
|
+
root,
|
|
735
|
+
own_artifacts,
|
|
736
|
+
executions,
|
|
737
|
+
imported_executions,
|
|
738
|
+
baseline_state,
|
|
739
|
+
)
|
|
740
|
+
all_findings = _scored.all_findings
|
|
741
|
+
axes = _scored.axes
|
|
742
|
+
coverage = _scored.coverage
|
|
743
|
+
declarations = _scored.declarations
|
|
744
|
+
gate = _scored.gate
|
|
745
|
+
score = _scored.score
|
|
746
|
+
undeclared_score = _scored.undeclared_score
|
|
747
|
+
verdict = _scored.verdict
|
|
713
748
|
# ----- write outputs -----
|
|
714
749
|
# The pillar artifact is what maintainability-agent ingests (D3). Built
|
|
715
750
|
# here rather than inside a renderer because it needs the practice level,
|
|
@@ -732,26 +767,8 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
732
767
|
baseline_mod.write(baseline_path, all_findings, baseline, root)
|
|
733
768
|
|
|
734
769
|
# ----- trend -----
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
history_path = _under_root(root, cfg.outputs.get("history_path") or "")
|
|
738
|
-
if cfg.outputs.get("history_path"):
|
|
739
|
-
history_mod.append(
|
|
740
|
-
history_path,
|
|
741
|
-
history_mod.entry_from(
|
|
742
|
-
version=__version__,
|
|
743
|
-
score=score,
|
|
744
|
-
gate=gate,
|
|
745
|
-
coverage=coverage,
|
|
746
|
-
finding_count=len([f for f in all_findings if not f.suppressed]),
|
|
747
|
-
scanners=tuple(ran),
|
|
748
|
-
),
|
|
749
|
-
)
|
|
750
|
-
|
|
751
|
-
trend_line = (
|
|
752
|
-
history_mod.trend(history_mod.read(history_path))
|
|
753
|
-
if cfg.outputs.get("history_path")
|
|
754
|
-
else None
|
|
770
|
+
trend_line = _record_trend(
|
|
771
|
+
cfg, root, all_findings, score=score, gate=gate, coverage=coverage, ran=ran
|
|
755
772
|
)
|
|
756
773
|
|
|
757
774
|
# ----- did the work order actually improve anything? -----
|
|
@@ -1036,7 +1053,25 @@ def _drop_excluded(
|
|
|
1036
1053
|
continue
|
|
1037
1054
|
if path.resolve() in own_artifacts:
|
|
1038
1055
|
continue
|
|
1039
|
-
|
|
1056
|
+
# A dependency advisory outlives the exclusion of the file it is filed
|
|
1057
|
+
# against, for the reason `_classify` already gives one layer up: the
|
|
1058
|
+
# finding is about the pinned package, and the manifest or lockfile is
|
|
1059
|
+
# only the one file there is to point at. A pattern matching that path
|
|
1060
|
+
# was never a statement about the package.
|
|
1061
|
+
#
|
|
1062
|
+
# `**/*.lock` is the ordinary case, and it is in this repository's own
|
|
1063
|
+
# config — a lockfile is generated, enormous, and full of hashes that
|
|
1064
|
+
# read like secrets. The effect was that trivy's 19 advisories against
|
|
1065
|
+
# `uv.lock`, one CRITICAL, never reached the report, and the
|
|
1066
|
+
# `dependencies` category graded 5.0: a perfect score for a category
|
|
1067
|
+
# whose only evidence source was excluded.
|
|
1068
|
+
#
|
|
1069
|
+
# Everything else in that file stays excluded — a secret or a code
|
|
1070
|
+
# finding inside it is about the file, which is what the operator
|
|
1071
|
+
# asked to be rid of. D34's scope note, and the question §8 asked.
|
|
1072
|
+
if finding.category is not Category.DEPENDENCIES and is_excluded(
|
|
1073
|
+
path, root, cfg.exclude_patterns
|
|
1074
|
+
):
|
|
1040
1075
|
continue
|
|
1041
1076
|
kept.append(finding)
|
|
1042
1077
|
return kept
|
|
@@ -1177,7 +1212,26 @@ def _install_standards_overlay(cfg: config_mod.Config, root: Path) -> int | None
|
|
|
1177
1212
|
#: `SEVERITY_WEIGHT`, so re-labelling removes a finding from the score while
|
|
1178
1213
|
#: leaving it in the report. `category_overrides` moves it between the
|
|
1179
1214
|
#: per-category rates, and the worst category drives the overall.
|
|
1180
|
-
|
|
1215
|
+
#: `loc_for_scoring` is the denominator. Every category but `secrets` is a
|
|
1216
|
+
#: density — weighted findings per thousand scanned lines — so a tree that
|
|
1217
|
+
#: declares its own line count declares its own grade, and declaring ten
|
|
1218
|
+
#: million lines took the same one-finding fixture from 0.00/F to 4.999/A+
|
|
1219
|
+
#: with no flag and no warning. It was missed the first time because this
|
|
1220
|
+
#: tuple was written as a list of the two keys the attack had been
|
|
1221
|
+
#: demonstrated through, rather than derived from what the attack *is*.
|
|
1222
|
+
_GRADING_KEYS = ("severity_overrides", "category_overrides", "loc_for_scoring")
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _unset_value(key: str) -> object:
|
|
1226
|
+
"""What this config field holds when no config set it.
|
|
1227
|
+
|
|
1228
|
+
Read from `Config`'s own dataclass fields, so "ignore this key" means
|
|
1229
|
+
exactly "behave as though the file had not mentioned it" — including for
|
|
1230
|
+
a key whose empty state is `None` rather than `{}`, which is the kind of
|
|
1231
|
+
difference a hand-written clearing expression gets wrong.
|
|
1232
|
+
"""
|
|
1233
|
+
spec = next(f for f in fields(config_mod.Config) if f.name == key)
|
|
1234
|
+
return spec.default_factory() if spec.default_factory is not MISSING else spec.default
|
|
1181
1235
|
|
|
1182
1236
|
|
|
1183
1237
|
def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Config:
|
|
@@ -1214,7 +1268,93 @@ def _refuse_target_policy(cfg: config_mod.Config, target: Path) -> config_mod.Co
|
|
|
1214
1268
|
f"changes the grade, so it was ignored. Pass --trust-target-config, or "
|
|
1215
1269
|
f"keep the configuration outside the tree, to apply it.\n"
|
|
1216
1270
|
)
|
|
1217
|
-
|
|
1271
|
+
# Cleared to each field's own default, derived from `_GRADING_KEYS` rather
|
|
1272
|
+
# than restated. The two lists used to be written out separately — the
|
|
1273
|
+
# warning loop read the tuple and the `replace` named two keys by hand —
|
|
1274
|
+
# so adding a third key told the operator it had been ignored and then
|
|
1275
|
+
# applied it anyway. A warning that says the opposite of what happened is
|
|
1276
|
+
# worse than no warning, and `test_every_grading_key_is_actually_cleared`
|
|
1277
|
+
# is what holds the two together now.
|
|
1278
|
+
return replace(cfg, **{key: _unset_value(key) for key in ignored})
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _record_trend(
|
|
1282
|
+
cfg: config_mod.Config,
|
|
1283
|
+
root: Path,
|
|
1284
|
+
findings: list[Finding],
|
|
1285
|
+
*,
|
|
1286
|
+
score: object,
|
|
1287
|
+
gate: object,
|
|
1288
|
+
coverage: object,
|
|
1289
|
+
ran: list[str],
|
|
1290
|
+
) -> str | None:
|
|
1291
|
+
"""Append this run to the history, and return the trend line to print.
|
|
1292
|
+
|
|
1293
|
+
Called before the verification branch returns, so a `--verify-against`
|
|
1294
|
+
run is recorded too: repair is the movement most worth seeing in a
|
|
1295
|
+
trend.
|
|
1296
|
+
|
|
1297
|
+
None when no `history_path` is configured — a repository that keeps no
|
|
1298
|
+
history gets no trend line rather than an empty one, because "no
|
|
1299
|
+
movement" and "nothing recorded" are different statements.
|
|
1300
|
+
"""
|
|
1301
|
+
configured = cfg.outputs.get("history_path")
|
|
1302
|
+
if not configured:
|
|
1303
|
+
return None
|
|
1304
|
+
history_path = _under_root(root, configured)
|
|
1305
|
+
history_mod.append(
|
|
1306
|
+
history_path,
|
|
1307
|
+
history_mod.entry_from(
|
|
1308
|
+
version=__version__,
|
|
1309
|
+
score=score,
|
|
1310
|
+
gate=gate,
|
|
1311
|
+
coverage=coverage,
|
|
1312
|
+
finding_count=len([f for f in findings if not f.suppressed]),
|
|
1313
|
+
scanners=tuple(ran),
|
|
1314
|
+
),
|
|
1315
|
+
)
|
|
1316
|
+
return history_mod.trend(history_mod.read(history_path))
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _apply_suppressions(
|
|
1320
|
+
findings: list[Finding], cfg: config_mod.Config, root: Path, scanned: Path
|
|
1321
|
+
) -> list[Finding] | None:
|
|
1322
|
+
"""Findings with suppressions applied, or None when the file is unusable.
|
|
1323
|
+
|
|
1324
|
+
None means the caller exits 1. **Fail closed**: a suppression file that
|
|
1325
|
+
exists is an explicit instruction, and ignoring it silently changes which
|
|
1326
|
+
findings are reported — "my suppressions are working" then looks exactly
|
|
1327
|
+
like "my suppressions were skipped". That bit in practice, when a missing
|
|
1328
|
+
PyYAML made an entire `.scignore.yaml` a no-op while the run still exited
|
|
1329
|
+
0 and reported the suppressed finding as live.
|
|
1330
|
+
|
|
1331
|
+
Only reachable when the file is present, so repositories that do not use
|
|
1332
|
+
suppressions are unaffected.
|
|
1333
|
+
"""
|
|
1334
|
+
suppression_path = _under_root(root, cfg.suppressions_file)
|
|
1335
|
+
rules, errors = suppressions.load(suppression_path)
|
|
1336
|
+
if errors:
|
|
1337
|
+
for err in errors:
|
|
1338
|
+
sys.stderr.write(f"ERROR: {err}\n")
|
|
1339
|
+
sys.stderr.write(
|
|
1340
|
+
f"ERROR: {suppression_path} exists but could not be applied; refusing to "
|
|
1341
|
+
"report results that silently ignore it.\n"
|
|
1342
|
+
)
|
|
1343
|
+
return None
|
|
1344
|
+
|
|
1345
|
+
applied = suppressions.apply(findings, rules, root)
|
|
1346
|
+
# The scanner findings, snapshotted before the synthetic ones are
|
|
1347
|
+
# appended. `unused_findings` asks whether each rule still has a subject,
|
|
1348
|
+
# and a wildcard rule with `paths` would happily match the
|
|
1349
|
+
# expired-suppression finding about `.scignore.yaml` itself — reporting
|
|
1350
|
+
# itself as used because it matched a finding about suppressions.
|
|
1351
|
+
scanner_findings = list(applied)
|
|
1352
|
+
for synthetic in (
|
|
1353
|
+
suppressions.expired_findings(rules, suppression_path),
|
|
1354
|
+
suppressions.unused_findings(rules, scanner_findings, suppression_path, root),
|
|
1355
|
+
):
|
|
1356
|
+
applied.extend(anchor(synthetic, scanned=scanned, root=root))
|
|
1357
|
+
return applied
|
|
1218
1358
|
|
|
1219
1359
|
|
|
1220
1360
|
def _apply_overrides(findings: list[Finding], cfg: config_mod.Config) -> list[Finding]:
|
|
@@ -1408,11 +1548,20 @@ def _declaration_delta(score, undeclared_score, declarations) -> str | None:
|
|
|
1408
1548
|
named = ", ".join(
|
|
1409
1549
|
f"{name} ({count})" for name, count in sorted(declarations.accounted.items()) if count
|
|
1410
1550
|
)
|
|
1411
|
-
line = (
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1551
|
+
line = f"declared: {declarations.total_accounted} finding(s) accounted for by {named}"
|
|
1552
|
+
if undeclared_score.overall is None:
|
|
1553
|
+
# No grade either way, so there is no cost to state. Reachable since
|
|
1554
|
+
# D33: nothing counted as scanned, every finding suppressed so no
|
|
1555
|
+
# category has a count — and `summarize_declarations` counts matched
|
|
1556
|
+
# findings without skipping suppressed ones, so the accounting is
|
|
1557
|
+
# non-empty while the grade is absent. `:.2f` on that raised, in
|
|
1558
|
+
# shipped code, on the summary path.
|
|
1559
|
+
#
|
|
1560
|
+
# The accounting is still printed. It is true, it is the disclosure
|
|
1561
|
+
# this mechanism exists for, and dropping the line would hide it to
|
|
1562
|
+
# avoid admitting there is no number.
|
|
1563
|
+
return f"{line}; no grade either way, so no without-declarations comparison"
|
|
1564
|
+
line += f"; without declarations {undeclared_score.overall:.2f} ({undeclared_score.letter})"
|
|
1416
1565
|
if declarations.unexercised:
|
|
1417
1566
|
# A declaration that matched nothing describes something this project
|
|
1418
1567
|
# does not do. Named rather than dropped, so a config cannot be padded
|