secure-code-agent 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {secure_code_agent-0.8.0/src/secure_code_agent.egg-info → secure_code_agent-0.9.0}/PKG-INFO +1 -1
  2. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0/src/secure_code_agent.egg-info}/PKG-INFO +1 -1
  3. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/__init__.py +1 -1
  4. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/baseline.py +31 -0
  5. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/cli.py +11 -3
  6. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/config.py +32 -2
  7. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/git_tools.py +14 -3
  8. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scoring.py +20 -1
  9. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/LICENSE +0 -0
  10. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/README.md +0 -0
  11. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/pyproject.toml +0 -0
  12. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/setup.cfg +0 -0
  13. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_agent.egg-info/SOURCES.txt +0 -0
  14. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
  15. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
  16. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_agent.egg-info/requires.txt +0 -0
  17. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_agent.egg-info/top_level.txt +0 -0
  18. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
  19. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/findings.py +0 -0
  20. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/history.py +0 -0
  21. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/instructions.py +0 -0
  22. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/pillar.py +0 -0
  23. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/practice.py +0 -0
  24. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/remediation.py +0 -0
  25. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/renderers.py +0 -0
  26. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/ruleset.py +0 -0
  27. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/sarif.py +0 -0
  28. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanner_status.py +0 -0
  29. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/__init__.py +0 -0
  30. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
  31. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/base.py +0 -0
  32. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
  33. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
  34. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/floor.py +0 -0
  35. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
  36. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
  37. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
  38. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
  39. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
  40. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
  41. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
  42. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
  43. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
  44. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
  45. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
  46. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
  47. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/standards.py +0 -0
  48. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/suppressions.py +0 -0
  49. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/triage.py +0 -0
  50. {secure_code_agent-0.8.0 → secure_code_agent-0.9.0}/src/secure_code_audit/verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.8.0
3
+ Version: 0.9.0
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secure-code-agent
3
- Version: 0.8.0
3
+ Version: 0.9.0
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
@@ -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.8.0"
15
+ __version__ = "0.9.0"
@@ -10,6 +10,7 @@ operator has acknowledged at a point in time. On the next run:
10
10
  from __future__ import annotations
11
11
 
12
12
  import datetime
13
+ import enum
13
14
  import json
14
15
  import shutil
15
16
  import subprocess
@@ -32,6 +33,36 @@ class BaselineEntry:
32
33
  notes: str = ""
33
34
 
34
35
 
36
+ class State(enum.Enum):
37
+ """Why the baseline is the shape it is.
38
+
39
+ `load` returns an empty mapping for a baseline that is absent, one that
40
+ is unreadable, and one that is genuinely empty. Downstream those look
41
+ identical and every finding reads as new — which is correct for the
42
+ first case, a silent failure for the second, and worth saying out loud
43
+ in all three once `fail_on_new` gates by default.
44
+
45
+ On a first run the honest message is "there is nothing to compare
46
+ against yet", not "2 new findings since baseline", which claims a
47
+ baseline exists and that these appeared after it.
48
+ """
49
+
50
+ ABSENT = "absent"
51
+ UNREADABLE = "unreadable"
52
+ PRESENT = "present"
53
+
54
+
55
+ def state(path: Path) -> State:
56
+ """Distinguish a missing baseline from a broken one."""
57
+ if not path.exists():
58
+ return State.ABSENT
59
+ try:
60
+ raw = json.loads(path.read_text(encoding="utf-8"))
61
+ except (OSError, json.JSONDecodeError):
62
+ return State.UNREADABLE
63
+ return State.PRESENT if isinstance(raw, dict) else State.UNREADABLE
64
+
65
+
35
66
  def load(path: Path) -> dict[str, BaselineEntry]:
36
67
  if not path.exists():
37
68
  return {}
@@ -338,6 +338,7 @@ def _do_audit(args: argparse.Namespace) -> int:
338
338
  # ----- baseline -----
339
339
  baseline_path = _under_root(root, args.baseline or cfg.outputs["baseline_path"])
340
340
  baseline = baseline_mod.load(baseline_path)
341
+ baseline_state = baseline_mod.state(baseline_path)
341
342
  all_findings = baseline_mod.mark_new(all_findings, baseline)
342
343
 
343
344
  # ----- scoring -----
@@ -377,8 +378,9 @@ def _do_audit(args: argparse.Namespace) -> int:
377
378
  if cfg.loc_for_scoring:
378
379
  loc = int(cfg.loc_for_scoring.get("value", 0))
379
380
  test_loc = 0
381
+ docs_loc = 0
380
382
  else:
381
- loc, test_loc = loc_under(
383
+ loc, test_loc, docs_loc = loc_under(
382
384
  target,
383
385
  cfg.include_extensions,
384
386
  cfg.exclude_patterns,
@@ -386,6 +388,7 @@ def _do_audit(args: argparse.Namespace) -> int:
386
388
  # Same set the findings were filtered against. Numerator and
387
389
  # denominator have to describe the same repository.
388
390
  own_artifacts,
391
+ cfg.docs_patterns,
389
392
  )
390
393
  # Dependencies come off the code-condition score and onto their own axis.
391
394
  # A CVE in a pinned dependency is fixed with a version bump; an injection
@@ -405,7 +408,7 @@ def _do_audit(args: argparse.Namespace) -> int:
405
408
  score = score_findings(scored_findings, loc, measurable)
406
409
  axes = (
407
410
  summarize_axis("test tree", test_findings, test_loc),
408
- summarize_axis("documentation", docs_findings),
411
+ summarize_axis("documentation", docs_findings, docs_loc or None),
409
412
  summarize_axis("dependencies", dependency_findings),
410
413
  )
411
414
  # Naming an import on the command line asserts that it contributes coverage,
@@ -425,7 +428,12 @@ def _do_audit(args: argparse.Namespace) -> int:
425
428
  ]
426
429
  coverage = evaluate_coverage(executions, required)
427
430
  # Gates see the dependency advisories; the score does not.
428
- gate = evaluate_gates(gated, score, cfg.gates, coverage)
431
+ # The gate needs to know *why* the baseline is empty to describe a first
432
+ # run truthfully. Passed in the config dict rather than as a parameter so
433
+ # the gate signature stays the one every check shares.
434
+ gate = evaluate_gates(
435
+ gated, score, {**cfg.gates, "_baseline_state": baseline_state.value}, coverage
436
+ )
429
437
  verdict = build_verdict(score, cfg.gates, coverage)
430
438
 
431
439
  # ----- write outputs -----
@@ -26,6 +26,17 @@ DEFAULT_EXCLUDES: tuple[str, ...] = (
26
26
  ".mypy_cache/",
27
27
  "**/*.min.js",
28
28
  "**/*.lock",
29
+ # Lockfiles that are not named `.lock`. `**/*.lock` catches
30
+ # `poetry.lock`, `Gemfile.lock`, `Cargo.lock` and `yarn.lock` and misses
31
+ # every lockfile the JavaScript ecosystem actually ships:
32
+ # `package-lock.json` alone was 9,699 of axios's 17,532 non-code lines
33
+ # and 5,845 of lodash's 6,222. A generated dependency manifest is not
34
+ # source, and counting it inflates the denominator that decides the
35
+ # grade.
36
+ "**/package-lock.json",
37
+ "**/npm-shrinkwrap.json",
38
+ "**/pnpm-lock.yaml",
39
+ "**/bun.lockb",
29
40
  )
30
41
 
31
42
  #: Conventional test-tree locations across the languages the floor reads.
@@ -169,7 +180,24 @@ class Config:
169
180
  scanners: dict[str, ScannerConfig] = field(default_factory=dict)
170
181
  severity_overrides: dict[str, str] = field(default_factory=dict)
171
182
  category_overrides: dict[str, str] = field(default_factory=dict)
172
- gates: dict[str, Any] = field(default_factory=dict)
183
+ #: Default policy: **ratchet on regressions**, not on absolute state.
184
+ #:
185
+ #: `fail_on_new` is the one gate measured to work. Severity-based
186
+ #: defaults cannot: `fail_on_severity: ["critical"]` caught none of four
187
+ #: known-vulnerable control repositories, and `["critical","high"]`
188
+ #: failed five of ten well-maintained ones while still missing SQL
189
+ #: injection and `pickle.loads`, which Bandit rates *medium*. See D15.
190
+ #:
191
+ #: The ratchet reads no severity at all, so it inherits none of that. A
192
+ #: false positive is baselined once and never asked about again, which
193
+ #: is the property a threshold cannot have. Measured across an adoption
194
+ #: lifecycle: first run fails (everything is new), `--bump-baseline`
195
+ #: accepts existing debt, an introduced SQL injection fails, reverting
196
+ #: passes.
197
+ #:
198
+ #: An empty `{}` was the previous default and provided no floor at all —
199
+ #: an absent gate cannot trip, so every audit "passed".
200
+ gates: dict[str, Any] = field(default_factory=lambda: {"fail_on_new": True})
173
201
  outputs: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_OUTPUTS))
174
202
  suppressions_file: str = ".scignore.yaml"
175
203
  loc_for_scoring: dict[str, Any] | None = None
@@ -343,7 +371,9 @@ def _from_dict(raw: dict[str, Any]) -> Config:
343
371
  raise ValueError(f"invalid severity override: {', '.join(invalid)}")
344
372
  if invalid := sorted(set(cfg.category_overrides.values()) - _CATEGORIES):
345
373
  raise ValueError(f"invalid category override: {', '.join(invalid)}")
346
- cfg.gates = _validate_gates(raw.get("gates", {}))
374
+ # An operator who writes a `gates` block chooses their own policy
375
+ # entirely; the ratchet default applies only when they write none.
376
+ cfg.gates = _validate_gates(raw["gates"]) if "gates" in raw else dict(cfg.gates)
347
377
 
348
378
  outputs = raw.get("outputs", {})
349
379
  if not isinstance(outputs, dict):
@@ -97,8 +97,9 @@ def loc_under(
97
97
  excludes: Iterable[str],
98
98
  test_patterns: Iterable[str] = (),
99
99
  skip: Iterable[Path] = (),
100
- ) -> tuple[int, int]:
101
- """Non-blank in-scope lines, split into (primary, test).
100
+ docs_patterns: Iterable[str] = (),
101
+ ) -> tuple[int, int, int]:
102
+ """Non-blank in-scope lines, split into (primary, test, docs).
102
103
 
103
104
  The split exists because the score's denominator has to move with its
104
105
  numerator. Scoring primary-tree findings over a LOC count that included the
@@ -106,6 +107,12 @@ def loc_under(
106
107
  tested — the same numerator/denominator mismatch that `exclude_patterns`
107
108
  already caused once, arriving by a different door.
108
109
 
110
+ Documentation is split for the same reason, and was not: its *findings*
111
+ move to their own axis and out of the score, while its *lines* stayed in
112
+ the primary denominator. FastAPI carries 7,160 lines of `docs/en/data/`
113
+ — translator and contributor lists — diluting the count its code is
114
+ graded against. Third occurrence of one mismatch.
115
+
109
116
  `skip` names the run's own artifacts — the report, the baseline, the
110
117
  suppressions file. Dropping their *findings* without dropping their
111
118
  *lines* is that same mismatch a third time: an audit that wrote a
@@ -114,9 +121,11 @@ def loc_under(
114
121
  unchanged repository returned 0.00 and 4.25.
115
122
  """
116
123
  test_patterns = tuple(test_patterns)
124
+ docs_patterns = tuple(docs_patterns)
117
125
  skip = {p.resolve() for p in skip}
118
126
  primary = 0
119
127
  test = 0
128
+ docs = 0
120
129
  # `rglob` on a file yields nothing, so a single-file audit reported zero
121
130
  # lines — and a zero denominator is not normalised at all, so the grade
122
131
  # became the raw subtotal. Auditing one file is supported; it should be
@@ -138,6 +147,8 @@ def loc_under(
138
147
  lines = sum(1 for line in text.splitlines() if line.strip())
139
148
  if test_patterns and is_test_path(path, root, test_patterns):
140
149
  test += lines
150
+ elif docs_patterns and is_test_path(path, root, docs_patterns):
151
+ docs += lines
141
152
  else:
142
153
  primary += lines
143
- return primary, test
154
+ return primary, test, docs
@@ -691,7 +691,26 @@ def _gate_fail_on_new(
691
691
  ]
692
692
  if new_findings:
693
693
  tripped.append("fail_on_new")
694
- reasons.append(f"{len(new_findings)} new finding(s) since baseline")
694
+ # What "new" means depends on whether there is anything to be new
695
+ # *against*. Saying "since baseline" when no baseline exists claims
696
+ # these findings appeared after one, which is the opposite of the
697
+ # truth on a first run.
698
+ baseline_state = gate_config.get("_baseline_state")
699
+ if baseline_state == "absent":
700
+ reasons.append(
701
+ f"{len(new_findings)} finding(s), and no baseline exists yet — on a first "
702
+ f"run everything is new because there is nothing to compare against. "
703
+ f"Work the order, then re-run with --bump-baseline to accept what is "
704
+ f"left and gate on regressions from there."
705
+ )
706
+ elif baseline_state == "unreadable":
707
+ reasons.append(
708
+ f"{len(new_findings)} finding(s) read as new because the baseline file "
709
+ f"could not be parsed. Fix or delete it — a broken baseline silently "
710
+ f"turns an established repository back into a first run."
711
+ )
712
+ else:
713
+ reasons.append(f"{len(new_findings)} new finding(s) since baseline")
695
714
 
696
715
 
697
716
  def _gate_min_score(