secure-code-agent 0.12.1__tar.gz → 0.12.3__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.
Files changed (52) hide show
  1. {secure_code_agent-0.12.1/src/secure_code_agent.egg-info → secure_code_agent-0.12.3}/PKG-INFO +4 -2
  2. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/README.md +3 -1
  3. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3/src/secure_code_agent.egg-info}/PKG-INFO +4 -2
  4. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/__init__.py +1 -1
  5. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/baseline.py +33 -6
  6. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/cli.py +34 -10
  7. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/findings.py +104 -1
  8. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/git_tools.py +11 -0
  9. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/remediation.py +8 -7
  10. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/base.py +3 -33
  11. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/suppressions.py +46 -8
  12. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/triage.py +16 -11
  13. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/verify.py +8 -2
  14. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/LICENSE +0 -0
  15. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/pyproject.toml +0 -0
  16. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/setup.cfg +0 -0
  17. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
  18. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  19. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  20. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_agent.egg-info/requires.txt +0 -0
  21. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  22. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/config.py +0 -0
  23. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  24. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/demo.py +0 -0
  25. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/history.py +0 -0
  26. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/instructions.py +0 -0
  27. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/mcp_server.py +0 -0
  28. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/pillar.py +0 -0
  29. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/practice.py +0 -0
  30. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/renderers.py +0 -0
  31. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/ruleset.py +0 -0
  32. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/sarif.py +0 -0
  33. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanner_status.py +0 -0
  34. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/__init__.py +0 -0
  35. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  36. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  37. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  38. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/floor.py +0 -0
  39. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  40. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  41. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  42. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  43. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  44. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  45. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  46. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  47. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  48. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  49. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  50. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  51. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/scoring.py +0 -0
  52. {secure_code_agent-0.12.1 → secure_code_agent-0.12.3}/src/secure_code_audit/standards.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.1
3
+ Version: 0.12.3
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
@@ -448,6 +448,8 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
448
448
  expires: "2027-05-13"
449
449
  ```
450
450
 
451
+ `paths` entries are repository-relative and use the same pattern syntax as `exclude_patterns`: a trailing `/` means that directory at any depth, a bare name matches at any depth, and `**/` includes the root.
452
+
451
453
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
452
454
 
453
455
  ## Baseline + incremental adoption
@@ -511,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
511
513
  ## GitHub Action
512
514
 
513
515
  ```yaml
514
- - uses: marshallguillory86/secure-code-agent@v0.12.1
516
+ - uses: marshallguillory86/secure-code-agent@v0.12.3
515
517
  with:
516
518
  config: secure-code-agent.json
517
519
  fail-on-gate: true
@@ -400,6 +400,8 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
400
400
  expires: "2027-05-13"
401
401
  ```
402
402
 
403
+ `paths` entries are repository-relative and use the same pattern syntax as `exclude_patterns`: a trailing `/` means that directory at any depth, a bare name matches at any depth, and `**/` includes the root.
404
+
403
405
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
404
406
 
405
407
  ## Baseline + incremental adoption
@@ -463,7 +465,7 @@ For agents that support invokable skills, this repo ships a portable skill under
463
465
  ## GitHub Action
464
466
 
465
467
  ```yaml
466
- - uses: marshallguillory86/secure-code-agent@v0.12.1
468
+ - uses: marshallguillory86/secure-code-agent@v0.12.3
467
469
  with:
468
470
  config: secure-code-agent.json
469
471
  fail-on-gate: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.12.1
3
+ Version: 0.12.3
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
@@ -448,6 +448,8 @@ for repositories whose `pyproject.toml` is their authoritative audit input.
448
448
  expires: "2027-05-13"
449
449
  ```
450
450
 
451
+ `paths` entries are repository-relative and use the same pattern syntax as `exclude_patterns`: a trailing `/` means that directory at any depth, a bare name matches at any depth, and `**/` includes the root.
452
+
451
453
  Wildcard rule (`rule_id: "*"`) requires a `file` or `paths` scope — you cannot disable a rule globally.
452
454
 
453
455
  ## Baseline + incremental adoption
@@ -511,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
511
513
  ## GitHub Action
512
514
 
513
515
  ```yaml
514
- - uses: marshallguillory86/secure-code-agent@v0.12.1
516
+ - uses: marshallguillory86/secure-code-agent@v0.12.3
515
517
  with:
516
518
  config: secure-code-agent.json
517
519
  fail-on-gate: true
@@ -12,4 +12,4 @@
12
12
  #:
13
13
  #: PyPI is immutable, so 0.4.0 stays wrong. 0.5.0 is the first build whose
14
14
  #: artifacts name their own producer correctly.
15
- __version__ = "0.12.1"
15
+ __version__ = "0.12.3"
@@ -85,9 +85,18 @@ def load(path: Path) -> dict[str, BaselineEntry]:
85
85
  return entries
86
86
 
87
87
 
88
- def write(path: Path, findings: Iterable[Finding], existing: dict[str, BaselineEntry]) -> None:
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 = existing.get(f.fingerprint)
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(findings: list[Finding], baseline: dict[str, BaselineEntry]) -> list[Finding]:
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.fingerprint not in baseline))
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(suppressions.expired_findings(sup_rules, suppression_path))
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
- if is_test_path(finding.file_path, root_for_tests, cfg.test_patterns):
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(finding.file_path, root_for_tests, cfg.docs_patterns):
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
@@ -48,6 +48,17 @@ def is_excluded(path: Path, root: Path, patterns: Iterable[str]) -> bool:
48
48
  return any(_matches(rel, path.name, pat) for pat in patterns)
49
49
 
50
50
 
51
+ def matches_pattern(rel: str, pattern: str) -> bool:
52
+ """One configured path pattern against one repository-relative POSIX path.
53
+
54
+ The pattern language every path setting shares: `exclude_patterns`,
55
+ `test_patterns`, `docs_patterns`, and `.scignore.yaml` `paths:`. There is
56
+ one of it so the same string cannot mean two things in one configuration
57
+ (D21).
58
+ """
59
+ return _matches(rel, rel.rsplit("/", 1)[-1], pattern)
60
+
61
+
51
62
  def _matches(rel: str, name: str, pat: str) -> bool:
52
63
  """One glob against one path, already made relative to the root.
53
64
 
@@ -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)) for i, f in enumerate(listed, 1)
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 absolute by design — every consumer that asks "where
311
- is this?" needs them anchored to the audited tree. But an absolute path
312
- is the wrong thing to hand a person or an agent: it names one machine's
313
- checkout, and `/private/tmp/.../scratchpad/wo/src/app.py:13` is not a
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()
@@ -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
- file_path = self._rooted(file_path)
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; exclude_patterns syntax, repository-relative
21
21
  fingerprint: 0aaa689f8a967d8c # optional; 16 hex chars, from the report
22
22
  line: 18 # optional; positive integer
23
23
 
@@ -37,6 +37,7 @@ from dataclasses import dataclass, field
37
37
  from pathlib import Path
38
38
 
39
39
  from secure_code_audit.findings import Category, Confidence, Finding, Severity
40
+ from secure_code_audit.git_tools import matches_pattern
40
41
 
41
42
  _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
42
43
  _MAX_TTL_DAYS = 365
@@ -66,17 +67,43 @@ class SuppressionRule:
66
67
  fingerprint: str | None = None
67
68
  line: int | None = None
68
69
 
69
- def matches(self, finding: Finding) -> bool:
70
+ def matches(self, finding: Finding, root: Path | None = None) -> bool:
71
+ """Does this entry cover `finding`?
72
+
73
+ `finding.file_path` is repository-relative (`findings.anchor`), which is
74
+ what an entry names. Through 0.12.1 it was absolute for most scanners, so
75
+ a `paths:` glob written relative never matched and entries grew a `*/`
76
+ prefix to swallow the checkout directory. Those entries keep working
77
+ without depending on where the checkout lives: a glob is also tried
78
+ against the path with a leading `/`, and `*/src/app.py` matches
79
+ `/src/app.py`. `root` lets an absolute `file:` and a fingerprint
80
+ recorded by an earlier release keep matching the finding they named.
81
+
82
+ **A `paths:` glob is an exclude pattern (D21).** It is matched by
83
+ `git_tools.matches_pattern`, the matcher `exclude_patterns` uses, so a
84
+ trailing slash means "this directory, at any depth" and a bare name
85
+ matches at any depth. Through 0.12.2 it was bare `fnmatch`, under which
86
+ `paths: ["tests/"]` — the example README, `design.md` and the skill all
87
+ show — matched nothing.
88
+ """
70
89
  if self.rule_id != "*" and self.rule_id != finding.rule_id:
71
90
  return False
72
91
  rel = finding.file_path.as_posix()
73
- if self.file is not None and self.file != rel and not rel.endswith(self.file):
92
+ if self.file is not None and not self._names_file(rel, finding, root):
74
93
  return False
75
- if self.fingerprint is not None and self.fingerprint != finding.fingerprint:
94
+ if self.fingerprint is not None and self.fingerprint not in _identities(finding, root):
76
95
  return False
77
96
  if self.line is not None and self.line != finding.line_start:
78
97
  return False
79
- return not self.paths or any(fnmatch.fnmatch(rel, p) for p in self.paths)
98
+ return not self.paths or any(
99
+ matches_pattern(rel, p) or fnmatch.fnmatch(f"/{rel}", p) for p in self.paths
100
+ )
101
+
102
+ def _names_file(self, rel: str, finding: Finding, root: Path | None) -> bool:
103
+ if self.file == rel or rel.endswith(self.file):
104
+ return True
105
+ named = Path(self.file)
106
+ return root is not None and named.is_absolute() and named == root / finding.file_path
80
107
 
81
108
  @property
82
109
  def expired(self) -> bool:
@@ -182,15 +209,26 @@ def load(path: Path) -> tuple[list[SuppressionRule], list[str]]:
182
209
  return rules, errors
183
210
 
184
211
 
185
- def apply(findings: list[Finding], rules: list[SuppressionRule]) -> list[Finding]:
212
+ def _identities(finding: Finding, root: Path | None) -> set[str]:
213
+ """The fingerprint, plus the one 0.12.1 and earlier recorded for it."""
214
+ if root is None:
215
+ return {finding.fingerprint}
216
+ return {finding.fingerprint, finding.legacy_fingerprint(root)}
217
+
218
+
219
+ def apply(
220
+ findings: list[Finding], rules: list[SuppressionRule], root: Path | None = None
221
+ ) -> list[Finding]:
186
222
  """Mark matching findings as suppressed (set suppressed=True + note).
187
223
  Expired rules do NOT suppress — but generate their own findings via
188
- `expired_findings()`. Returns a new list (frozen dataclass replace)."""
224
+ `expired_findings()`. Returns a new list (frozen dataclass replace).
225
+
226
+ `root` is the repository root the finding paths are relative to."""
189
227
  from dataclasses import replace
190
228
 
191
229
  out: list[Finding] = []
192
230
  for f in findings:
193
- active = next((r for r in rules if not r.expired and r.matches(f)), None)
231
+ active = next((r for r in rules if not r.expired and r.matches(f, root)), None)
194
232
  if active is not None:
195
233
  out.append(
196
234
  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
- path = finding.file_path
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
- cited.add(finding.file_path.resolve().relative_to(root.resolve()).as_posix())
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]