secure-code-agent 0.12.5__tar.gz → 0.12.7__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.5/src/secure_code_agent.egg-info → secure_code_agent-0.12.7}/PKG-INFO +2 -2
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/README.md +1 -1
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7/src/secure_code_agent.egg-info}/PKG-INFO +2 -2
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/SOURCES.txt +1 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/__init__.py +1 -1
- secure_code_agent-0.12.7/src/secure_code_audit/capabilities.py +137 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/cli.py +117 -3
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/config.py +38 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/renderers.py +13 -2
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scoring.py +18 -1
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/LICENSE +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/pyproject.toml +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/setup.cfg +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/dependency_links.txt +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/entry_points.txt +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/requires.txt +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/top_level.txt +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/baseline.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/data/semgrep-offline.yaml +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/demo.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/findings.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/git_tools.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/history.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/instructions.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/mcp_server.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/pillar.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/practice.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/remediation.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/ruleset.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/sarif.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanner_status.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/__init__.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/bandit_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/base.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/builtin_rules.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/checkov_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/floor.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/gitleaks_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/gosec_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/hadolint_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/njsscan_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/npm_audit_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/osv_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/pip_audit_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/rubocop_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/scorecard_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/semgrep_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/trivy_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/trufflehog_scanner.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/standards.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/suppressions.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/triage.py +0 -0
- {secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/verify.py +0 -0
{secure_code_agent-0.12.5/src/secure_code_agent.egg-info → secure_code_agent-0.12.7}/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.7
|
|
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
|
|
@@ -513,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
513
513
|
## GitHub Action
|
|
514
514
|
|
|
515
515
|
```yaml
|
|
516
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
516
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.7
|
|
517
517
|
with:
|
|
518
518
|
config: secure-code-agent.json
|
|
519
519
|
fail-on-gate: true
|
|
@@ -465,7 +465,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
465
465
|
## GitHub Action
|
|
466
466
|
|
|
467
467
|
```yaml
|
|
468
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
468
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.7
|
|
469
469
|
with:
|
|
470
470
|
config: secure-code-agent.json
|
|
471
471
|
fail-on-gate: true
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7/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.7
|
|
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
|
|
@@ -513,7 +513,7 @@ For agents that support invokable skills, this repo ships a portable skill under
|
|
|
513
513
|
## GitHub Action
|
|
514
514
|
|
|
515
515
|
```yaml
|
|
516
|
-
- uses: marshallguillory86/secure-code-agent@v0.12.
|
|
516
|
+
- uses: marshallguillory86/secure-code-agent@v0.12.7
|
|
517
517
|
with:
|
|
518
518
|
config: secure-code-agent.json
|
|
519
519
|
fail-on-gate: true
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/SOURCES.txt
RENAMED
|
@@ -9,6 +9,7 @@ src/secure_code_agent.egg-info/requires.txt
|
|
|
9
9
|
src/secure_code_agent.egg-info/top_level.txt
|
|
10
10
|
src/secure_code_audit/__init__.py
|
|
11
11
|
src/secure_code_audit/baseline.py
|
|
12
|
+
src/secure_code_audit/capabilities.py
|
|
12
13
|
src/secure_code_audit/cli.py
|
|
13
14
|
src/secure_code_audit/config.py
|
|
14
15
|
src/secure_code_audit/demo.py
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""What a project declares it does, and the findings that follow from it.
|
|
2
|
+
|
|
3
|
+
Some findings are not defects and never will be. A tool that runs external
|
|
4
|
+
analyzers imports `subprocess` and spawns them; a tool that reads analyzer
|
|
5
|
+
output parses XML it did not write. Bandit reports both, correctly — the
|
|
6
|
+
observation is true. What was missing is any way for the project to say "yes,
|
|
7
|
+
that is what this is", so the same findings were reported as defects on every
|
|
8
|
+
run forever.
|
|
9
|
+
|
|
10
|
+
The instruments that existed could only hide them. `exclude_patterns` stops
|
|
11
|
+
the scan. `extra_args: --skip` turns the check off. Both leave a report that
|
|
12
|
+
says nothing, which is the silence this tool exists to refuse — the same shape
|
|
13
|
+
`_conformance` counts as a defect when it finds `NOSONAR` in someone else's
|
|
14
|
+
tree. `.scignore.yaml` states a reason, but a suppression expires, and an
|
|
15
|
+
architectural fact does not stop being true in a year; renewing it annually is
|
|
16
|
+
a ritual that teaches people to renew rituals.
|
|
17
|
+
|
|
18
|
+
A declaration is different from all three. The operator states, in the config,
|
|
19
|
+
what the project does. Findings consistent with that declaration are routed to
|
|
20
|
+
their own axis: **counted, listed in the report, and not scored**. Nothing is
|
|
21
|
+
hidden, nothing expires, and a subprocess call written next year is already
|
|
22
|
+
covered rather than generating a new exception.
|
|
23
|
+
|
|
24
|
+
**Declared, never inferred.** The tool does not decide that a project looks
|
|
25
|
+
like it spawns processes. It is told, in a file a reviewer reads, and the
|
|
26
|
+
report names the declaration next to the findings it accounts for. Inference
|
|
27
|
+
here would make the grade depend on a guess about the codebase, which is the
|
|
28
|
+
property this rubric does not have anywhere else.
|
|
29
|
+
|
|
30
|
+
**A declaration is falsifiable.** Declaring a capability the project does not
|
|
31
|
+
exercise is itself reported (`unexercised`), so the config cannot be padded
|
|
32
|
+
with pre-emptive declarations against findings that might arrive later.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Iterable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
from secure_code_audit.findings import Finding
|
|
41
|
+
|
|
42
|
+
#: Capability name -> the rule ids whose finding *is* that capability being
|
|
43
|
+
#: exercised. Deterministic and enumerated rather than pattern-matched: a
|
|
44
|
+
#: reader can see exactly which observations a declaration accounts for, and
|
|
45
|
+
#: a rule absent from this table is never routed by it.
|
|
46
|
+
#:
|
|
47
|
+
#: Scoped narrowly on purpose. `spawns_processes` covers the reports that say
|
|
48
|
+
#: "this code starts a process", not every report that happens to occur in a
|
|
49
|
+
#: file that also starts one. B602 (`shell=True`) is deliberately **not**
|
|
50
|
+
#: here: spawning a process is architecture, handing a string to a shell is a
|
|
51
|
+
#: decision, and a project that declares the former has not excused the latter.
|
|
52
|
+
CAPABILITY_RULES: dict[str, frozenset[str]] = {
|
|
53
|
+
"spawns_processes": frozenset(
|
|
54
|
+
{
|
|
55
|
+
"B404", # import subprocess
|
|
56
|
+
"B603", # subprocess call without shell
|
|
57
|
+
"B606", # start process with no shell
|
|
58
|
+
"B607", # start process with a partial executable path
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
"parses_untrusted_xml": frozenset(
|
|
62
|
+
{
|
|
63
|
+
"B313", # xml.etree.cElementTree
|
|
64
|
+
"B314", # xml.etree.ElementTree
|
|
65
|
+
"B405", # import xml.etree
|
|
66
|
+
"B406", # import xml.sax
|
|
67
|
+
"B408", # import xml.minidom
|
|
68
|
+
"B409", # import xml.pulldom
|
|
69
|
+
}
|
|
70
|
+
),
|
|
71
|
+
"fetches_remote_urls": frozenset(
|
|
72
|
+
{
|
|
73
|
+
"B310", # urllib.request.urlopen
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
"uses_nondeterministic_randomness": frozenset(
|
|
77
|
+
{
|
|
78
|
+
"B311", # random, not suitable for cryptography
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#: The axis a declared finding is reported on, prefixed so a reader of the
|
|
84
|
+
#: report can tell a declaration apart from a path-derived side axis.
|
|
85
|
+
AXIS_PREFIX = "declared: "
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class DeclarationReport:
|
|
90
|
+
"""What the declarations accounted for, for the report to state."""
|
|
91
|
+
|
|
92
|
+
#: Capability -> the operator's stated reason, as written in the config.
|
|
93
|
+
declared: dict[str, str]
|
|
94
|
+
#: Capability -> how many findings it accounted for.
|
|
95
|
+
accounted: dict[str, int]
|
|
96
|
+
#: Declared, and nothing matched it. A declaration that describes nothing
|
|
97
|
+
#: this project does is reported rather than ignored, because a config can
|
|
98
|
+
#: otherwise be padded against findings that have not arrived yet.
|
|
99
|
+
unexercised: tuple[str, ...]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def total_accounted(self) -> int:
|
|
103
|
+
return sum(self.accounted.values())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unknown_capabilities(declared: Iterable[str]) -> tuple[str, ...]:
|
|
107
|
+
"""Declared names this build has no rules for.
|
|
108
|
+
|
|
109
|
+
A typo in a capability name would otherwise declare nothing and route
|
|
110
|
+
nothing, silently, and the operator would read a report that still scored
|
|
111
|
+
the findings they thought they had accounted for.
|
|
112
|
+
"""
|
|
113
|
+
return tuple(sorted(name for name in declared if name not in CAPABILITY_RULES))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def capability_for(finding: Finding, declared: Iterable[str]) -> str | None:
|
|
117
|
+
"""The declared capability this finding is an instance of, or None."""
|
|
118
|
+
for name in declared:
|
|
119
|
+
if finding.rule_id in CAPABILITY_RULES.get(name, frozenset()):
|
|
120
|
+
return name
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def summarize_declarations(
|
|
125
|
+
declared: dict[str, str], findings: Iterable[Finding]
|
|
126
|
+
) -> DeclarationReport:
|
|
127
|
+
"""Count what each declaration accounted for, and what accounted for nothing."""
|
|
128
|
+
accounted = dict.fromkeys(declared, 0)
|
|
129
|
+
for finding in findings:
|
|
130
|
+
name = capability_for(finding, declared)
|
|
131
|
+
if name is not None:
|
|
132
|
+
accounted[name] += 1
|
|
133
|
+
return DeclarationReport(
|
|
134
|
+
declared=dict(declared),
|
|
135
|
+
accounted=accounted,
|
|
136
|
+
unexercised=tuple(sorted(n for n, count in accounted.items() if count == 0)),
|
|
137
|
+
)
|
|
@@ -30,6 +30,11 @@ from secure_code_audit import history as history_mod
|
|
|
30
30
|
from secure_code_audit import pillar as pillar_mod
|
|
31
31
|
from secure_code_audit import practice as practice_mod
|
|
32
32
|
from secure_code_audit import verify as verify_mod
|
|
33
|
+
from secure_code_audit.capabilities import (
|
|
34
|
+
AXIS_PREFIX,
|
|
35
|
+
capability_for,
|
|
36
|
+
summarize_declarations,
|
|
37
|
+
)
|
|
33
38
|
from secure_code_audit.findings import (
|
|
34
39
|
Category,
|
|
35
40
|
Confidence,
|
|
@@ -454,6 +459,16 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
454
459
|
return "test tree"
|
|
455
460
|
if is_test_path(located, root_for_tests, cfg.docs_patterns):
|
|
456
461
|
return "documentation"
|
|
462
|
+
# A finding that *is* a declared capability being exercised (D24),
|
|
463
|
+
# checked **after** the path axes. A subprocess call in the test tree
|
|
464
|
+
# is test tree: it is already unscored, and routing it to the
|
|
465
|
+
# declaration instead moved 492 findings off the tree's own axis and
|
|
466
|
+
# made the declaration look like it accounted for work it did not.
|
|
467
|
+
# The declaration is for the shipped source, which is the only place
|
|
468
|
+
# the path axes leave on the primary axis.
|
|
469
|
+
declared_as = capability_for(finding, cfg.capabilities)
|
|
470
|
+
if declared_as is not None:
|
|
471
|
+
return AXIS_PREFIX + declared_as
|
|
457
472
|
return "primary"
|
|
458
473
|
|
|
459
474
|
# `all_findings` keeps meaning *all* of them. Rebinding it to the primary
|
|
@@ -504,13 +519,60 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
504
519
|
# named — a secret matters wherever it lives, a test fixture's HIGH code
|
|
505
520
|
# smell does not, and feeding a deliberately-vulnerable fixture tree to
|
|
506
521
|
# `fail_on_severity` would fail every build in the corpus.
|
|
507
|
-
|
|
522
|
+
# Every axis whose name carries the declaration prefix, in the order the
|
|
523
|
+
# operator declared them so the report is stable across runs.
|
|
524
|
+
declared_axes = {
|
|
525
|
+
AXIS_PREFIX + name: path_axes.get(AXIS_PREFIX + name, []) for name in cfg.capabilities
|
|
526
|
+
}
|
|
527
|
+
# Declared axes gate on the same terms as the path axes: a secret is a
|
|
528
|
+
# secret wherever it is found, and declaring that this project spawns
|
|
529
|
+
# processes does not excuse a credential sitting next to one.
|
|
530
|
+
gated = gate_set(
|
|
531
|
+
primary_findings,
|
|
532
|
+
(test_findings, docs_findings, *declared_axes.values()),
|
|
533
|
+
cfg.gates,
|
|
534
|
+
)
|
|
508
535
|
measurable = _measurable_categories(executions, scored_findings)
|
|
536
|
+
# Counted over what was actually *routed*, not over every finding whose
|
|
537
|
+
# rule id matches. A subprocess call in the test tree is test tree, and
|
|
538
|
+
# counting it here made the disclosure claim 554 findings while the axis
|
|
539
|
+
# beside it showed 62 — two numbers about the same thing, in the same
|
|
540
|
+
# report, disagreeing by an order of magnitude.
|
|
541
|
+
declarations = summarize_declarations(
|
|
542
|
+
cfg.capabilities,
|
|
543
|
+
[f for found in declared_axes.values() for f in found],
|
|
544
|
+
)
|
|
509
545
|
score = score_findings(scored_findings, loc, measurable)
|
|
546
|
+
# What the declarations bought, stated as a number rather than left for a
|
|
547
|
+
# reader to reconstruct (D24). A declaration moves the grade — that is its
|
|
548
|
+
# purpose, and `exclude_patterns` has always been able to move it further
|
|
549
|
+
# while leaving no trace at all. The difference is disclosure: this reports
|
|
550
|
+
# the score the same tree earns when the declarations are disregarded, so a
|
|
551
|
+
# project that declares its way up a band has to show that on its own
|
|
552
|
+
# report. Computed only when something was actually accounted for, so an
|
|
553
|
+
# undeclared repository's output is unchanged.
|
|
554
|
+
undeclared_score = None
|
|
555
|
+
if declarations.total_accounted:
|
|
556
|
+
undeclared_findings = list(scored_findings) + [
|
|
557
|
+
f for found in declared_axes.values() for f in found if not f.suppressed
|
|
558
|
+
]
|
|
559
|
+
undeclared_score = score_findings(undeclared_findings, loc, measurable)
|
|
510
560
|
axes = (
|
|
511
561
|
summarize_axis("test tree", test_findings, test_loc),
|
|
512
562
|
summarize_axis("documentation", docs_findings, docs_loc or None),
|
|
513
563
|
summarize_axis("dependencies", dependency_findings),
|
|
564
|
+
# The note is the reason the project wrote, carried verbatim. A
|
|
565
|
+
# declared axis whose page shows counts and no statement of why is a
|
|
566
|
+
# grade moved in silence — the one thing this mechanism exists not to
|
|
567
|
+
# be (D25).
|
|
568
|
+
*(
|
|
569
|
+
summarize_axis(
|
|
570
|
+
name,
|
|
571
|
+
found,
|
|
572
|
+
note=cfg.capabilities[name.removeprefix(AXIS_PREFIX)],
|
|
573
|
+
)
|
|
574
|
+
for name, found in declared_axes.items()
|
|
575
|
+
),
|
|
514
576
|
)
|
|
515
577
|
# Naming an import on the command line asserts that it contributes coverage,
|
|
516
578
|
# so a broken one fails the gate even if no config requires that scanner.
|
|
@@ -615,7 +677,18 @@ def _do_audit(args: argparse.Namespace) -> int:
|
|
|
615
677
|
)
|
|
616
678
|
sys.stdout.write("\n")
|
|
617
679
|
else:
|
|
618
|
-
_print_summary(
|
|
680
|
+
_print_summary(
|
|
681
|
+
verdict,
|
|
682
|
+
score,
|
|
683
|
+
gate,
|
|
684
|
+
ran,
|
|
685
|
+
unavailable,
|
|
686
|
+
coverage,
|
|
687
|
+
paths,
|
|
688
|
+
axes,
|
|
689
|
+
trend_line,
|
|
690
|
+
_declaration_delta(score, undeclared_score, declarations),
|
|
691
|
+
)
|
|
619
692
|
|
|
620
693
|
return _exit_code(args, gate, all_findings)
|
|
621
694
|
|
|
@@ -1139,8 +1212,42 @@ def _print_install_guidance(unavailable: list[str]) -> None:
|
|
|
1139
1212
|
print(" (or --preflight to check the whole floor before a run)")
|
|
1140
1213
|
|
|
1141
1214
|
|
|
1215
|
+
def _declaration_delta(score, undeclared_score, declarations) -> str | None:
|
|
1216
|
+
"""One line naming what the declarations accounted for, and what it cost.
|
|
1217
|
+
|
|
1218
|
+
`exclude_patterns` can move a grade further than this and says nothing at
|
|
1219
|
+
all. The difference this mechanism is meant to hold is disclosure, so the
|
|
1220
|
+
number a reader would otherwise have to reconstruct is printed for them.
|
|
1221
|
+
"""
|
|
1222
|
+
if undeclared_score is None or not declarations.total_accounted:
|
|
1223
|
+
return None
|
|
1224
|
+
named = ", ".join(
|
|
1225
|
+
f"{name} ({count})" for name, count in sorted(declarations.accounted.items()) if count
|
|
1226
|
+
)
|
|
1227
|
+
line = (
|
|
1228
|
+
f"declared: {declarations.total_accounted} finding(s) accounted for by "
|
|
1229
|
+
f"{named}; without declarations "
|
|
1230
|
+
f"{undeclared_score.overall:.2f} ({undeclared_score.letter})"
|
|
1231
|
+
)
|
|
1232
|
+
if declarations.unexercised:
|
|
1233
|
+
# A declaration that matched nothing describes something this project
|
|
1234
|
+
# does not do. Named rather than dropped, so a config cannot be padded
|
|
1235
|
+
# against findings that have not arrived yet.
|
|
1236
|
+
line += f"; unexercised: {', '.join(declarations.unexercised)}"
|
|
1237
|
+
return line
|
|
1238
|
+
|
|
1239
|
+
|
|
1142
1240
|
def _print_summary(
|
|
1143
|
-
verdict,
|
|
1241
|
+
verdict,
|
|
1242
|
+
score,
|
|
1243
|
+
gate,
|
|
1244
|
+
ran,
|
|
1245
|
+
unavailable,
|
|
1246
|
+
coverage,
|
|
1247
|
+
paths,
|
|
1248
|
+
axes=(),
|
|
1249
|
+
trend=None,
|
|
1250
|
+
declaration_delta=None,
|
|
1144
1251
|
) -> None:
|
|
1145
1252
|
# "PASS" is a claim that something was checked. With no gate configured
|
|
1146
1253
|
# nothing was, and saying so is the difference between a report and a
|
|
@@ -1162,6 +1269,13 @@ def _print_summary(
|
|
|
1162
1269
|
for axis in axes or ():
|
|
1163
1270
|
if axis.count or axis.loc:
|
|
1164
1271
|
print(f" {axis.headline()}")
|
|
1272
|
+
# What the declarations bought, next to the axes that show what they
|
|
1273
|
+
# accounted for. Printed only when they accounted for something, and
|
|
1274
|
+
# printed even when the two scores land in the same band — "no change"
|
|
1275
|
+
# is the reassuring case and is exactly what a reader should be able to
|
|
1276
|
+
# see without recomputing it.
|
|
1277
|
+
if declaration_delta:
|
|
1278
|
+
print(f" {declaration_delta}")
|
|
1165
1279
|
print(f" scanners run: {', '.join(ran) if ran else '(none)'}")
|
|
1166
1280
|
coverage_line = f" coverage: {coverage.status.value.upper()}"
|
|
1167
1281
|
if coverage.unverified:
|
|
@@ -11,6 +11,11 @@ from dataclasses import dataclass, field
|
|
|
11
11
|
from pathlib import Path, PurePosixPath
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from secure_code_audit.capabilities import (
|
|
15
|
+
CAPABILITY_RULES,
|
|
16
|
+
unknown_capabilities,
|
|
17
|
+
)
|
|
18
|
+
|
|
14
19
|
DEFAULT_CONFIG_PATH = Path("secure-code-agent.json")
|
|
15
20
|
|
|
16
21
|
DEFAULT_EXCLUDES: tuple[str, ...] = (
|
|
@@ -245,6 +250,13 @@ class Config:
|
|
|
245
250
|
#: carries example JWTs that scored as critical secrets and drove it to F.
|
|
246
251
|
#: Reported and gated like the test tree, never scored as code condition.
|
|
247
252
|
docs_patterns: tuple[str, ...] = DEFAULT_DOCS_PATTERNS
|
|
253
|
+
#: What this project declares it does, as ``name -> reason`` (D24). A
|
|
254
|
+
#: finding that *is* a declared capability being exercised is reported on
|
|
255
|
+
#: its own axis rather than scored: a tool that runs analyzers spawns
|
|
256
|
+
#: processes, and reporting that as a defect on every run forever is the
|
|
257
|
+
#: observation being true and useless. Declared, never inferred, and
|
|
258
|
+
#: falsifiable — a declaration nothing matches is reported as unexercised.
|
|
259
|
+
capabilities: dict[str, str] = field(default_factory=dict)
|
|
248
260
|
scanners: dict[str, ScannerConfig] = field(default_factory=dict)
|
|
249
261
|
severity_overrides: dict[str, str] = field(default_factory=dict)
|
|
250
262
|
category_overrides: dict[str, str] = field(default_factory=dict)
|
|
@@ -398,6 +410,7 @@ _KNOWN_KEYS = frozenset(
|
|
|
398
410
|
"$schema",
|
|
399
411
|
"version",
|
|
400
412
|
"paths",
|
|
413
|
+
"capabilities",
|
|
401
414
|
"scanners",
|
|
402
415
|
"severity_overrides",
|
|
403
416
|
"category_overrides",
|
|
@@ -442,6 +455,31 @@ def _from_dict(raw: dict[str, Any]) -> Config:
|
|
|
442
455
|
if "docs_patterns" in paths:
|
|
443
456
|
cfg.docs_patterns = tuple(_string_list(paths["docs_patterns"], "paths.docs_patterns"))
|
|
444
457
|
|
|
458
|
+
capabilities_raw = raw.get("capabilities", {})
|
|
459
|
+
if not isinstance(capabilities_raw, dict):
|
|
460
|
+
raise ValueError("capabilities must be a JSON object of name -> reason")
|
|
461
|
+
for name, reason in capabilities_raw.items():
|
|
462
|
+
if not isinstance(name, str) or not name:
|
|
463
|
+
raise ValueError("capability names must be non-empty strings")
|
|
464
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
465
|
+
# A declaration without a reason is the thing this is meant to
|
|
466
|
+
# replace. The reason is what a reviewer reads to decide whether
|
|
467
|
+
# the declaration is still true, so an empty one is refused here
|
|
468
|
+
# rather than rendered as a blank line in the report.
|
|
469
|
+
raise ValueError(f"capabilities.{name} must state a non-empty reason")
|
|
470
|
+
unknown = unknown_capabilities(capabilities_raw)
|
|
471
|
+
if unknown:
|
|
472
|
+
# A typo would declare nothing, route nothing, and score the findings
|
|
473
|
+
# the operator believed they had accounted for -- silently, which is
|
|
474
|
+
# the failure mode this whole mechanism exists to remove.
|
|
475
|
+
raise ValueError(
|
|
476
|
+
"unknown capabilities: "
|
|
477
|
+
+ ", ".join(unknown)
|
|
478
|
+
+ "; known names are "
|
|
479
|
+
+ ", ".join(sorted(CAPABILITY_RULES))
|
|
480
|
+
)
|
|
481
|
+
cfg.capabilities = dict(capabilities_raw)
|
|
482
|
+
|
|
445
483
|
scanners_raw = raw.get("scanners", {})
|
|
446
484
|
if not isinstance(scanners_raw, dict):
|
|
447
485
|
raise ValueError("scanners must be a JSON object")
|
|
@@ -167,7 +167,7 @@ def _axes_to_dict(axes: Iterable[AxisReport]) -> dict:
|
|
|
167
167
|
"count": axis.count,
|
|
168
168
|
"scored": False,
|
|
169
169
|
"gated": axis.name == "dependencies",
|
|
170
|
-
"note":
|
|
170
|
+
"note": _axis_note(axis),
|
|
171
171
|
"per_severity_count": {s.value: n for s, n in axis.per_severity_count.items()},
|
|
172
172
|
"per_category_count": {c.value: n for c, n in axis.per_category_count.items()},
|
|
173
173
|
# Not repeated here: every one of these appears in the top-level
|
|
@@ -206,6 +206,17 @@ _AXIS_NOTES = {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
def _axis_note(axis: AxisReport) -> str:
|
|
210
|
+
"""Why this axis sits outside the score, in the report's own words.
|
|
211
|
+
|
|
212
|
+
An axis that carries its own note wins. Only a declared axis does, and
|
|
213
|
+
its note is the reason the audited project wrote in its configuration —
|
|
214
|
+
the tool cannot supply that sentence, and a declared axis printed without
|
|
215
|
+
it is a grade moved with no statement of why.
|
|
216
|
+
"""
|
|
217
|
+
return axis.note or _AXIS_NOTES.get(axis.name, "")
|
|
218
|
+
|
|
219
|
+
|
|
209
220
|
def write_json(
|
|
210
221
|
findings: Iterable[Finding],
|
|
211
222
|
score: ScoreReport,
|
|
@@ -361,7 +372,7 @@ def _axis_section(report: AxisReport | None) -> str:
|
|
|
361
372
|
)
|
|
362
373
|
)
|
|
363
374
|
out.append(f"- **By severity:** {by_severity}")
|
|
364
|
-
note =
|
|
375
|
+
note = _axis_note(report)
|
|
365
376
|
if note:
|
|
366
377
|
out.append(f"- {note}")
|
|
367
378
|
out.append("")
|
|
@@ -554,6 +554,17 @@ class AxisReport:
|
|
|
554
554
|
#: advisories are counted against a lockfile, not a line count, so this is
|
|
555
555
|
#: None for them rather than a misleading zero.
|
|
556
556
|
loc: int | None = None
|
|
557
|
+
#: Why this axis sits outside the score, in the report's own words. Empty
|
|
558
|
+
#: for the fixed axes, whose notes the renderer holds because they are the
|
|
559
|
+
#: same sentence for every project.
|
|
560
|
+
#:
|
|
561
|
+
#: A declared axis is the case this field exists for. Its justification is
|
|
562
|
+
#: not a constant the tool can write: it is the reason *this* project
|
|
563
|
+
#: stated in its configuration. 0.12.6 shipped the declaration reaching the
|
|
564
|
+
#: routing and never reaching the page, so a reader saw
|
|
565
|
+
#: `## Declared: spawns_processes` with counts under it and no statement of
|
|
566
|
+
#: why — which is the disclosure the whole mechanism rests on (D25).
|
|
567
|
+
note: str = ""
|
|
557
568
|
|
|
558
569
|
@property
|
|
559
570
|
def count(self) -> int:
|
|
@@ -580,7 +591,12 @@ class AxisReport:
|
|
|
580
591
|
)
|
|
581
592
|
|
|
582
593
|
|
|
583
|
-
def summarize_axis(
|
|
594
|
+
def summarize_axis(
|
|
595
|
+
name: str,
|
|
596
|
+
findings: Iterable[Finding],
|
|
597
|
+
loc: int | None = None,
|
|
598
|
+
note: str = "",
|
|
599
|
+
) -> AxisReport:
|
|
584
600
|
"""Count an axis without scoring it.
|
|
585
601
|
|
|
586
602
|
Not named `test_*` anything: pytest collects any callable whose name begins
|
|
@@ -601,6 +617,7 @@ def summarize_axis(name: str, findings: Iterable[Finding], loc: int | None = Non
|
|
|
601
617
|
return AxisReport(
|
|
602
618
|
name=name,
|
|
603
619
|
loc=loc,
|
|
620
|
+
note=note,
|
|
604
621
|
findings=findings,
|
|
605
622
|
per_severity_count=per_severity,
|
|
606
623
|
per_category_count=per_category,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_agent.egg-info/requires.txt
RENAMED
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/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
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanner_status.py
RENAMED
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/src/secure_code_audit/scanners/base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{secure_code_agent-0.12.5 → secure_code_agent-0.12.7}/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.5 → secure_code_agent-0.12.7}/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
|
|
File without changes
|
|
File without changes
|