Repository navigation
ci(security): fail the Bandit gate when it analysed nothing and scan examples, scripts and tools - #587
Conversation
…port Bandit exits 0 for an empty directory, a missing path and a file it cannot parse; it only lists them as skipped. scripts/check_bandit_report.py reads Bandit's JSON report and exits 1 unless some code was analysed (loc > 0), no file was skipped (errors), every .py file under the declared scope appears in the report (catches a scope narrowed with -x or a .bandit file) and there are no findings. It prints one `bandit: ...` line per problem, and fails closed on a missing or unparsable report. tests/test_check_bandit_report.py runs real Bandit with the flags the Makefile uses on scratch trees: empty directory, only empty files, missing directory, unparsable file, a file excluded by .bandit and a new shell=True call must fail with the matching reason; a clean tree, a 60-file tree (Bandit 1.9.4 draws a progress bar above 50 files) and a finding suppressed inline with `# nosec Bxxx - reason` must pass. Disabling any single guard check turns at least one case red. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ake security make security ran `bandit -r src` and trusted its exit status. It now deletes the old reports, scans `src examples` with every test and `scripts tools` with B404/B603/B607 skipped (B602 shell=True stays on), each with `-q -f json -o test-results/bandit/<scope>.json`, runs scripts/check_bandit_report.py on each report with the same scope variable, and fails if Bandit or the guard did. -q keeps the progress bar Bandit 1.9.4 prints on stdout above 50 files out of the log. Deleting the reports first means a stale clean report can never be read when Bandit does not run. The `security` Hatch script (`python -m bandit -r src`, no guard) is removed: nothing calls it any more except docs/security.md, so it would stay a second, vacuous way to run the scan. test-results/ is ignored. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Upload test-results/bandit/ with if: always() so the JSON behind a red or green required check can be inspected, with 14-day retention. actions/upload-artifact is pinned to the v7.0.1 SHA that sbom.yml already uses (node24); the v4 pin used by the release workflows runs on node20 and would put a deprecation annotation on a required check. The artifact name carries run_attempt because v4+ artifacts are immutable per workflow run. The job id, permissions, triggers and existing steps are unchanged. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ention docs/security.md still described `hatch run security` and `python -m bandit -r src`. Describe the real scope, the report guard and the single suppression mechanism (`# nosec Bxxx - <reason>`, no allow-list or baseline file), and say in docs/development.md that the pre-commit Bandit hook is narrower than make security. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
There was a problem hiding this comment.
🟡 Changes recommended
Helper-wide rule exclusions need reconciliation with issue #578’s requirement for reasoned inline exceptions.
1 open issue
What changed in this PR
This PR strengthens the required Bandit security check by expanding scan coverage and rejecting empty, incomplete, or finding-bearing scans.
Changes:
- Scan
src,examples,scripts, andtools, with selected subprocess checks skipped for helpers. - Validate Bandit JSON reports and add regression tests for clean and failing scans.
- Upload scan reports and align documentation around
make security.
| File | Description |
|---|---|
tests/test_check_bandit_report.py |
Tests report validation using real Bandit scans. |
scripts/check_bandit_report.py |
Rejects empty, incomplete, or finding-bearing reports. |
pyproject.toml |
Removes the unguarded Hatch security command. |
Makefile |
Expands scanning and combines scanner and guard results. |
docs/security.md |
Documents scan scope, validation, and suppressions. |
docs/development.md |
Distinguishes pre-commit scanning from make security. |
.gitignore |
Ignores generated test and scan reports. |
.github/workflows/security.yml |
Uploads Bandit reports even after failures. |
Review effort: Balanced
…nours The convention written in the previous commits, `# nosec Bxxx - <reason>`, works but makes Bandit 1.9.4 print a `Test in comment: <word> is not a test name or id` warning for every word of the reason, and a comma-separated list such as `# nosec B404,B602` registers only its last ID (NOSEC_COMMENT_TESTS captures the last repetition of its group). The ID list ends at a second `#`, so `# nosec Bxxx # <reason>` is silent (measured: stdout and stderr empty) and several IDs are separated by spaces. Document that form in docs/security.md and the Makefile comment, and use it in the test fixture so the documented form is the one proven to suppress the finding. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…chemas Bandit reports B404 on `import subprocess` and B603 on both `subprocess.run` calls in scripts/check_wheel_schemas.py (LOW severity, HIGH confidence). They run the python of the temporary venv the script has just created, as argument lists without a shell; the only non-literal argument is the absolute wheel path, and the second call runs a fixed code string. Each finding gets its own `# nosec Bxxx # <reason>` marker so the directory-wide skip of those tests can go. The pip call is wrapped onto three lines only so its marker fits the line width; ruff format 0.16.8 and 0.17.1 both accept the file as written. `bandit --ignore-nosec -r scripts tools` reports the same eight findings before and after (this file's three move from lines 11, 71, 74 to 11, 71, 76), and dropping any single ID from a marker re-surfaces exactly that finding. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
tools/check_pr_format.py runs `git diff` and `ruff format --check` as fixed argument lists without a shell. Bandit reports B404 on the import and B603 plus B607 (executable found through PATH) on each call, all LOW severity. Each finding gets its own `# nosec Bxxx # <reason>` marker so the directory-wide skip of those tests can go. The ruff call is wrapped onto three lines only so its marker fits the line width; ruff format 0.16.8 and 0.17.1 both accept the file as written, and tests/test_check_pr_format.py still passes. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ip list The helper scan skipped B404, B603 and B607 for all of scripts/ and tools/, so a new unreviewed subprocess call there passed make security: a seeded tools/zz.py with `subprocess.run([sys.argv[1]], check=True)` was green while an unskipped Bandit reports B404 and B603. That contradicts #578, which allows exceptions only as inline `# nosec` markers with reasons. The eight reviewed findings now carry their own markers, so drop `-s` and scan `src examples scripts tools` in one run: one report (test-results/bandit/bandit.json) and one guard call on the same scope variable, which leaves no way to give one directory different flags. tests/test_check_bandit_report.py gets four cases that run the real Makefile recipe on a scratch copy of the layout it scans (a pass-through script stands in for hatch, `true` for pip), with the same new code in scripts/ and tools/: clean (passes), an unannotated subprocess call (fails with B404 and B603), `shell=True` (fails with B602) and a call with reasoned inline markers (passes). The unannotated-call case fails against the previous recipe; re-adding `-s B404,B603,B607`, or dropping scripts or tools from the scope, turns cases red again. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…suppressions docs/security.md still said scripts and tools run with B404, B603 and B607 skipped. Describe the one scope with every test enabled, the single report file, and the rule that each subprocess call in scripts/ and tools/ carries its own reasoned marker, so a new one is reported until someone reviews it. Refs #578 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
|
R3 authorization for head The maintainer delegated the merge decision to an Oracle review. The Oracle (AI) reviewed this change at head |

Summary
make securityranbandit -r srcand trusted its exit status. Bandit exits 0 for an empty directory, a missing path and a file it cannot parse (they are only listed as skipped), so the requiredbanditcheck could go green without analysing anything; all three are reproduced in #578. It also never looked atexamples/,scripts/ortools/.make securitynow scanssrc examples scripts toolsin one Bandit run with every test enabled and no skip list, and fails unless Bandit analysed everything in that scope and found nothing. The eight existing findings (all LOW, all in two helper files) are suppressed by six individually reasoned inline# nosec Bxxx # <reason>markers, the only kind of exception #578 allows.Changes
scripts/check_bandit_report.py(new, 44 lines; unchanged by the latest four commits): reads Bandit's own JSON report and exits 1 unless all of these hold, printing onebandit: ...line per problem (explicit checks, noassert):loc > 0)errorslist).pyfile under the declared scope appears in the report (catches a scope narrowed with-xor a.banditfile'sexclude, which Bandit follows silently)A missing or unreadable report also fails (it is never treated as clean).
Makefilesecurity: deletestest-results/bandit/first, runs Bandit once overBANDIT_SCOPE := src examples scripts toolswith-q -f json -o test-results/bandit/bandit.json, runs the guard with the same scope variable, and exits 1 if Bandit or the guard failed. Before this change there were two invocations (BANDIT_STRICT,BANDIT_HELPERS) with different flags; with one invocation a directory cannot get a different skip list.-qkeeps the progress bar out of the log: Bandit 1.9.4 writes aWorking... 100%bar to stdout above 50 files even with-o(measured earlier: 60-file scan, no-q: bar on stdout;-q: stdout and stderr empty).scripts/check_wheel_schemas.py(+5/-3) andtools/check_pr_format.py(+5/-3): six inline markers in the documented form (# nosec Bxxx # <reason>, IDs separated by spaces) for the eight findings thatbandit --ignore-nosec -r scripts toolsreports (B404 x2, B603 x4, B607 x2, all LOW severity, HIGH confidence). Nothing else is annotated, and there is no blanket# nosec.scripts/check_wheel_schemas.py:11scripts/check_wheel_schemas.py:71scripts/check_wheel_schemas.py:76smoke_codeliteral, no shelltools/check_pr_format.py:5tools/check_pr_format.py:10tools/check_pr_format.py:37--; ruff on PATHThe
pip installcall and theruff format --checkcall were one-liners; each is wrapped onto three lines only so its marker fits the 100-column line width the repository otherwise keeps (ruff itself ignores pragma comments when measuring width). No behaviour change;tests/test_check_wheel_schemas.pyandtests/test_check_pr_format.pystill pass..github/workflows/security.yml(+8):if: always()upload oftest-results/bandit/(now one file,bandit.json), 14-day retention.actions/upload-artifactis pinned to043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1, the pinsbom.ymlalready uses (git ls-remoteresolvesv7.0.1to that SHA; itsaction.ymlruns on node24). Thev4pin used by the release workflows runs on node20 and would put a deprecation annotation on a required check;tools/lint_release_workflows.pyonly fixes the pin inpublish-pypi.ymlande2e-azure.yml. The artifact name carriesgithub.run_attemptbecause artifacts are immutable per workflow run..gitignore:test-results/.pyproject.toml(-1): removes thesecurityHatch script (python -m bandit -r src, no guard). Onlydocs/security.mdreferenced it, so it would have stayed a second, vacuous way to run the scan. Outside the planned file list; say so if you would rather keep it.tests/test_check_bandit_report.py(new): the guard cases (real Bandit, same flags as the Makefile, on scratch trees: empty directory, only empty files, missing directory, unparsable file, a file excluded by.banditand a newshell=Truecall must fail with the matching reason; a clean tree, a 60-file tree and a finding suppressed inline must pass; a missing report must fail) plus four new cases that run the realmake securityrecipe (a pass-through script stands in forhatch,trueforpip) on a scratch copy of the layout it scans, with the same new code inscripts/andtools/: clean (passes); an unannotatedsubprocess.run([sys.argv[1]], check=True)(fails, B404 and B603 reported for both directories);subprocess.call('echo', shell=True)(fails, B602 reported for both); the same call with reasoned inline markers (passes). They are skipped only wheremakeor/bin/shis missing (Windows); CI runs on ubuntu.docs/security.md,docs/development.md: describe the real scope, the guard and the suppression convention (they still saidhatch run securityandpython -m bandit -r src). The convention is# nosec Bxxx # <reason>with IDs separated by spaces. Bandit 1.9.4 prints aTest in comment: <word> is not a test name or idwarning for every word after the IDs unless the reason sits behind a second#, and a comma list such asB404,B602registers only its last ID (NOSEC_COMMENT_TESTSkeeps the last repetition of its group; measured, and read inbandit/core/manager.py). The- <reason>form works but is noisy, so it is not the documented one. The latest commit rewrites the paragraph that described the helper skip list.Size: 10 files, +228/-17 (tests +142,
check_bandit_report.py+44, Makefile +15/-1, docs +8/-9,security.yml+8, the two helper files +10/-6,.gitignore+1,pyproject.toml-1). Of the latest four commits (3336611,57afe4e,28e74d5,07a6b62): 5 files, +81/-21.Not in this PR: Semgrep (#579), the release advisory gate, any policy/exception file, the Makefile bootstrap rework. The 20 existing
# nosec B101markers insrc/azure_functions_validation/conformance.pykeep their current form (no reason text): they sit in the shipped conformance suite, so adding 20 honest reasons touches runtime source and is not a trivial comment edit; left as is (--ignore-nosecover the whole scope reports exactly those 20 plus the 8 helper findings, 28 in total, and the report'sskipped_testsis 28). #568 and #569 also editMakefile,security.yml,pyproject.tomland.gitignore; whichever lands second needs a rebase.Verification
Local rows: Python 3.11.15, Bandit 1.9.4, Hatch 1.18.1 (worktree
.venvfromCI=1 make install), on head07a6b62(tree clean, merged withorigin/main934d60d, which had not moved: "Already up to date").make check-allruff checkandruff format --checkOK; mypy strictsrc testsOK (64 files); 727 passed, 27 deselected, 4 warnings (723 oncf810eb: +4 new cases, no new warnings); coverage 97.15% (gate 95%);make securityprintedbandit: src examples scripts tools: 3544 lines in 33 files, no findingsmake build;make docs;mkdocs build --strictpytest tests --no-cov-qq, so no counts)ruff check,ruff format --checkon the 3 changed Python files with ruff 0.16.8 (theformatjob's pin) and 0.17.1 (dev extra)bandit --ignore-nosec -r scripts toolsbefore and after the markerspipcall was wrapped)--ignore-nosec# nosec(_totals.nosecis 0), 8 ID-specific skips, stderr emptybandit --ignore-nosec -r src examples scripts toolsconformance.py(existing markers) + the 8 above; reportskipped_tests28,nosec0origin/main(934d60d) before this PRbandit -r src examples: 24 files / 2582 LOC, 0 findings;bandit -r scripts toolswithout any skip list: 8 files / 921 LOC, 8 LOW findingsRed then green for the reviewer's case (the real-recipe cases from
28e74d5, run first against the Makefile ofcf810eb, i.e. with the helper-s B404,B603,B607, before it was edited):cf810ebrecipenew-subprocess-call-without-nosecfailed:make securityexited 0 for the seeded call (bandit: scripts tools: 45 lines in 5 files, no findings).clean-helpers,shell-true(B602 was never skipped) andsubprocess-call-with-reasoned-nosecpassedtests/test_check_bandit_report.py(10 guard + 4 real-recipe)End-to-end
make security(real Hatch and Bandit; each case created in the worktree and reverted, working tree identical afterwards):make securitybandit: src examples scripts tools: 3544 lines in 33 files, no findingssubprocess.call('echo', shell=True)inexamples/B404,B607,B602printedscripts/(helper scope)B404,B607,B602printed (before this change onlyB602was printed there)subprocess.run([sys.argv[1]], check=True)intools/zz.py(the reviewed case)B404andB603printed (this passedmake securityoncf810eb)scripts/zz.pyB404andB603printedtools/zz.pywith# nosec B404 # <reason>and# nosec B603 # <reason>bandit: ... 3547 lines in 34 files, no findings.pyinexamples/skipped examples/zz_bad.py: syntax error while parsing AST from fileexamples/missing (renamed)skipped ./examples: No such file or directorymake security BANDIT_SCOPE=<empty dir>)no code was analysedBANDIT_SCOPE=<nonexistent>)no code was analysedandskipped ...: No such file or directory.pyfileno code was analysedtools/.bandit(exclude = tools/lint_workflow_pins.py)not scanned: tools/lint_workflow_pins.pyexamples/(93 files in the scope)scripts/.banditandtools/.banditboth present)cannot read test-results/bandit/bandit.json: Expecting value: line 1 column 1 (char 0)(Bandit leaves an empty file; the stale clean one was deleted first)make security BANDIT=false)cannot read test-results/bandit/bandit.json: [Errno 2] No such file or directory; the stale report is goneMutation checks (original restored after each; working tree clean afterwards):
loccheck off / skipped-files check off / not-scanned check off / findings check off / unreadable report treated as OK, one at a timeempty-dir+only-empty-files/missing-dir+unparsable-file/narrowed-scope/shell-true-findingplus the real-recipenew-subprocess-call-without-nosecandshell-true/test_missing_report...fail-s B404,B603,B607re-added to the Bandit callnew-subprocess-call-without-nosecfailsscriptsortoolsdropped fromBANDIT_SCOPEnew-subprocess-call-without-nosecandshell-truefail (the guard cannot notice this itself: it reads the same variable)no findings; the target is still non-zero only because Bandit's own status is keptmake securityexits 0, a false green. This is the case the delete-first plus the kept status preventGitHub Actions on head
07a6b62(every check-run on that exact SHA, all result pages: 29 at the time of the first read, 24 success and 5 skipped (dependabot-auto-merge,stale-branches,stale-issues), none failed, none pending):ci-required,bandit,semgrep,Analyze,PR titlePR title,branch-naming-validationand the stale workflows, so each of those appears more than onceTest (Py 3.11/3.12/3.13/3.14)andMinimum dependencies (Py 3.11)cf810ebhad 723 passed with 4 and 7 on 3.11 and 3.14); coverage 97.15% on 3.11Ruff format check (changed Python files)4 files already formatted(ruff 0.16.8; this PR's owntools/check_pr_format.pyon the 4 changed Python files)banditjob log (Security Scansrun 40103144509, attempt 1)Run make securityprintedbandit: src examples scripts tools: 3544 lines in 33 files, no findings;Upload Bandit reportsended withArtifact bandit-reports-1 has been successfully uploaded! Final size is 839 bytes(one file,bandit.json); no##[warning]annotationNot run and known edges
Security Scans: therun_attemptartifact name follows the documented per-run immutability, but no failed run was re-run here.make securityon Python other than 3.11 (the CI job pins 3.11); on 3.12-3.14 only the test suite ran. macOS and Windows were not run (the real-recipe test cases are skipped on Windows).security.ymlandmaintenance.yml, which also callmake security.examples/e2e_app/.python_packages/(gitignored; only the Azure e2e workflow's pre-install step creates it) would now be scanned bymake securityand its vendored code could fail the target (reproduced with a seeded file, then removed). CI'sbanditjob never has that directory..banditfile withskipsortestsinside a scanned directory is honoured by Bandit, and the guard only noticesexclude(seededtools/.banditwithskips = B404,B603plus the unreviewed call from above:make securityexit 0, Bandit logsFound project level .bandit fileandUsing ini file for skipped tests); (2) a blanket# nosecwithout IDs suppresses silently (same call: exit 0; Bandit counts these in_totals.nosec, which is 0 today). A follow-up could pass--inian empty file and have the guard fail when_totals.nosecis above 0.Type of Change
Checklist
make check(lint + typecheck) (as part ofmake check-all)make testand all tests passRelease Impact
PRD.mdif product scope or goals changedci:does not cut a release;hatch run securityno longer exists (usemake security).Related Issues
Closes #578
Review Evidence
COMMENTED) ofde77db0, whoseMakefilethread is anchored tocf810eb; a second review that executed the seededtools/zz.pycase againstcf810eb. The fix is07a6b62(with3336611,57afe4e,28e74d5); it has only the author's own verification (tables above). Independent human review requested.-s B404,B603,B607forscripts toolscontradicts ci(security): Bandit gate exits 0 for empty, missing or unparsable scan scope #578's inline-# nosecwording and hides new subprocess calls. Fixed in3336611,57afe4e(markers),28e74d5(Makefile and tests) and07a6b62(docs). The review thread is left unresolved for the reviewer, with a reply naming the commits. Deferred, no issue yet: the two bypasses listed under known edges, and reasons for the 20 existing# nosec B101markers.permissions:change, no secrets, no release/publish file, no input/response filtering, no policy file). It still changes the command of a required security check, so it is flagged above for explicit independent scrutiny.