secure-code-agent 0.12.0__tar.gz → 0.12.2__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.0/src/secure_code_agent.egg-info → secure_code_agent-0.12.2}/PKG-INFO +7 -5
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/README.md +6 -4
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2/src/secure_code_agent.egg-info}/PKG-INFO +7 -5
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/__init__.py +1 -1
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/baseline.py +33 -6
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/cli.py +34 -10
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/findings.py +104 -1
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/remediation.py +8 -7
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/renderers.py +20 -6
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/base.py +3 -33
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/suppressions.py +38 -8
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/triage.py +16 -11
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/verify.py +8 -2
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/LICENSE +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/pyproject.toml +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/setup.cfg +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/requires.txt +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/config.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/demo.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/git_tools.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/mcp_server.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scoring.py +0 -0
- {secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/standards.py +0 -0
{secure_code_agent-0.12.0/src/secure_code_agent.egg-info → secure_code_agent-0.12.2}/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.2
|
|
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
|
|
@@ -489,9 +489,11 @@ secure-code-agent --sarif-import codeql-results.sarif \
|
|
|
489
489
|
--sarif-import snyk-results.sarif
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
`--changed-only`
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
`--changed-only REF` scopes the **report** to files changed since `REF`. The
|
|
493
|
+
whole tree is still scanned — scanners read trees, not diffs — and **no grade
|
|
494
|
+
is issued**, because a run that looks at less must not score better. A ref git
|
|
495
|
+
cannot resolve is an error rather than an empty diff, since "nothing changed"
|
|
496
|
+
and "your ref is wrong" otherwise produce the same finding count.
|
|
495
497
|
|
|
496
498
|
The current orchestrator accepts one repository root per invocation. Multiple
|
|
497
499
|
positional roots fail with exit code 2 instead of silently ignoring coverage.
|
|
@@ -509,7 +511,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
509
511
|
## GitHub Action
|
|
510
512
|
|
|
511
513
|
```yaml
|
|
512
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
514
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.2
|
|
513
515
|
with:
|
|
514
516
|
config: secure-code-agent.json
|
|
515
517
|
fail-on-gate: true
|
|
@@ -441,9 +441,11 @@ secure-code-agent --sarif-import codeql-results.sarif \
|
|
|
441
441
|
--sarif-import snyk-results.sarif
|
|
442
442
|
```
|
|
443
443
|
|
|
444
|
-
`--changed-only`
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
`--changed-only REF` scopes the **report** to files changed since `REF`. The
|
|
445
|
+
whole tree is still scanned — scanners read trees, not diffs — and **no grade
|
|
446
|
+
is issued**, because a run that looks at less must not score better. A ref git
|
|
447
|
+
cannot resolve is an error rather than an empty diff, since "nothing changed"
|
|
448
|
+
and "your ref is wrong" otherwise produce the same finding count.
|
|
447
449
|
|
|
448
450
|
The current orchestrator accepts one repository root per invocation. Multiple
|
|
449
451
|
positional roots fail with exit code 2 instead of silently ignoring coverage.
|
|
@@ -461,7 +463,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
461
463
|
## GitHub Action
|
|
462
464
|
|
|
463
465
|
```yaml
|
|
464
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
466
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.2
|
|
465
467
|
with:
|
|
466
468
|
config: secure-code-agent.json
|
|
467
469
|
fail-on-gate: true
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2/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.2
|
|
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
|
|
@@ -489,9 +489,11 @@ secure-code-agent --sarif-import codeql-results.sarif \
|
|
|
489
489
|
--sarif-import snyk-results.sarif
|
|
490
490
|
```
|
|
491
491
|
|
|
492
|
-
`--changed-only`
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
`--changed-only REF` scopes the **report** to files changed since `REF`. The
|
|
493
|
+
whole tree is still scanned — scanners read trees, not diffs — and **no grade
|
|
494
|
+
is issued**, because a run that looks at less must not score better. A ref git
|
|
495
|
+
cannot resolve is an error rather than an empty diff, since "nothing changed"
|
|
496
|
+
and "your ref is wrong" otherwise produce the same finding count.
|
|
495
497
|
|
|
496
498
|
The current orchestrator accepts one repository root per invocation. Multiple
|
|
497
499
|
positional roots fail with exit code 2 instead of silently ignoring coverage.
|
|
@@ -509,7 +511,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
509
511
|
## GitHub Action
|
|
510
512
|
|
|
511
513
|
```yaml
|
|
512
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
514
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.2
|
|
513
515
|
with:
|
|
514
516
|
config: secure-code-agent.json
|
|
515
517
|
fail-on-gate: true
|
|
@@ -85,9 +85,18 @@ def load(path: Path) -> dict[str, BaselineEntry]:
|
|
|
85
85
|
return entries
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def write(
|
|
88
|
+
def write(
|
|
89
|
+
path: Path,
|
|
90
|
+
findings: Iterable[Finding],
|
|
91
|
+
existing: dict[str, BaselineEntry],
|
|
92
|
+
root: Path | None = None,
|
|
93
|
+
) -> None:
|
|
89
94
|
"""Rewrite the baseline file. Existing entries' first_seen + bumped_by
|
|
90
|
-
are preserved so a baseline bump only mutates net-new entries.
|
|
95
|
+
are preserved so a baseline bump only mutates net-new entries.
|
|
96
|
+
|
|
97
|
+
Entries are written under the repository-relative fingerprint. An entry
|
|
98
|
+
an earlier release recorded under the absolute-path fingerprint keeps its
|
|
99
|
+
history and is rewritten in the portable form."""
|
|
91
100
|
now_iso = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
92
101
|
operator = _git_user_email()
|
|
93
102
|
|
|
@@ -95,7 +104,7 @@ def write(path: Path, findings: Iterable[Finding], existing: dict[str, BaselineE
|
|
|
95
104
|
for f in findings:
|
|
96
105
|
if f.suppressed:
|
|
97
106
|
continue
|
|
98
|
-
prev =
|
|
107
|
+
prev = _entry_for(f, existing, root)
|
|
99
108
|
entries[f.fingerprint] = {
|
|
100
109
|
"rule_id": f.rule_id,
|
|
101
110
|
"severity": f.severity.value,
|
|
@@ -121,18 +130,36 @@ def write(path: Path, findings: Iterable[Finding], existing: dict[str, BaselineE
|
|
|
121
130
|
)
|
|
122
131
|
|
|
123
132
|
|
|
124
|
-
def mark_new(
|
|
133
|
+
def mark_new(
|
|
134
|
+
findings: list[Finding], baseline: dict[str, BaselineEntry], root: Path | None = None
|
|
135
|
+
) -> list[Finding]:
|
|
125
136
|
"""Set is_new=True on findings whose fingerprint is not in the baseline.
|
|
126
137
|
Returns a new list with mutated Finding objects (frozen dataclass →
|
|
127
|
-
replace).
|
|
138
|
+
replace).
|
|
139
|
+
|
|
140
|
+
**A baseline from 0.12.1 or earlier still matches.** Those releases
|
|
141
|
+
fingerprinted absolute paths, and an upgrade must not report every
|
|
142
|
+
acknowledged finding as new and trip `fail_on_new` on a tree nobody
|
|
143
|
+
changed. With `root`, the fingerprint those releases would have recorded
|
|
144
|
+
is accepted too. It only ever matched a checkout at that same path, so it
|
|
145
|
+
matches nothing it did not match before."""
|
|
128
146
|
from dataclasses import replace
|
|
129
147
|
|
|
130
148
|
out: list[Finding] = []
|
|
131
149
|
for f in findings:
|
|
132
|
-
out.append(replace(f, is_new=f
|
|
150
|
+
out.append(replace(f, is_new=_entry_for(f, baseline, root) is None))
|
|
133
151
|
return out
|
|
134
152
|
|
|
135
153
|
|
|
154
|
+
def _entry_for(
|
|
155
|
+
finding: Finding, baseline: dict[str, BaselineEntry], root: Path | None
|
|
156
|
+
) -> BaselineEntry | None:
|
|
157
|
+
entry = baseline.get(finding.fingerprint)
|
|
158
|
+
if entry is None and root is not None:
|
|
159
|
+
entry = baseline.get(finding.legacy_fingerprint(root))
|
|
160
|
+
return entry
|
|
161
|
+
|
|
162
|
+
|
|
136
163
|
def _git_user_email() -> str:
|
|
137
164
|
git = shutil.which("git")
|
|
138
165
|
if git is None:
|
|
@@ -35,6 +35,7 @@ from secure_code_audit.findings import (
|
|
|
35
35
|
Confidence,
|
|
36
36
|
Finding,
|
|
37
37
|
Severity,
|
|
38
|
+
anchor,
|
|
38
39
|
merge_corroborating,
|
|
39
40
|
)
|
|
40
41
|
from secure_code_audit.git_tools import find_repo_root, is_excluded, is_test_path, loc_under
|
|
@@ -349,9 +350,16 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
349
350
|
all_findings.extend(imported)
|
|
350
351
|
executions.extend(imported_executions)
|
|
351
352
|
|
|
353
|
+
# ----- one path convention, before anything reads a path -----
|
|
354
|
+
# Repository-relative from here on, whichever tool reported the finding.
|
|
355
|
+
# Suppressions, the baseline and every written output compare or publish
|
|
356
|
+
# this path, and none of them may depend on where the checkout lives.
|
|
357
|
+
scanned = target if target.is_dir() else target.parent
|
|
358
|
+
all_findings = anchor(all_findings, scanned=scanned, root=root)
|
|
359
|
+
|
|
352
360
|
# ----- scan scope, enforced once -----
|
|
353
361
|
own_artifacts = _own_artifacts(paths, cfg, root)
|
|
354
|
-
all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts)
|
|
362
|
+
all_findings = _drop_excluded(all_findings, target, cfg, own_artifacts, root)
|
|
355
363
|
|
|
356
364
|
# ----- one weakness, one finding -----
|
|
357
365
|
# Before overrides and suppressions, so an operator writing either one
|
|
@@ -384,8 +392,14 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
384
392
|
"report results that silently ignore it.\n"
|
|
385
393
|
)
|
|
386
394
|
return 1
|
|
387
|
-
all_findings = suppressions.apply(all_findings, sup_rules)
|
|
388
|
-
all_findings.extend(
|
|
395
|
+
all_findings = suppressions.apply(all_findings, sup_rules, root)
|
|
396
|
+
all_findings.extend(
|
|
397
|
+
anchor(
|
|
398
|
+
suppressions.expired_findings(sup_rules, suppression_path),
|
|
399
|
+
scanned=scanned,
|
|
400
|
+
root=root,
|
|
401
|
+
)
|
|
402
|
+
)
|
|
389
403
|
|
|
390
404
|
# ----- severity threshold filter -----
|
|
391
405
|
threshold = Severity.from_string(args.severity_threshold)
|
|
@@ -395,7 +409,7 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
395
409
|
baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
|
|
396
410
|
baseline = baseline_mod.load(baseline_path)
|
|
397
411
|
baseline_state = baseline_mod.state(baseline_path)
|
|
398
|
-
all_findings = baseline_mod.mark_new(all_findings, baseline)
|
|
412
|
+
all_findings = baseline_mod.mark_new(all_findings, baseline, root)
|
|
399
413
|
|
|
400
414
|
# ----- scoring -----
|
|
401
415
|
# The test tree is reported, not scored. A project graded on its test
|
|
@@ -423,9 +437,13 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
423
437
|
# so this changes where a finding is *reported*, not the grade.
|
|
424
438
|
if finding.category is Category.DEPENDENCIES:
|
|
425
439
|
return "primary"
|
|
426
|
-
|
|
440
|
+
# Joined onto the repository root: the patterns are relative to the
|
|
441
|
+
# scan target, which is not the repository root for a subdirectory
|
|
442
|
+
# audit.
|
|
443
|
+
located = root / finding.file_path
|
|
444
|
+
if is_test_path(located, root_for_tests, cfg.test_patterns):
|
|
427
445
|
return "test tree"
|
|
428
|
-
if is_test_path(
|
|
446
|
+
if is_test_path(located, root_for_tests, cfg.docs_patterns):
|
|
429
447
|
return "documentation"
|
|
430
448
|
return "primary"
|
|
431
449
|
|
|
@@ -516,7 +534,7 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
516
534
|
# forever.
|
|
517
535
|
untriaged = 0
|
|
518
536
|
if baseline_state is baseline_mod.State.ABSENT:
|
|
519
|
-
tiers = triage.partition(all_findings, lambda f: renderers.axis_of(f, axes))
|
|
537
|
+
tiers = triage.partition(all_findings, lambda f: renderers.axis_of(f, axes), root)
|
|
520
538
|
untriaged = len(tiers[triage.Tier.FIX]) + len(tiers[triage.Tier.REVIEW])
|
|
521
539
|
verdict = build_verdict(score, cfg.gates, coverage, untriaged)
|
|
522
540
|
if changed_note is not None:
|
|
@@ -549,7 +567,7 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
549
567
|
root,
|
|
550
568
|
)
|
|
551
569
|
if args.bump_baseline:
|
|
552
|
-
baseline_mod.write(baseline_path, all_findings, baseline)
|
|
570
|
+
baseline_mod.write(baseline_path, all_findings, baseline, root)
|
|
553
571
|
|
|
554
572
|
# ----- trend -----
|
|
555
573
|
# Appended before the verification branch returns, so a verify run is
|
|
@@ -798,6 +816,7 @@ def _drop_excluded(
|
|
|
798
816
|
target: Path,
|
|
799
817
|
cfg: config_mod.Config,
|
|
800
818
|
own_artifacts: frozenset[Path] = frozenset(),
|
|
819
|
+
repository: Path | None = None,
|
|
801
820
|
) -> list[Finding]:
|
|
802
821
|
"""Enforce `paths.exclude_patterns` on findings, not just on file discovery.
|
|
803
822
|
|
|
@@ -820,14 +839,19 @@ def _drop_excluded(
|
|
|
820
839
|
dropping "bandit could not run" because the root matched a pattern would
|
|
821
840
|
turn a failed scanner back into a silent one — the defect this whole
|
|
822
841
|
project exists to prevent.
|
|
842
|
+
|
|
843
|
+
Finding paths are repository-relative (`findings.anchor`), so each is
|
|
844
|
+
joined onto `repository` before it is compared with anything on disk.
|
|
845
|
+
Resolving it bare would anchor it to the process working directory.
|
|
823
846
|
"""
|
|
824
847
|
if not cfg.exclude_patterns and not own_artifacts:
|
|
825
848
|
return findings
|
|
826
849
|
|
|
827
850
|
root = target if target.is_dir() else target.parent
|
|
851
|
+
repository = repository or root
|
|
828
852
|
kept: list[Finding] = []
|
|
829
853
|
for finding in findings:
|
|
830
|
-
path = finding.file_path
|
|
854
|
+
path = repository / finding.file_path
|
|
831
855
|
is_control = path in (target, root)
|
|
832
856
|
if is_control:
|
|
833
857
|
kept.append(finding)
|
|
@@ -1297,7 +1321,7 @@ def _restrict_to_changed(
|
|
|
1297
1321
|
|
|
1298
1322
|
def touched(finding: Finding) -> bool:
|
|
1299
1323
|
try:
|
|
1300
|
-
return finding.file_path.resolve() in absolute
|
|
1324
|
+
return (root / finding.file_path).resolve() in absolute
|
|
1301
1325
|
except OSError:
|
|
1302
1326
|
return False
|
|
1303
1327
|
|
|
@@ -14,9 +14,11 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import enum
|
|
16
16
|
import hashlib
|
|
17
|
+
import os
|
|
17
18
|
from collections.abc import Iterable
|
|
18
19
|
from dataclasses import dataclass, field, replace
|
|
19
20
|
from pathlib import Path
|
|
21
|
+
from urllib.parse import unquote, urlparse
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class Severity(str, enum.Enum):
|
|
@@ -166,7 +168,9 @@ class Finding:
|
|
|
166
168
|
Inputs:
|
|
167
169
|
- canonical_cwe falls back to rule_id when no CWE is mapped (so
|
|
168
170
|
unmapped findings still remain distinct per rule).
|
|
169
|
-
- file_path is POSIX-normalized for cross-platform stability
|
|
171
|
+
- file_path is POSIX-normalized for cross-platform stability, and
|
|
172
|
+
repository-relative once `anchor` has run, so the id does not
|
|
173
|
+
depend on where the checkout lives.
|
|
170
174
|
- code_snippet is normalized (whitespace collapsed, max 512 chars)
|
|
171
175
|
so a reformat-only edit doesn't break the fingerprint.
|
|
172
176
|
"""
|
|
@@ -176,6 +180,105 @@ class Finding:
|
|
|
176
180
|
material = f"{key}|{path}|{snippet_norm}".encode()
|
|
177
181
|
return hashlib.sha256(material).hexdigest()[:16]
|
|
178
182
|
|
|
183
|
+
def legacy_fingerprint(self, root: Path) -> str:
|
|
184
|
+
"""The id 0.12.1 and earlier gave this finding, when paths were absolute.
|
|
185
|
+
|
|
186
|
+
Those releases fingerprinted the absolute path, so a baseline or a
|
|
187
|
+
`fingerprint:` suppression written by one of them holds this value.
|
|
188
|
+
Read, never written: accepting it keeps an existing baseline matching
|
|
189
|
+
where it matched before, and `--bump-baseline` rewrites it in the
|
|
190
|
+
portable form.
|
|
191
|
+
"""
|
|
192
|
+
return Finding.make_fingerprint(
|
|
193
|
+
canonical_cwe=self.canonical_cwe,
|
|
194
|
+
rule_id=self.rule_id,
|
|
195
|
+
file_path=root / self.file_path,
|
|
196
|
+
code_snippet=self.code_snippet,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Location
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def repository_path(path: Path, *, scanned: Path, root: Path) -> Path:
|
|
206
|
+
"""Where a scanner-reported path is, relative to the repository root.
|
|
207
|
+
|
|
208
|
+
Scanners do not agree. Bandit, Semgrep and gitleaks' `dir` pass report
|
|
209
|
+
absolute paths; gitleaks' `git` pass and the SARIF-ingesting adapters
|
|
210
|
+
report paths relative to the tree they were given; an imported SARIF may
|
|
211
|
+
spell a location as a `file://` URI. A relative report is taken as
|
|
212
|
+
relative to `scanned`, the directory the scanner was pointed at.
|
|
213
|
+
|
|
214
|
+
A location outside `root` keeps its absolute path. An imported SARIF may
|
|
215
|
+
legitimately name another machine's checkout, and inventing a relative
|
|
216
|
+
path for it would place a finding at a file that is not there.
|
|
217
|
+
"""
|
|
218
|
+
text = str(path)
|
|
219
|
+
if text.startswith("file:"):
|
|
220
|
+
path = Path(unquote(urlparse(text).path))
|
|
221
|
+
absolute = path if path.is_absolute() else scanned / path
|
|
222
|
+
for base, candidate in (
|
|
223
|
+
(root, Path(os.path.normpath(absolute))),
|
|
224
|
+
(root.resolve(), absolute.resolve()),
|
|
225
|
+
):
|
|
226
|
+
try:
|
|
227
|
+
return Path(candidate.relative_to(base).as_posix())
|
|
228
|
+
except ValueError:
|
|
229
|
+
continue
|
|
230
|
+
return absolute
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def anchor(findings: Iterable[Finding], *, scanned: Path, root: Path) -> list[Finding]:
|
|
234
|
+
"""Make every finding's path repository-relative. The one place that does.
|
|
235
|
+
|
|
236
|
+
**The invariant:** after this, `Finding.file_path` is a POSIX path
|
|
237
|
+
relative to the repository root, or an absolute path for a location
|
|
238
|
+
outside it. A consumer that reads the file joins it back onto the root;
|
|
239
|
+
nothing resolves it against the process working directory.
|
|
240
|
+
|
|
241
|
+
It used to be absolute, set by `Scanner._rooted` inside the adapter
|
|
242
|
+
constructor (D5), because consumers resolved relative paths against the
|
|
243
|
+
working directory. That left two defects. The adapters that build
|
|
244
|
+
findings through SARIF ingest never passed through `_rooted`, so
|
|
245
|
+
`file_path` had two conventions depending on the tool. And an absolute
|
|
246
|
+
path is the wrong identity for anything compared across machines:
|
|
247
|
+
`SuppressionRule.matches` ran a repository-relative `paths:` glob against
|
|
248
|
+
`/Users/.../.github/workflows/x.yml`, which cannot match, so reviewed
|
|
249
|
+
suppressions were silently ignored; and the fingerprint hashed the same
|
|
250
|
+
absolute path, so a baseline made on a laptop matched nothing in CI.
|
|
251
|
+
|
|
252
|
+
Called by the CLI on everything scanners and SARIF imports return, before
|
|
253
|
+
any consumer sees it. The fingerprint is recomputed only when it was
|
|
254
|
+
derived from the path being replaced; control findings carry ids of their
|
|
255
|
+
own, such as `unavailable.bandit`, and keep them.
|
|
256
|
+
"""
|
|
257
|
+
out: list[Finding] = []
|
|
258
|
+
for finding in findings:
|
|
259
|
+
located = repository_path(finding.file_path, scanned=scanned, root=root)
|
|
260
|
+
if located == finding.file_path:
|
|
261
|
+
out.append(finding)
|
|
262
|
+
continue
|
|
263
|
+
derived = finding.fingerprint == Finding.make_fingerprint(
|
|
264
|
+
canonical_cwe=finding.canonical_cwe,
|
|
265
|
+
rule_id=finding.rule_id,
|
|
266
|
+
file_path=finding.file_path,
|
|
267
|
+
code_snippet=finding.code_snippet,
|
|
268
|
+
)
|
|
269
|
+
fingerprint = (
|
|
270
|
+
Finding.make_fingerprint(
|
|
271
|
+
canonical_cwe=finding.canonical_cwe,
|
|
272
|
+
rule_id=finding.rule_id,
|
|
273
|
+
file_path=located,
|
|
274
|
+
code_snippet=finding.code_snippet,
|
|
275
|
+
)
|
|
276
|
+
if derived
|
|
277
|
+
else finding.fingerprint
|
|
278
|
+
)
|
|
279
|
+
out.append(replace(finding, file_path=located, fingerprint=fingerprint))
|
|
280
|
+
return out
|
|
281
|
+
|
|
179
282
|
|
|
180
283
|
# ---------------------------------------------------------------------------
|
|
181
284
|
# Helpers
|
|
@@ -181,7 +181,7 @@ def generate(
|
|
|
181
181
|
name-match the same billing, so an agent working top-to-bottom spent its
|
|
182
182
|
care on noise. See `triage.py` for what lands where and why.
|
|
183
183
|
"""
|
|
184
|
-
tiers = triage.partition(findings, axis_of)
|
|
184
|
+
tiers = triage.partition(findings, axis_of, root)
|
|
185
185
|
if not any(tiers.values()):
|
|
186
186
|
return (
|
|
187
187
|
"# Security remediation — no actionable findings\n\n"
|
|
@@ -229,7 +229,8 @@ def generate(
|
|
|
229
229
|
listed = review[:_MAX_REVIEW_LINES]
|
|
230
230
|
parts.append(
|
|
231
231
|
"\n".join(
|
|
232
|
-
_review_line(i, f, root, triage.reason_for(f
|
|
232
|
+
_review_line(i, f, root, triage.reason_for(f, root))
|
|
233
|
+
for i, f in enumerate(listed, 1)
|
|
233
234
|
)
|
|
234
235
|
+ "\n"
|
|
235
236
|
)
|
|
@@ -307,11 +308,11 @@ def _footer() -> str:
|
|
|
307
308
|
def _display_path(path: Path, root: Path | None) -> str:
|
|
308
309
|
"""Repository-relative, because a work order gets pasted somewhere else.
|
|
309
310
|
|
|
310
|
-
Finding paths are
|
|
311
|
-
|
|
312
|
-
is the wrong thing to hand a person or an agent
|
|
313
|
-
|
|
314
|
-
location anyone can act on.
|
|
311
|
+
Finding paths are repository-relative already (`findings.anchor`), and
|
|
312
|
+
pass straight through. An absolute one is relativized where it can be:
|
|
313
|
+
an absolute path is the wrong thing to hand a person or an agent, since
|
|
314
|
+
`/private/tmp/.../scratchpad/wo/src/app.py:13` names one machine's
|
|
315
|
+
checkout rather than a location anyone can act on.
|
|
315
316
|
"""
|
|
316
317
|
if root is None or not path.is_absolute():
|
|
317
318
|
return path.as_posix()
|
|
@@ -297,7 +297,17 @@ def _summary_section(
|
|
|
297
297
|
# No verdict means nobody decided this run could claim a grade, so it
|
|
298
298
|
# cannot. Falling back to a locally-invented condition here is what put a
|
|
299
299
|
# second decision rule in the codebase in the first place.
|
|
300
|
-
if verdict is None
|
|
300
|
+
if verdict is not None and verdict.untriaged:
|
|
301
|
+
# An untriaged run leads with the work, on every surface. The terminal
|
|
302
|
+
# did this from the day it shipped and the Markdown report did not, so
|
|
303
|
+
# the same audit said two different things depending on where it was
|
|
304
|
+
# read — and the report is the one a reviewer opens.
|
|
305
|
+
out.append(f"- **{verdict.headline()}**")
|
|
306
|
+
out.append(
|
|
307
|
+
" - The number is in the JSON and the trend. It is not the first "
|
|
308
|
+
"thing to say about a repository nobody has triaged yet."
|
|
309
|
+
)
|
|
310
|
+
elif verdict is None or not verdict.is_verified:
|
|
301
311
|
if score.overall is None:
|
|
302
312
|
out.append(
|
|
303
313
|
"- **No score:** nothing measurable was scanned. This is not a "
|
|
@@ -541,11 +551,15 @@ def _pr_comment(
|
|
|
541
551
|
# never caveat on its own terms.
|
|
542
552
|
unverified = verdict is None or not verdict.is_verified
|
|
543
553
|
score_label = "finding score, not a verified grade" if unverified else "verified grade"
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
554
|
+
if verdict is not None and verdict.untriaged:
|
|
555
|
+
# Same rule as the Markdown report and the terminal. A PR comment is
|
|
556
|
+
# the most widely read artifact this tool produces, so it is the worst
|
|
557
|
+
# place for a letter nobody has triaged to be the headline.
|
|
558
|
+
headline = verdict.headline()
|
|
559
|
+
elif score.overall is None:
|
|
560
|
+
headline = "no score — nothing measurable was scanned"
|
|
561
|
+
else:
|
|
562
|
+
headline = f"{score_label} **{score.letter}** ({score.overall:.2f}/5.00)"
|
|
549
563
|
out = [
|
|
550
564
|
f"### secure-code-agent {status} — {headline}",
|
|
551
565
|
"",
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/base.py
RENAMED
|
@@ -62,7 +62,6 @@ class Scanner(ABC):
|
|
|
62
62
|
root = containment_root(target)
|
|
63
63
|
self._allow_target_executables = target_executables_allowed(config, target)
|
|
64
64
|
self._resolved_command = self._resolve_command(root, self.cfg(config))
|
|
65
|
-
self._target_root = root
|
|
66
65
|
|
|
67
66
|
@property
|
|
68
67
|
def command(self) -> tuple[str, ...]:
|
|
@@ -274,8 +273,9 @@ class Scanner(ABC):
|
|
|
274
273
|
short_desc = entry.short_desc if entry else None
|
|
275
274
|
fix_hint = entry.fix_hint if entry else None
|
|
276
275
|
|
|
277
|
-
|
|
278
|
-
|
|
276
|
+
# The path is kept as the tool reported it. `findings.anchor` makes it
|
|
277
|
+
# repository-relative for every adapter and every SARIF import at once;
|
|
278
|
+
# doing it here as well was how two conventions came to coexist.
|
|
279
279
|
fingerprint = Finding.make_fingerprint(
|
|
280
280
|
canonical_cwe=canonical_cwe,
|
|
281
281
|
rule_id=rule_id,
|
|
@@ -304,36 +304,6 @@ class Scanner(ABC):
|
|
|
304
304
|
cwe_top25=is_top25(canonical_cwe),
|
|
305
305
|
)
|
|
306
306
|
|
|
307
|
-
def _rooted(self, file_path: Path) -> Path:
|
|
308
|
-
"""Anchor a scanner-reported path to the audited tree.
|
|
309
|
-
|
|
310
|
-
Adapters do not agree on this. Bandit, Semgrep, RuboCop and the rest
|
|
311
|
-
report absolute paths; gitleaks reports paths relative to the
|
|
312
|
-
repository it scanned. Both are reasonable and neither is negotiable
|
|
313
|
-
from here, so the difference is absorbed at the one boundary every
|
|
314
|
-
adapter passes through.
|
|
315
|
-
|
|
316
|
-
Leaving it unabsorbed was not cosmetic. Every consumer that answers
|
|
317
|
-
"where is this?" — `is_excluded`, `is_test_path`, the axis split —
|
|
318
|
-
resolves a relative path against the *process* working directory,
|
|
319
|
-
which is wherever the operator happened to invoke the CLI. From there
|
|
320
|
-
`relative_to(root)` raises and the answer comes back "no". So
|
|
321
|
-
`exclude_patterns` silently did not apply to gitleaks findings at all:
|
|
322
|
-
an operator excluding `vendor/` still had vendor secrets scored, and
|
|
323
|
-
the calibration corpus scored four `tests/certs/*.key` files in
|
|
324
|
-
`requests` and six documentation examples in `flask` as production
|
|
325
|
-
secrets, holding both at F.
|
|
326
|
-
|
|
327
|
-
A path that is already absolute is returned untouched, including one
|
|
328
|
-
outside the target — an imported SARIF may legitimately name another
|
|
329
|
-
machine's tree, and inventing a root for it would be worse than
|
|
330
|
-
leaving it where it is.
|
|
331
|
-
"""
|
|
332
|
-
if file_path.is_absolute():
|
|
333
|
-
return file_path
|
|
334
|
-
root = getattr(self, "_target_root", None)
|
|
335
|
-
return root / file_path if root is not None else file_path
|
|
336
|
-
|
|
337
307
|
def _findings_exit_contradiction(
|
|
338
308
|
self,
|
|
339
309
|
target: Path,
|
|
@@ -17,7 +17,7 @@ Entry schema (.scignore.yaml)::
|
|
|
17
17
|
...
|
|
18
18
|
expires: 2027-08-01 # required; max 365 days out
|
|
19
19
|
file: api/tests/x.py # optional path (suffix match)
|
|
20
|
-
paths: ["api/**"] # optional fnmatch patterns
|
|
20
|
+
paths: ["api/**"] # optional fnmatch patterns, repository-relative
|
|
21
21
|
fingerprint: 0aaa689f8a967d8c # optional; 16 hex chars, from the report
|
|
22
22
|
line: 18 # optional; positive integer
|
|
23
23
|
|
|
@@ -66,17 +66,36 @@ class SuppressionRule:
|
|
|
66
66
|
fingerprint: str | None = None
|
|
67
67
|
line: int | None = None
|
|
68
68
|
|
|
69
|
-
def matches(self, finding: Finding) -> bool:
|
|
69
|
+
def matches(self, finding: Finding, root: Path | None = None) -> bool:
|
|
70
|
+
"""Does this entry cover `finding`?
|
|
71
|
+
|
|
72
|
+
`finding.file_path` is repository-relative (`findings.anchor`), which is
|
|
73
|
+
what an entry names. Through 0.12.1 it was absolute for most scanners, so
|
|
74
|
+
a `paths:` glob written relative never matched and entries grew a `*/`
|
|
75
|
+
prefix to swallow the checkout directory. Those entries keep working
|
|
76
|
+
without depending on where the checkout lives: a glob is also tried
|
|
77
|
+
against the path with a leading `/`, and `*/src/app.py` matches
|
|
78
|
+
`/src/app.py`. `root` lets an absolute `file:` and a fingerprint
|
|
79
|
+
recorded by an earlier release keep matching the finding they named.
|
|
80
|
+
"""
|
|
70
81
|
if self.rule_id != "*" and self.rule_id != finding.rule_id:
|
|
71
82
|
return False
|
|
72
83
|
rel = finding.file_path.as_posix()
|
|
73
|
-
if self.file is not None and self.
|
|
84
|
+
if self.file is not None and not self._names_file(rel, finding, root):
|
|
74
85
|
return False
|
|
75
|
-
if self.fingerprint is not None and self.fingerprint
|
|
86
|
+
if self.fingerprint is not None and self.fingerprint not in _identities(finding, root):
|
|
76
87
|
return False
|
|
77
88
|
if self.line is not None and self.line != finding.line_start:
|
|
78
89
|
return False
|
|
79
|
-
return not self.paths or any(
|
|
90
|
+
return not self.paths or any(
|
|
91
|
+
fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(f"/{rel}", p) for p in self.paths
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _names_file(self, rel: str, finding: Finding, root: Path | None) -> bool:
|
|
95
|
+
if self.file == rel or rel.endswith(self.file):
|
|
96
|
+
return True
|
|
97
|
+
named = Path(self.file)
|
|
98
|
+
return root is not None and named.is_absolute() and named == root / finding.file_path
|
|
80
99
|
|
|
81
100
|
@property
|
|
82
101
|
def expired(self) -> bool:
|
|
@@ -182,15 +201,26 @@ def load(path: Path) -> tuple[list[SuppressionRule], list[str]]:
|
|
|
182
201
|
return rules, errors
|
|
183
202
|
|
|
184
203
|
|
|
185
|
-
def
|
|
204
|
+
def _identities(finding: Finding, root: Path | None) -> set[str]:
|
|
205
|
+
"""The fingerprint, plus the one 0.12.1 and earlier recorded for it."""
|
|
206
|
+
if root is None:
|
|
207
|
+
return {finding.fingerprint}
|
|
208
|
+
return {finding.fingerprint, finding.legacy_fingerprint(root)}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def apply(
|
|
212
|
+
findings: list[Finding], rules: list[SuppressionRule], root: Path | None = None
|
|
213
|
+
) -> list[Finding]:
|
|
186
214
|
"""Mark matching findings as suppressed (set suppressed=True + note).
|
|
187
215
|
Expired rules do NOT suppress — but generate their own findings via
|
|
188
|
-
`expired_findings()`. Returns a new list (frozen dataclass replace).
|
|
216
|
+
`expired_findings()`. Returns a new list (frozen dataclass replace).
|
|
217
|
+
|
|
218
|
+
`root` is the repository root the finding paths are relative to."""
|
|
189
219
|
from dataclasses import replace
|
|
190
220
|
|
|
191
221
|
out: list[Finding] = []
|
|
192
222
|
for f in findings:
|
|
193
|
-
active = next((r for r in rules if not r.expired and r.matches(f)), None)
|
|
223
|
+
active = next((r for r in rules if not r.expired and r.matches(f, root)), None)
|
|
194
224
|
if active is not None:
|
|
195
225
|
out.append(
|
|
196
226
|
replace(
|
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
import enum
|
|
29
29
|
import re
|
|
30
30
|
from collections.abc import Iterable
|
|
31
|
+
from pathlib import Path
|
|
31
32
|
|
|
32
33
|
from secure_code_audit.findings import Category, Confidence, Finding, Severity
|
|
33
34
|
|
|
@@ -156,7 +157,7 @@ def _contains_a_literal_credential(text: str) -> bool:
|
|
|
156
157
|
return False
|
|
157
158
|
|
|
158
159
|
|
|
159
|
-
def _line_of(finding: Finding) -> str | None:
|
|
160
|
+
def _line_of(finding: Finding, root: Path | None = None) -> str | None:
|
|
160
161
|
"""Read back the source line a secrets finding points at.
|
|
161
162
|
|
|
162
163
|
Gitleaks is run with `--redact`, so the matched text never reaches the
|
|
@@ -168,10 +169,14 @@ def _line_of(finding: Finding) -> str | None:
|
|
|
168
169
|
is gone, the path is a directory, the line number is out of range, or the
|
|
169
170
|
finding came out of git history and the working tree has moved on. Every
|
|
170
171
|
one of those must leave the finding where it was.
|
|
172
|
+
|
|
173
|
+
`root` is the repository root the finding's path is relative to. Without
|
|
174
|
+
it a relative path would be read from the process working directory, and
|
|
175
|
+
the demotion would depend on where the CLI was invoked.
|
|
171
176
|
"""
|
|
172
|
-
|
|
173
|
-
if path is None or finding.line_start is None or finding.line_start < 1:
|
|
177
|
+
if finding.file_path is None or finding.line_start is None or finding.line_start < 1:
|
|
174
178
|
return None
|
|
179
|
+
path = root / finding.file_path if root is not None else finding.file_path
|
|
175
180
|
try:
|
|
176
181
|
if not path.is_file():
|
|
177
182
|
return None
|
|
@@ -186,7 +191,7 @@ def _line_of(finding: Finding) -> str | None:
|
|
|
186
191
|
return None
|
|
187
192
|
|
|
188
193
|
|
|
189
|
-
def _is_a_reference_not_a_value(finding: Finding) -> bool:
|
|
194
|
+
def _is_a_reference_not_a_value(finding: Finding, root: Path | None = None) -> bool:
|
|
190
195
|
"""Is the flagged credential a variable name rather than a credential?
|
|
191
196
|
|
|
192
197
|
`gitleaks.curl-auth-user` fires CRITICAL on
|
|
@@ -210,7 +215,7 @@ def _is_a_reference_not_a_value(finding: Finding) -> bool:
|
|
|
210
215
|
"""
|
|
211
216
|
if finding.category is not Category.SECRETS:
|
|
212
217
|
return False
|
|
213
|
-
line = _line_of(finding)
|
|
218
|
+
line = _line_of(finding, root)
|
|
214
219
|
if line is None:
|
|
215
220
|
return False
|
|
216
221
|
if not _VARIABLE_REFERENCE.search(line):
|
|
@@ -221,7 +226,7 @@ def _is_a_reference_not_a_value(finding: Finding) -> bool:
|
|
|
221
226
|
return not _contains_a_literal_credential(without_references)
|
|
222
227
|
|
|
223
228
|
|
|
224
|
-
def tier_of(finding: Finding, axis: str = "primary") -> Tier:
|
|
229
|
+
def tier_of(finding: Finding, axis: str = "primary", root: Path | None = None) -> Tier:
|
|
225
230
|
"""Classify one finding.
|
|
226
231
|
|
|
227
232
|
Order matters. Where a finding *lives* outweighs what rule found it: a
|
|
@@ -235,19 +240,19 @@ def tier_of(finding: Finding, axis: str = "primary") -> Tier:
|
|
|
235
240
|
if _looks_like_a_credential(finding.message):
|
|
236
241
|
return Tier.FIX
|
|
237
242
|
return Tier.REVIEW
|
|
238
|
-
if _is_a_reference_not_a_value(finding):
|
|
243
|
+
if _is_a_reference_not_a_value(finding, root):
|
|
239
244
|
return Tier.REVIEW
|
|
240
245
|
if finding.confidence is Confidence.LOW:
|
|
241
246
|
return Tier.REVIEW
|
|
242
247
|
return Tier.FIX
|
|
243
248
|
|
|
244
249
|
|
|
245
|
-
def reason_for(finding: Finding) -> str | None:
|
|
250
|
+
def reason_for(finding: Finding, root: Path | None = None) -> str | None:
|
|
246
251
|
"""Why a finding was demoted, in words the reader can check."""
|
|
247
252
|
measured = LOW_PRECISION.get(finding.rule_id)
|
|
248
253
|
if measured:
|
|
249
254
|
return measured
|
|
250
|
-
if _is_a_reference_not_a_value(finding):
|
|
255
|
+
if _is_a_reference_not_a_value(finding, root):
|
|
251
256
|
return (
|
|
252
257
|
"The line holds a variable reference, not a credential — a name "
|
|
253
258
|
"standing in for a value kept elsewhere, which is the recommended "
|
|
@@ -265,7 +270,7 @@ def reason_for(finding: Finding) -> str | None:
|
|
|
265
270
|
|
|
266
271
|
|
|
267
272
|
def partition(
|
|
268
|
-
findings: Iterable[Finding], axis_of=lambda _f: "primary"
|
|
273
|
+
findings: Iterable[Finding], axis_of=lambda _f: "primary", root: Path | None = None
|
|
269
274
|
) -> dict[Tier, list[Finding]]:
|
|
270
275
|
"""Group actionable findings by tier, worst-first within each.
|
|
271
276
|
|
|
@@ -277,7 +282,7 @@ def partition(
|
|
|
277
282
|
for finding in findings:
|
|
278
283
|
if finding.suppressed or finding.severity is Severity.INFORMATIONAL:
|
|
279
284
|
continue
|
|
280
|
-
out[tier_of(finding, axis_of(finding))].append(finding)
|
|
285
|
+
out[tier_of(finding, axis_of(finding), root)].append(finding)
|
|
281
286
|
for group in out.values():
|
|
282
287
|
group.sort(key=lambda f: (-f.severity.rank, f.file_path.as_posix(), f.line_start))
|
|
283
288
|
return out
|
|
@@ -114,8 +114,14 @@ def measure_scope(
|
|
|
114
114
|
|
|
115
115
|
cited: set[str] = set()
|
|
116
116
|
for finding in before:
|
|
117
|
+
# Joined onto the root first. A repository-relative path resolved bare
|
|
118
|
+
# is anchored to the working directory, which names the wrong file
|
|
119
|
+
# whenever the CLI runs from a subdirectory. A before-report from
|
|
120
|
+
# 0.12.1 or earlier holds absolute paths, and joining leaves those as
|
|
121
|
+
# they are.
|
|
117
122
|
try:
|
|
118
|
-
|
|
123
|
+
located = (root / finding.file_path).resolve()
|
|
124
|
+
cited.add(located.relative_to(root.resolve()).as_posix())
|
|
119
125
|
except (ValueError, OSError):
|
|
120
126
|
cited.add(finding.file_path.as_posix())
|
|
121
127
|
|
|
@@ -323,7 +329,7 @@ def compare(
|
|
|
323
329
|
suppressed_now |= silenced_inline
|
|
324
330
|
|
|
325
331
|
def _asked_about(finding: Finding) -> bool:
|
|
326
|
-
return triage.tier_of(finding, axis_of(finding)) is not triage.Tier.ACCEPT
|
|
332
|
+
return triage.tier_of(finding, axis_of(finding), root) is not triage.Tier.ACCEPT
|
|
327
333
|
|
|
328
334
|
fixed = [f for fp, f in old.items() if fp not in new and fp not in suppressed_now]
|
|
329
335
|
still_open = [f for fp, f in old.items() if fp in new]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/requires.txt
RENAMED
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_agent.egg-info/top_level.txt
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanner_status.py
RENAMED
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/floor.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.0 → secure_code_agent-0.12.2}/src/secure_code_audit/scanners/osv_scanner.py
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
|
|
File without changes
|