Changelog
All notable changes to secure-code-agent are documented here. Format follows
Keep a Changelog. Semver pre-1.0 — config
schema may evolve.
0.13.1 — 2026-10-30
Coverage was reported to code scanning as a failed run.
Fixed — SARIF executionSuccessful is the run, not the coverage
- The emitted invocation set
executionSuccessfulfrom scanner coverage (coverage.status is COMPLETE). Onmaintainability-agentevery scanner completed and coverage isPARTIALonly because nothing in the floor reads shell, which 0.13.0 began reporting. GitHub code scanning readsexecutionSuccessful: falseas "the tool exited with errors but provided no error code": every upload since then carried "unsuccessful tool execution, exit code 0", and the repository's Security overview showed a "Code scanning configuration error" banner. executionSuccessfulis nowtruewhenever a document is written, whatever the coverage. Coverage stays on its own axis, inproperties.coverageStatus, and is never a failed execution. That includesFAILEDcoverage: the gate still fails the build, but the tool ran.- Every coverage gap is a
toolExecutionNotification(descriptor.id: scanner-coverage) naming what was not read.errorfor a required scanner that did not complete, as before.warningfor a language nothing in the run reads, and for a scanner that is not required and did not complete, which until now made coveragePARTIALwithout a word in the SARIF.
Docs
- The README opens with the project's cover image (
docs/brand/sca.png).
0.13.2 — 2026-10-30
Docs
- The README carries a language row and a badge row (PyPI version, Python
versions, licence, CI on
main, downloads). Short machine-translated pages in Spanish, German, French, Hindi and Simplified Chinese point back to the English README as the only complete copy (D36). - The project site is published at https://secure-code-agent.techportfolio.monster/: a landing page, the tool's own report on this repository as a live example, and the rendered documentation.
0.13.0 — 2026-10-05
Two ways a grade was issued without the evidence to support it.
Coverage knows which languages it actually read
COMPLETEmeant "every configured scanner ran" — a fact about the scanner list, not about the tree. It now also means something was read: coverage degrades toPARTIALand names any language in the scored tree that nothing in the run reads, with the remedy, in the summary and in the JSON. That catches C, Rust, PHP and shell, which no scanner in this floor reads.- Scoped to the primary tree, exactly as the scoring denominator is: a Go
fixture under
tests/does not oblige a Python project to installgosec. PARTIAL, notFAILED.PARTIALwithholds the verified grade and leavesrequire_scannersalone;FAILEDwould refuse to audit any repository containing a shell script. The stricter reading of P7 is whatrequire_scannersis for.- A correction worth recording. The first attempt lifted
LANGUAGE_SCANNERSfromcalibration/calibrate.pyand asserted something false — that nothing reads Go withoutgosec. Semgrep's offline profile carries five Go rules and they fire (verified on the shipped fixture), andbuiltin_rulesis Python-only and finds plenty withoutbandit. The study uses that map as a proxy for coverage depth inside a fixed scanner set; read as "what reads this at all" it is wrong. It broke a real test (test_a_grade_is_issued_only_when_a_declared_scanner_set_actually_ran), the test was right, and the map now lists every verified reader. - Still open, and a product decision rather than a defect: whether five
generic rules counts as covering a language.
COMPLETE/PARTIALcannot express depth, and a third state is a statement about what the tool promises. Recorded in D35.
A tool-unavailable notice bought a perfect score.
Security — a control finding is not evidence that anything was examined (D33, amended)
- Found by UAT on the published 0.12.12 wheel, not by any test in this
repository. A clean
pip install secure-code-agentships no scanners, so on an empty tree every adapter emits its own{name}.tool_unavailablecontrol finding —POLICY_DOCS,INFORMATIONAL. D33 counted any finding as evidence the category had been examined, so that notice graded its own category, and becauseINFORMATIONALcarries weight 0.0 the subtotal was zero and the grade a perfect 5.0. Being the only graded category it became the overall:
text
per_category: { ...all null..., "policy_docs": 5.0 }
overall: 5.0 letter: A+ loc_scanned: 0
- D33's reasoning was right about scanner findings and wrong about a scanner's notice about itself: "this tool could not run" is evidence of the opposite of examination.
- Only a finding that could move the score now counts as evidence that a
score may be produced. Stated as severity weight rather than as a list of
control rule ids —
scoringmust not know about the scanner layer, the four suffixes would drift, and the real property is "could this have changed the number". - The notice is still reported.
per_category_countcounts it, because that is how a reader finds out a scanner failed; it simply no longer grades anything. - A development checkout has the floor installed, so the branch was
unreachable locally. Verified fixed by rebuilding the wheel and installing it
into a clean scanner-free virtualenv:
overall: null, every category null, the notice still counted.
0.12.12 — 2026-10-04
A run that scanned nothing reported a perfect score.
Security — nothing scanned is ungraded, not perfect (D33)
- Pointed at an empty directory, the audit reported
overall: 5.00, letter A+, exit 0, and nothing on stderr. No attacker is required: a path typo, a failed checkout, or oneexclude_patternsentry matching more than its author meant produces the same result. Measured on a tree holding a single shell-injection finding, each row one config key from the honest run:
| run | overall | loc_scanned |
|---|---|---|
| honest | 0.00 (F) | 3 |
paths.exclude_patterns: ["**"] |
5.00 (A+) | 0 |
paths.test_patterns: ["**"] |
5.00 (A+) | 0 |
paths.docs_patterns: ["**"] |
5.00 (A+) | 0 |
| an empty directory | 5.00 (A+) | 0 |
normalizedivides the weighted subtotal bymax(loc_scanned, 1) / 1000. Themax(..., 1)avoids a division by zero and in exchange answers "no denominator" with "a denominator of one line", so zero findings over zero lines was a density of zero — a perfect grade.- A category with no findings and no scanned lines now grades
None, as a category no scanner covers already did. A scanned clean tree still grades 5.00/A+, and a finding on a tree that counted no lines is still graded —loc_scannedcounts only configured source extensions, so a credential in a.envis evidence even though it contributes no lines. - The
min_scoregate now trips on such a run with "nothing measurable was scanned". That branch already existed and was unreachable from this direction: an operator with amin_scorefloor and a broken path was shown PASS. - No configured gate is satisfied by a run that measured nothing — the
more dangerous half, because the grade is read by a person and the gate is
read by the build. An empty directory with a real severity gate and
--fail-on-gatereportedgate PASSat exit 0: every gate asks "are there offending findings", and zero findings is zero offenders. Such a run now tripsnothing_measuredwhenever any gate is configured, and exits 1. With no gate configured it still only reports, which is what--fail-on-gatealready refuses outright. --changed-onlyis not affected: it scopes the report and still scans the whole tree, so a commit touching no source keeps a realloc_scannedand a real grade.architecture.md§5 declared this problem CLOSED on 2026-09-09. It closed the measurability door — which is why disabling every scanner already behaved correctly — and listed this one as a "smaller consequence".- One consequence of making
Nonereachable, found and fixed in the same change: the declaration-disclosure line formatted the without-declarations grade with:.2fand guarded only the whole report being absent, not itsoverall. With nothing counted as scanned, every finding suppressed, and one of them matched by a declared capability —summarize_declarationscounts matched findings without skipping suppressed ones — it raisedTypeErroron the summary path. It now reports the accounting, which is the disclosure the mechanism exists for, and says there is no comparison rather than inventing one.
Security — the denominator is a grading key too (D32, amended)
- D32 refused
severity_overridesandcategory_overridesfrom a config inside the audited tree. It did not refuseloc_for_scoring, and every category exceptsecretsis a density, so the tree declaring its own line count declares its own grade: the same one-finding fixture went 0.00 (F) → 4.999 (A+) with an in-treeloc_for_scoring: {value: 10000000}, no flag and no warning. loc_for_scoringis now refused on the same terms, with the same two escape hatches:--trust-target-config, or a config kept outside the tree.- The warning and the clearing are now one list.
_refuse_target_policywarned for each key in_GRADING_KEYSand then cleared the keys in a separately written expression, so a key added to the tuple would have told the operator it was ignored and then applied it. The clearing is derived from the tuple, each key restored to its own dataclass default. - Measured and not holes, recorded so they are not re-closed:
scanners.<name>.enabled: falsefrom inside the tree yieldsoverall: null(ungraded, not perfect), andpaths.include_extensionsnarrowed to a non-source suffix leaves the grade honest.
Fixed — exclude_patterns did not stop the scan (D34)
- This register has always said
exclude_patterns"stops the scan and leaves no trace in the report". Only the second half was implemented: patterns filtered findings and LOC after the scanners ran, and most adapters were handed the bare target root. calibration/.corpus/holds 342 MB of cloned third-party repositories — webgoat, gson, commons-lang, tenpom.xmlfiles between them. It is gitignored, untracked, and the first entry inpaths.exclude_patterns. Trivy was still given the root, walked into it, and resolved those Java projects' dependencies against Maven Central until it was rate-limited with a 429. That is the root cause of the required-scanner failure below, of sixty consecutivecoverage_complete: falserows, of 26 unreported dependency findings inuv.lock, and of a forty-five-minute local suite.
| trivy invocation | result |
|---|---|
| the repository root, as the adapter gave it | fails after minutes of backoff |
--skip-dirs calibration/.corpus |
exit 0 in 51s |
- Through the product after the fix: the first
coverage: COMPLETEandgate PASSself-audit in this repository's history, in 48 seconds, on the sixty-sixth scored run. - Exclusions are now passed per tool, each flag verified against its own
--help: trivy--skip-dirs, semgrep--exclude, checkov--skip-path. Bandit already passed--exclude;builtin_rules,hadolint,npm_auditandpip_auditalready filter the inputs they discover. - Checkov's
--skip-pathtakes a regex, so every literal is escaped —calibration/.corpushanded over unescaped would also skipcalibration/Xcorpus, because a regex.matches any character. - Declared, not inferred. Every adapter now sets
honours_exclusions, or sets it False with anexclusion_notegiving the reason, and a lint requires one or the other — a new adapter inherits False and an empty note, so it fails until somebody decides. Declined with reasons recorded:gitleaksandrubocop(config-file only),njsscan(no such flag),osv-scanner(experimental flag only),scorecard(scores a remote repo, no local tree),gosecandtrufflehog(not installed here, so no flag could be verified against a real binary). - Finding-level filtering stays authoritative, so this is an optimisation and never the only thing excluding a path. Only the trailing-slash directory form of D21 travels; bare names and file globs stay with the post-filter rather than being guessed at. Under-excluding costs time; over-excluding would silently stop scanning real code.
- A path exclusion no longer deletes a dependency advisory.
_classifysettled the same argument one layer up — "a dependency advisory is about the dependency, not about the file that happened to declare it… the path routing gets it wrong" — and nothing applied it to exclusion. A dependency finding is filed against the manifest that pins the version, because that is the only file there is to point at, so any pattern matching that path removed it.**/*.lockis in this repository's own config, and the effect was 19 advisories againstuv.lock, ten of them high, invisible while thedependenciescategory graded 5.0 — a perfect score for a category whose only evidence source was excluded. Everything else in an excluded file stays excluded: a secret or a code finding there is about the file, which is what the operator asked to be rid of. - Those 19 are not a change in this release, and nothing is suppressed for
them.
uv.lockis deliberately gitignored — the comment beside the entry says why: pyproject is the one source of dependency truth, and "committing a lock nothing enforces would create a second one, free to drift unnoticed". CI installs withpip install -e, so no lockfile exists there and this exemption changes nothing about this project's own CI audit. The advisories are a property of a developer's resolved environment, and they will now be reported in one — which is the point. - Recorded rather than dispositioned, because they are not ours to fix. Read
from the installed metadata rather than inferred:
njsscan==1.0.1requiressemgrep==1.172.0, which requiresmcp==1.23.3andpyjwt[crypto]~=2.13.0— a compatible-release pin forbidding the 2.14.0 that carries the fix.checkov 3.3.22pinsastevalandecdsa, andecdsahas no fixed version published at all. None of it reaches a published install path: this package's only runtime dependency isPyYAML, and none of its exact pins is affected. - The exemption matters most for the projects that do commit a lockfile, which is nearly all of them: there, excluding it had been deleting the whole dependency axis.
Fixed — a required scanner failed for months and would not say why
- Found by running this repository's own audit. All sixty committed rows in
.secure-code/history.jsonlcarrycoverage_complete: false, becausetrivyis required and has been failing, and the reason recorded every time wastrivy emitted no SARIF output— a dead end for the operator. What trivy was actually saying, captured by reproducing the adapter's own invocation:
text
FATAL remote Maven repository returned 429 Too Many Requests for
https://repo.maven.apache.org/... Retry-After: 1313.
To avoid this, populate the local Maven cache before scanning.
A cause and a remedy, held in r.stderr and discarded. The adapter's other
two failure paths — timeout and bad exit — both carry that text; the one
path with no diagnosis of its own was the one that threw it away. It also
explains twenty-minute trivy invocations in the local suite: it was retrying
against Maven with backoff.
- The empty-output failure now reports the exit code and what trivy said. The
exit code is reported rather than judged: trivy returns 1 both for a fatal
error and, with --exit-code, for findings, so output decides success
(D30) and a trivy that returns 1 with usable SARIF is still parsed.
- Second half, one layer up: evaluate_coverage composed its failure strings
from the outcome alone, so coverage.failures — what the require_scanners
gate reports and what the summary prints — said did not complete: failed
while ScannerExecution.reason held the cause in the same record. A
diagnosis that only reaches a JSON field nobody reads is not a diagnosis.
Both halves are the same mistake in the same direction: the tool had the
evidence and reported its absence.
Fixed
calibrate --onlywith a name matching no repository in the corpus audited nothing and exited 0. The loop never ran, sofailedstayed empty — which is what the exit code keys on — and the run wrote empty distributions and reported success. It now names the unmatched selectors, lists the known names and exits 2. Sibling of the exit-code defect fixed in 0.12.11; that fix keyed onfailedand could not see this case.- The calibration test harness captured only stdout, so no test in that file could observe a refusal. It now captures both streams.
- The calibration study could not handle a repository the product declined to
grade — the state D33 makes reachable, and which
overall: nullhas always meant.summarizesortedNoneagainst floats and the per-repository line formatted it with:.2f; the raise was caught by the guard that keeps one bad repository from ending a study, so a successful audit was recorded asFAILED — TypeError: unsupported format string passed to NoneType, attributed to the repository, and the study exited nonzero because of it. An ungraded repository is nowunexamined— the harness's existing concept for "the floor could not read this", whose own docstring says it works "exactly as the product keeps an ungraded run out of a trend" — and the distributions that read product values skip it while the counts still include it. Latent until now only becausecalibration-config.jsonhappens to list an extension for every language in the corpus.
Internal
_gate_fail_on_severityand_gate_fail_on_categorywere a near-duplicate pair at similarity 1.0 — fourteen lines differing in a config key, an attribute and a noun. Each separately lowercased its configured values and separately excluded suppressed findings, and both break silently: a gate that stops lowercasing accepts its config, matches nothing and passes the build. They now share one evaluator and one table, with a lint pairing the table against the schema so a third vocabulary gate cannot arrive as a third copy.- New lint: every
subprocesscall in the package carries atimeout. All eight already did; the class was unguarded, and an audit that never returns is worse than one that fails.
0.12.11 — 2026-10-03
The audited repository could set its own grade.
Security — the audited tree does not grade itself (D32)
severity_overridesandcategory_overrideswere applied from any configuration, including one discovered inside the audited tree, with no--trust-target-config.INFORMATIONALcarries weight 0.0, so re-labelling a finding removed it from the score while leaving it visible in the report. Measured on one shell-injection finding: 0.00 (F) honestly, 5.00 (A+) with an in-treeseverity_overrides. The audited party moved its own grade.- Both keys are now dropped before any consumer reads them when the
configuration comes from inside the containment root, and the run warns on
stderr naming the file.
--trust-target-config, and a configuration kept outside the tree, still apply them. - D29 closed the argument half of threat-model T1; this is the half about what an in-tree configuration may claim about the findings. Promise P3 now names both directions: it refused an input whose removal raises the graded field, and this was one whose addition raised it.
Fixed
- The coverage floor CI enforced was not the one declared.
fail_underwas raised to 92 in 0.12.10 while both workflows passed--cov-fail-under=85on the command line, which wins. The gate stayed at 85, and a release could have shipped under the floor it claims. The number is declared once, inpyproject.toml. - The suite tested whatever copy was installed. Nothing put
srcon the import path, so barepytestimported whicheversecure_code_auditthe interpreter had. maintainability-agent runs this repository's declared test command from its own virtualenv and had been reporting a bug fixed in 0.12.10 — so it had never once measured this suite. - Bandit reported a clean scan when it had failed. It declared
allowed_exits=(0, 1)to_exec, which did nothing with it, and then checked no exit code at all. Bandit exits 2 on an internal error and can still emit a JSON envelope, which was parsed as an empty clean result. Exit-code policy now belongs to each adapter (D30). - A CWE override was honoured for the CWE and ignored for the OWASP id derived from it, so a Semgrep rule asserting CWE-22 against B608 produced CWE-22 with A03 — path traversal filed under Injection (D31).
- A
--onlycalibration run computed its variants block over reports left by earlier runs, at whatever scanner versions were current then — including the figures D16 chose the grade slope from. - A calibration run in which every repository failed still exited 0.
Added
- A suppression that matches nothing is reported. Only the expiry was
enforced; nothing asked whether an entry still had a subject.
INFORMATIONAL, so tidiness cannot move a grade. standards_overlaylayers an operator's own standards mapping over the shipped table, which now ships asdata/standards.yaml. An overlay may add or correct a rule's CWE, OWASP id, ASVS section, SSDF practice and prose, and is refused if it names severity, confidence or category — those are the scoring inputs, and an overlay that set them would reopen D32 through a second door.docs/html/, a rendered reading copy of every document in the repository, generated bytools/render_docs.pyand held current by a test that compares the committed copy against a fresh render.docs/remediation-prompt-evaluation.md, the study design for §4 criterion 3 — the one success criterion with no evidence behind it.
Changed
- The declared test command reports coverage to an artifact, so a consumer can
read it. maintainability-agent now measures
test_effectivenessfor this repository for the first time. Scannersplits into command resolution, subprocess execution and finding construction (423 → 104 lines);suppressions.loadsplits along its three jobs (111 → 22 lines). No call site changed.
0.12.10 — 2026-10-02
A repository could run code on the auditing machine through preflight.
Security — an untrusted config does not choose a scanner's command (D29)
- A
secure-code-agent.jsoninside the audited tree could set a scanner's command to any program onPATHwith any arguments —python -c "<code>"included — and--preflight's version probe ran it before anything was audited. The threat model's T1 ruling refused tree-local executables and not the arguments. An in-tree config no longer chooses the command: the scanner resolves as it would with no config, and preflight reportsignored_command.--trust-target-config, or a config kept outside the tree, keeps the configured command. - Found by maintainability-agent's audit, where it was reachable before the user had consented to a run.
0.12.9 — 2026-09-20
One tool, two answers. A run reported 5.00 (A+) — no findings in the
pillar and 68 to fix in the work order, about the same repository in the
same minute. The 68 were the findings the operator had declared.
Fixed — declared findings are not patch targets (D28)
- D24 reached the scorer and the renderer and stopped short of triage.
_ACCEPT_AXESwas a hardcoded pair written before declared axes existed, so a declaration moved a finding off the score and left it under §FIX — patch these. - Every
declared:axis is now a suppression candidate, for the same reason the test tree is. Fixed at the axis, so a capability added tomorrow is covered without anyone remembering this. - The primary axis is unchanged: an undeclared project still gets the finding in §FIX, because it has not said this is what it is.
0.12.8 — 2026-09-19
The work order, as facts. generate writes Markdown, which is right for
the operator and the agent and wrong for a tool. maintainability-agent
embeds this work order in an HTML report, had only prose, and wrapped it in
<pre> — so a reader who chose HTML got raw Markdown inside a rendered page.
Added — --work-order-json / outputs.work_order_json_path (D27)
- The same triage, order and caps as the prompt, emitted as JSON, with an
omittedcount carrying the number_overflowprints. - Tiers come from
triage.partitionexactly as the Markdown's do, so the two renderings cannot describe different work. - A finding carries what a renderer needs — what it is, where it is, what to do, and the citation. Fingerprints, baseline flags and suppression notes stay out: bookkeeping is not a published contract.
- Off unless declared, like the code-scanning SARIF. The default name is declared anyway so the file is excluded from the next scan.
- Markdown is unchanged. This adds a representation and replaces nothing.
0.12.7 — 2026-09-18
The declaration now says why on the page. 0.12.6 shipped capabilities
with the disclosure missing from the artifact the disclosure lives in.
Fixed — a declared axis prints the reason the project stated (D25)
- The report showed
## Declared: spawns_processes, a count, and nothing else. The reason from the configuration reached the routing and stopped there, so a grade moved with no statement of why — the shape D24 exists to be the opposite of. - The JSON carried
"note": ""on declared axes, indistinguishable from an axis whose note the tool had forgotten. AxisReportnow carries its ownnote, and the CLI fills a declared axis withcfg.capabilities[name]verbatim. Markdown and JSON both print it.- The fixed axes — test tree, documentation, dependencies — are unchanged. Their sentences are the same for every project and stay with the renderer.
0.12.6 — 2026-09-18
A project can say what it is. A tool that runs external analyzers imports
subprocess and spawns them, and the scanner reports that correctly on every
run forever. Until now the only ways to stop it were exclude_patterns and
--skip, which delete the observation from the report, or a .scignore.yaml
suppression, which states a reason and then expires annually for a fact that
has not changed.
Added — capabilities (D24)
"capabilities": {
"spawns_processes": "Runs the analyzer pool as subprocesses. ADR 006."
}
- A finding whose rule is that capability being exercised is routed to a
declared: <name>axis: counted, listed in the report, and not scored. - Declared, never inferred. The tool is told, in a file a reviewer reads. It does not decide that a project looks like it spawns processes.
- The registry is narrow.
B602(shell=True) is in no capability: spawning a process is architecture, handing a string to a shell is a choice. - Path axes win. A subprocess call in the test tree stays test tree.
- Declared axes still gate. A secret beside a declared finding still fails the build.
- Falsifiable. A declaration matching nothing is reported as
unexercised, and a misspelled capability name is refused rather than silently declaring nothing. - The cost is disclosed. The summary prints the score the same tree earns
with declarations disregarded —
without declarations 3.76 (B+)— so a project that declares its way up a band shows that on its own report.
Known names: spawns_processes, parses_untrusted_xml, fetches_remote_urls,
uses_nondeterministic_randomness.
A declaration asserts an architecture, not its correctness.
parses_untrusted_xml says the project reads XML it did not write; it does not
say the parser is hardened, and this tool cannot check that. The findings stay
in the report for that reason.
0.12.5 — 2026-09-17
A repository with no lockfile can be graded again. osv-scanner reports "no package
sources found" with exit code 128. The adapter's comment called that an internal error and
the code treated it as a scanner failure, which held coverage at partial forever for any
repository that declares its dependencies nowhere osv-scanner reads — this one included.
Fixed — nothing to scan is not a failure to scan (D23)
- Exit 128 is now
not_applicablewith a reason naming what was absent, instead offailed.NOT_APPLICABLEwas already excluded from what degrades coverage, so coverage reachescompletewith no change to the coverage model. - 127 and every other non-zero exit are unchanged: still
failed, stillpartial. - Naming
osv_scanneringates.require_scannersstill fails for a repository with no package sources. Requiring dependency scanning where no dependencies are declared is an assertion an operator should hear about. - An integration test now runs the installed osv-scanner against a lockfile-less tree. The defect was a wrong belief about the tool, and a mocked exit code is written from the same belief as the code it checks.
For maintainability-agent users. A security posture of unverified on a repository
with no lockfile was most likely this. Upgrade and re-run.
0.12.4 — 2026-09-14
Code scanning gets alerts, not the whole record. Since 0.12.2 made finding paths
relative, GitHub has been able to place this tool's SARIF results on pull-request lines.
Every test-tree assert became a review thread, and repositories that require resolved
conversations could not merge.
Fixed — the uploaded SARIF leaves out suppressed results (D22)
The SARIF this tool writes marks test-tree and documentation findings, and
.scignore.yaml suppressions, with the standard suppressions field. GitHub code
scanning opens an alert for every uploaded result and does not act on that field.
- New
--code-scanning-sarif-output PATH(outputs.code_scanning_sarif_path): the same SARIF without results that carrysuppressions. Off unless asked for. - Except what fails the build. A secret in the test tree or documentation is still
uploaded, because the gate escalates secrets from any axis and a failing build must show
the finding that failed it. Reviewed
.scignore.yamlentries are left out whatever the category; they return as live critical findings when they expire. - The GitHub Action uploads
secure-code.code-scanning.sarifinstead of the full SARIF, and exposes it as thecode-scanning-sarif-pathoutput. The full SARIF is still written tosarif-outputand kept in the report artifact. --sarif-outputis unchanged. Every finding still appears in it, and in the JSON report, the Markdown report and the work order.
What you will see. On the first upload from your default branch after upgrading, code scanning closes the alerts for test-tree findings, documentation findings and reviewed suppressions, because the new upload no longer contains them. Alerts for live findings in shipped source are unaffected. If you upload this tool's SARIF with your own workflow step, point it at the new output.
0.12.3 — 2026-09-14
The documented suppression works. paths: ["tests/"] is the example README,
docs/design.md and the skill show for Bandit's B101, and it has never suppressed a
single finding.
Fixed — a paths: glob means what an exclude pattern means (D21)
.scignore.yaml paths: globs were matched with bare fnmatch, while
exclude_patterns, test_patterns and docs_patterns use this tool's own matcher.
Under fnmatch a trailing / means nothing, so tests/ matched a file literally named
tests/ and no finding anywhere. paths: now uses the same matcher:
tests/matches that directory at any depth:tests/test_app.pyandsrc/tests/test_app.py.- A bare name such as
conftest.pymatches that file name at any depth. **/includes the repository root, and directory patterns glob (*.egg-info/,src/*/generated/).*/-prefixed entries written for 0.12.1's absolute paths still match (D20).
What widens. Entries that name a directory with a trailing /, or a bare file name,
now cover what they appear to name. Entries that give a full path, which is the common
form, are unchanged. Every paths: entry in this repository's .scignore.yaml and in
maintainability-agent's selects exactly the files it selected before. A bare-name entry
such as conftest.py now also covers pkg/conftest.py. To mean the root only, write a
leading slash: /conftest.py.
A suppression and an exclusion are now tested to agree on every case the exclude matcher
is pinned to, every shipped default, and every paths: example in the documentation.
The README entry runs end to end against the real Bandit.
0.12.2 — 2026-09-14
A reviewed suppression suppresses. paths: entries in .scignore.yaml never
matched Bandit, gitleaks, Semgrep or any other adapter-built finding, so findings an
operator had reviewed reported as live — five criticals on maintainability-agent.
The defect was not in any one scanner: Finding.file_path had no single convention.
D20 gives it one.
Fixed — finding paths are repository-relative, set in one place (D20)
Scanner._rooted made adapter-built paths absolute, while Checkov, Trivy and
--sarif-import findings stayed relative. findings.anchor now converts every
finding once, as the CLI collects it: a POSIX path relative to the repository root, or
absolute for a location outside it. _rooted is gone.
What that fixes, consumer by consumer:
- Suppressions. A repository-relative
paths:glob matches, whatever form the scan root was given in (/abs/repoor.). - Fingerprints and baselines. The fingerprint hashed the absolute path, so the same tree at two locations gave different ids for every finding, and a baseline recorded on one machine matched nothing in CI. The id no longer depends on where the checkout lives.
- Outputs. JSON
file_path, SARIFartifactLocation.uri, the Markdown report, the work order and the baseline no longer publish a machine's directory layout. --changed-only, own-artifact exclusion,--verify-againstscope. These resolved Checkov's and Trivy's relative paths against the process working directory. They join onto the root now.- Subdirectory audits. Test and documentation patterns are relative to the scan target, and a finding is classified against it.
Tested over every registered scanner, through the real CLI pipeline, in both path
shapes; plus real Bandit and gitleaks runs against an absolute and a . target. Each
compatibility rule and each root join was mutated and caught.
Migration — nothing to do, and one thing worth doing
Fingerprint values change for every finding whose path used to be absolute:
every adapter that builds findings through Scanner._make_finding — Bandit, gitleaks,
Semgrep, RuboCop, njsscan, gosec, hadolint, OSV-Scanner, pip-audit, npm audit,
TruffleHog, Scorecard and the built-in rules. Findings that were already relative to
the scan root keep their ids; Checkov's were, as observed on maintainability-agent.
JSON reports, SARIF and the Markdown report show the new ids and relative paths. What
was already recorded keeps working:
- Baselines written by 0.12.1 or earlier still match. The old id is accepted
alongside the new one. Run
--bump-baselineonce to rewrite entries under the portable id, which keeps each entry'sfirst_seen, so the baseline also matches on other machines and in CI. fingerprint:pins copied from an older report still match. New pins should come from a 0.12.2 report.paths:globs written with a leading*/to absorb the absolute prefix still match. Plain repository-relative globs now work too, and are the form to write.file:entries, relative or absolute, still match.
Two things do not carry over. A --verify-against before-report written by 0.12.1
or earlier compares fingerprints, so re-run the before audit on 0.12.2. And a glob
that relied on the checkout's own directory names, such as */my-clone/src/*, no
longer matches — it only ever matched on that one machine.
Documentation — D3 amended: maintainability-agent runs this tool
Decided 2026-09-13. maintainability-agent 3.7.0+ runs secure-code-agent on every audit
instead of only reading a security-pillar.json something else wrote, because nothing
outside CI ever wrote one. The security-pillar.json contract is unchanged, there is no
package dependency between the tools (MA 3.7.2), and --security-pillar still hands MA
a document from a pipeline that runs this tool itself. docs/decisions.md D3 and
docs/ma-integration.md say so. This project's [mcp] extra pins mcp<2 and must not
share an environment with MA's MCP server.
0.12.1 — 2026-09-12
One contract, not two. 0.12.0 shipped --changed-only and left six
documents describing it as reserved. A feature the docs deny is a feature
nobody uses.
Fixed — the shipped contract is the documented contract
--changed-only is real. docs/product-intent.md, docs/design.md,
docs/architecture.md (two places), README.md, skills/secure-code-agent/SKILL.md
and the Copilot prompt all said it was reserved and exited 2.
test_agent_guidance.py had encoded the old refusal, so it passed while the
guidance was wrong — it now fails on the stale wording instead, and a
non-vacuity check requires the skill to actually recommend the flag. The flag
had also never been exercised behaviourally; test_plan_features.py named it
in a docstring and tested none of it. It now has a fixture proving the report
is scoped, the grade is withheld, the whole tree is still scanned, and an
unresolvable ref is an error.
Fixed — every surface says the same thing about an untriaged run
The terminal led with N finding(s), none triaged while the Markdown report
and the PR comment still led with the letter. One audit said two different
things depending on where it was read, and the report is the one a reviewer
opens. Both now route through Verdict.headline().
Fixed — documentation that described older behaviour
docs/work-orders.md said §FIX and §REVIEW cap at 40 blocks. They cap at 12
and 12, with snippets trimmed to 3 lines and the §ACCEPT table to 8 rules.
The 0.12.0 note claimed "the differentiator is measurable". It is measurable
at file granularity: a rewritten session model inside a cited file still
reads as conformant. Walked back to what --verify-against actually reports.
Whether a bounded work order keeps an agent in scope remains unmeasured —
product-intent §8 Q2 stays open.
Note on release dates
0.11.0, 0.11.1 and 0.12.0 were dated 2026-09-11 here and shipped 2026-09-12 UTC. Corrected. Seven releases inside about twenty-four hours is a real cadence and not a typo; the dates should at least be true.
0.12.0 — 2026-09-12
Four review objections closed. Blast radius is measurable at file granularity, a PR-shaped audit exists, an untriaged run stops leading with a letter, and there is a chat door.
Note what this does not establish. Whether a bounded work order actually
keeps an agent inside its scope is still unmeasured — product-intent §8 Q2
remains open. What shipped is the instrument: --verify-against now reports
which files changed outside the cited set. It does not judge whether a
change was appropriate, and it says nothing at line granularity.
Added — scope conformance in --verify-against
"Only verify is mechanical, and only for silencing and regressions — not for 'did you rewrite the session model.'"
Partly mechanical now, at file granularity. The work order cites files;
the repository knows which files changed. --verify-against reports which
changed files were never cited, as scope: conformant or scope: EXCEEDED.
A rewritten session model inside a cited file still reads as conformant. This narrows the question from "did it stay in scope" to "did it touch anything it was not asked about", which is the answerable half.
The JSON report records the commit it was taken at, which is the input this
needs. Where it cannot be answered — no commit in the before-report, not a git
repository — the scope reports itself unknown, never conformant. A failed
measurement must not read as a clean result.
Exit semantics are unchanged: scope is reported, not gated. Changing what fails CI inside a reporting change is the defect this project keeps finding in other people's tools.
Added — --changed-only REF
"A PR-shaped tool that cannot do a PR-shaped audit is missing the run people will actually schedule."
The whole tree is still scanned — scanners read trees, not diffs, and
Semgrep's cross-file dataflow is the obvious casualty of doing otherwise — and
the report is scoped to files changed since REF. The grade is withheld,
because a run that looks at less must not score better. A ref git cannot
resolve is an error, not an empty diff.
Added — an MCP server
"It is named agent and there is no chat door."
pip install 'secure-code-agent[mcp]', then secure-code-agent-mcp. Tools:
audit_repository, preflight, agent_info.
It never audits unasked — audit_repository without action="run"
returns the question. It returns the work order first, because the score is
second class. It drives the CLI by subprocess rather than reimplementing it, so
there is one answer to "what did you find". No report is written into the
audited tree; only .secure-code/history.jsonl is appended, because that is
how the trend works.
Changed — an untriaged run leads with the work, not a letter
"A staff engineer who sees Django at F turns the gate off."
On a first run with findings and no baseline the headline now reads
N finding(s), none triaged — a starting position, not a grade. The number is
still in the report, the JSON and the trend; it is simply not the first thing
said about a repository nobody has triaged.
Triage is decoupled from evidence: it does not withhold the grade.
evidence_reasons answers did we look; triage answers did you review.
Folding them together made a verified grade unreachable on first contact for
any repository with one finding.
0.11.1 — 2026-09-12
Fixed — a vulnerable dependency was filed under "documentation"
requirements.txt matches the documentation pattern **/*.txt, and the axis
split read the path before the category — so every CVE in a pip manifest was
reported on the documentation axis. Eighteen of them on a six-file tree.
Classification now reads the category first, so a dependency advisory reaches
the dependencies axis wherever the manifest lives, including under docs/ and
tests/. Neither axis is scored, so no grade moves; what moves is the label,
and the label is the product.
Found by building the --demo fixture in 0.11.0, shipped as a known defect in
that release rather than fixed mid-flight.
0.11.0 — 2026-09-12
The first five minutes, and a work order that is a prompt again.
Added — --demo
secure-code-agent --demo generates a small application carrying seven real
defects, audits it, and writes the work order. A fresh install pointed at any
repository used to report coverage: FAILED and four missing scanners;
pointed at this repository it reported 0 to fix, 0 to review and 1,108
suppression candidates, because a security tool's tests are deliberately
vulnerable fixtures. Both are correct and neither demonstrates anything.
The fixture is assembled at runtime, never shipped — writing it into the
package would put vulnerable source in every site-packages and make this
project's own audit flag its own demo.
Added — install guidance where the failure happens
A coverage failure now names the install command for each missing scanner,
from the same source --preflight uses. The guidance existed and the failure
path printed only the names, so a first run was a scavenger hunt. The tool
still installs nothing; naming a command is not running one.
Changed — the work order is a prompt, not a backlog
| Django | PyGoat | |
|---|---|---|
| previously | 1,954 lines | 1,602 lines |
| now | 254 | 229 |
Ordinary repositories land at 85–145 lines. §FIX caps at 12 findings, §REVIEW is one line per finding, quoted snippets cap at 3 lines — Django's twelve §FIX blocks had carried 212 lines inside code fences — per-finding metadata collapses from eight bullets to one standards line, and the constraints go from 30 lines to 10.
All ten hard constraints survive, and a test asserts each one is still present. Nothing is dropped silently: every truncation is stated and points at the JSON report.
Fixed — exclude patterns that matched nothing
A directory pattern holding a glob was inert: *.egg-info/ matched
nothing while src/pkg.egg-info/ was scanned, and so were build-*/ and
test_*/. The directory branch did no globbing at all. **/dir/ was one
instance of that class, fixed earlier; this is the class.
Multi-segment patterns (calibration/.corpus/) are matched as a consecutive
run of components — a regression introduced by the first fix and caught when
the repository inventory started walking nineteen cloned corpus repositories
it was configured to exclude.
Fixed — documentation that described an older tool
Every doc now carries a > Status: header naming the version it describes,
and a test enforces it. README told adopters to pin @v0.3.0 — seven releases
stale. docs/ma-integration.md documented schema_version: 1 and "a field
may be added without a bump", contradicting the shipped contract and the rule
agreed with the consumer.
Known
A dependency CVE on a requirements.txt is filed on the documentation
axis, because the axis split reads the file extension. A dependency finding is
not documentation whatever the manifest is called. Not fixed here.
0.10.0 — 2026-09-11
The scoring model changed, and it now says so. Two normalizer decisions
(D16, D17) closed D5, which had been open since the first week. A
scoring_model field lets a consumer tell the models apart.
Changed — the grade is a density, and secrets is a count
The normalizer was sqrt(LOC/1000), which under-corrected for size, so the
ranking followed how big a repository is rather than how much is wrong with
it. Flask carried 2.2× Django's finding density and normalized at half the
value; grade correlated −0.37 with size. It is now a straight per-kLOC
density, and the grade slope moved 0.5 → 1.3 with it (D16).
secrets is the exception and normalizes by sqrt(LOC/1000) (D17). OWASP
Juice Shop carries four hardcoded API keys and three private keys and graded
B+ under straight density, because 115,340 lines divided seven committed
credentials down to nothing — while Flask, with no secrets at all, graded F.
A committed private key is one committed private key whether the repository is
a thousand lines or a million. Damped, not exempted: an absolute count failed
Django on two low-confidence hits.
Every grade moves. A repository with committed secrets scores lower; a large repository with diffuse low-severity findings scores higher.
Added — scoring_model, and security-pillar.json schema 2
A scoring model can change without the document carrying it changing shape —
same fields, a different number for the same repository. schema_version is
now 2 and carries a top-level scoring_model integer, currently 2. v2 is
v1 plus that one field; every v1 key keeps its name, type and meaning.
maintainability-agent keyed trend comparability on our release version,
which opened a new series on every release including ones that changed no
scoring. It now keys on scoring_model when present and falls back to the
version when absent, so old documents keep working (D19).
SCORING_MODEL is pinned to a digest of the weight tables, the slope, the
letter bands and the output of the real score() over a fixed matrix, so
changing a constant or a formula without bumping it fails CI.
Fixed — Bandit's eval/exec were filed as OS command injection
B102 was mapped to CWE-78 and B307 had no curated entry, so it
inherited CWE-78 from Bandit. CWE-78 is OS command injection — the shell
weakness B602 covers. Both are CWE-95 now, matching what semgrep, RuboCop
and the built-in rules already used.
This was not only a label. Corroboration merges across scanners on a shared
CWE, so B102 never merged with the built-in sca.python.eval on the same
exec(compile(...)) line and one defect scored twice; Flask carried four
such pairs. is_top25 now follows the documented CWE-95 → CWE-94 ChildOf
relationship, so describing the weakness accurately is not what removes its
Top-25 weight.
Fixed — stored analysis output was scanned as source
This tool's own output is now excluded by default anywhere in a tree, along
with .secure-code/ and .maintainability/. _own_artifacts only ever
removed the paths the current run was about to write, so an archived copy
was read as source: 556,808 LOC and 550 findings here, and on
maintainability-agent 957,219 LOC of stored audit output about other
repositories, which diluted five genuine criticals to an A−. A stored
report quotes findings verbatim, so it manufactures findings about findings
and inflates the LOC denominator at once.
Patterns are derived from DEFAULT_OUTPUTS, so adding an output cannot leave
a readable artifact behind. vendor/ and third_party/ are deliberately
not excluded — vendored code is deployed code (D18).
Fixed — a variable reference is not a credential
gitleaks.curl-auth-user fires CRITICAL on curl -sS -u "$SONAR_TOKEN:" in a
GitHub Actions workflow, where no credential is present. Such a finding is now
demoted to §REVIEW with the reason stated, checked by reading the source
line back because gitleaks runs --redact and the variable name is exactly
what got redacted.
Never to §ACCEPT: it still escalates through fail_on_category: [secrets]
from any axis. curl -u "$USER:hunter2Passw0rd" stays in §FIX — half a line
done correctly must not launder the other half. An unreadable file, an
out-of-range line, or a history-only finding all leave the finding where it
was.
Fixed — --target silently audited the wrong tree
--target names an agent for --init-agent-standards. On an audit run it
was accepted and discarded, so secure-code-agent --target /some/repo audited
the current directory and printed a grade for it. Now refused, with the
working command in the message.
Calibration — D5 is closed
The corpus gained a bad end: five applications written to be vulnerable (OWASP PyGoat, NodeGoat, railsgoat, WebGoat, Juice Shop), cloned and read, never executed. Without them there was nothing to place D and F against, which is why the band question stayed open.
| value | |
|---|---|
| AUC (maintained scores above vulnerable) | 1.00 |
| separation | +1.26 |
| maintained median | 3.41 (B) |
| vulnerable median | 0.00 (F) |
| Spearman(LOC, grade) | −0.05 |
Two band edges are measured: D/F at 1.00 falls inside the gap between the populations, and the centre lands in B. A−, B and C hold no observation at all, and no larger corpus fixes that — at that end the difference between two repositories is five findings versus eight in twenty thousand lines. Those edges are presentational granularity, not measured thresholds. A B+ is not meaningfully better than an A−.
test_scoring_drift.py now pins actual values as well as properties. Every
assertion in it had been relational, so a full model retune passed it
untouched.
0.9.0 — 2026-09-11
Two behaviour changes. The default gate is no longer empty, and the LOC denominator no longer counts lockfiles or documentation data.
Changed — the default gate is a ratchet
DEFAULT gates was {}. An absent gate cannot trip, so every audit
"passed" — a 200,000-line repository carrying SQL injection, shell=True,
pickle.loads and eval reported gate PASS out of the box. It now
reports gate FAIL.
The default is {"fail_on_new": true}. Severity-based defaults were
measured and rejected (D15): ["critical"] caught none of four
known-vulnerable control repositories, and ["critical","high"] failed
five of ten well-maintained ones while still missing SQL injection and
pickle.loads, which Bandit rates medium. The ratchet reads no severity
at all and so inherits none of that — a false positive is baselined once
and never asked about again.
An operator who writes a gates block gets exactly what they wrote; the
default applies only when none is written. "gates": {} still means
report-only, explicitly.
Fixed — a first run was reported as a regression
The gate said 2 new finding(s) since baseline with no baseline in
existence, claiming those findings appeared after one. On a first run the
truth is that there is nothing to compare against, and that is now what it
says, along with what to do next.
load() returned an empty mapping for a baseline that was absent,
unreadable or genuinely empty, so a corrupt baseline silently turned an
established repository back into a first run and failed the build with a
message blaming the code. The three states are now distinguished and each
says which it is.
Fixed — the denominator counted things that are not code
docs/scoring.md defines the divisor as "scanned code volume". Two kinds of
line were in it that are not:
- Lockfiles not named
.lock.**/*.lockcatchespoetry.lock,Gemfile.lock,Cargo.lockandyarn.lock, and misses every lockfile the JavaScript ecosystem ships.package-lock.jsonalone was 9,699 of axios's 17,532 non-code lines and 5,845 of lodash's 6,222.package-lock.json,npm-shrinkwrap.json,pnpm-lock.yamlandbun.lockbare excluded now. - Documentation. Its findings move to their own axis and out of the
score; its lines stayed in the primary denominator. FastAPI carries
7,160 lines of
docs/en/data/translator and contributor lists diluting the count its code is graded against. Third occurrence of that mismatch, after the test tree and the run's own artifacts.loc_underreturns(primary, test, docs)and the documentation axis reports a real line count.
Grades move down where non-code was inflating a denominator — fastapi 2.47 (C) to 1.09 (D) on a tree that is 23,764 lines of code rather than 35,655. No repository improved. The examined-corpus median holds at 3.36, in the B band, so the calibration target is unaffected.
sqrt(LOC) is unchanged. Per-kLOC was measured and is worse on the case
that matters: it grades a 200,000-line repository containing SQL injection
A+ rather than A−.
0.8.0 — 2026-09-11
Fixed — "gate PASS" was printed when no gate existed
Out of the box, with no configuration, a 200,000-line repository containing
SQL injection, shell=True command injection, pickle.loads, MD5 and
eval reported:
score 4.06 — grade withheld (A- unverified) · gate PASS
Every word is technically defensible. The grade was withheld. No gate
tripped, because DEFAULT gates is {} and an absent gate cannot trip. And
the line still tells a reader their repository is fine. The work order was
correct at the same moment — seven findings in §FIX, the SQL injection and
the shell=True among them — so the first-class output worked and the
summary undid it.
Three states now, where there were two:
gate FAIL — a configured gate tripped
gate PASS — a configured gate held
gate NOT CONFIGURED — nothing was checked
An unconfigured run says so in words and points at the work order.
gate.configured and gate.enforced are in the JSON report for consumers
that read passed as a security signal.
passed is unchanged. No gate tripped, which is true, and flipping it
would fail every ungated CI job that exits 0 today — a behaviour change
hiding inside a reporting fix. Inert configuration is still not enforcement:
an empty fail_on_severity list and a min_score of 0 both read NOT
CONFIGURED.
Recorded — D15: a scanner's severity is not a measure of consequence
Four problems solved or attempted on this project turned out to be one
problem: the score grading Bandit's talkativeness, B105 scoring 0 useful
hits in 22, a proposed severity floor, and proposed default gates.
A scanner's severity answers "how confident am I that this pattern is
present", not "how bad is this in your code" — Bandit rates SQL injection
medium and hashlib.md5 high. Six candidate floor rules were
measured against the corpus and four known-vulnerable controls; none
separates. Two candidate default gates likewise: critical-only catches
none of four vulnerable controls, critical+high fails five of ten
well-maintained repositories.
Severity remains a weight in the score and a filter an operator configures.
Nothing may use it to identify the dangerous findings. The full data is in
docs/decisions.md D15, including the one mechanism
measured to work — fail_on_new, which ignores severity entirely and
therefore does not inherit the flaw.
0.7.0 — 2026-09-11
Nine defects, all found by running the tool against repository shapes and content the calibration corpus does not contain. Every one survived 526 tests and a clean self-audit.
Fixed — targets that are not a tidy repository full of code
- A mistyped path reached the scanners and died with a raw
FileNotFoundErrorout ofsubprocess.py. The scan root is checked first, and the error names the path. - A single file as the target crashed twice: every adapter passed it as
a subprocess
cwd, andfind_repo_rootreturned the file when no git repository sat above it, so the report path becameone.py/secure-code-report.md.secure-code-agent one_file.pyis the first thing anyone tries and it was always meant to work. - A single file measured zero lines, because
rglobon a file yields nothing. A zero denominator is not normalised at all, so the grade became the raw subtotal — a two-line file containingeval(input())scored 3.59 (B+) instead of 0.00 (F).
Fixed — the corroboration merge was hiding findings
Bandit files B602 (shell=True), B603 (subprocess call) and B607
(partial executable path) all under CWE-78. Keying the merge on the CWE
collapsed them, so subprocess.call('ls', shell=True) reported B607
alone with the shell=True buried inside it as a footnote — the more
serious of the two, missing from the work order.
A shared CWE is no longer sufficient. Two rules from the same scanner merge only through the hand-checked alias table; different scanners still merge on a shared CWE, which is the cross-tool corroboration the function exists for.
test_two_different_weaknesses_at_one_line_stay_two_findings has asserted
this since the merge was written and passed throughout, because its
fixtures carried no CWE. Reading Bandit's CWEs in 0.6.0 gave them one and
turned a real test into a false assurance.
Fixed — a category nothing could read was graded 5.0
A Dockerfile with USER root and chmod 777, beside Terraform with a
public-read bucket and a 0.0.0.0/0 ingress rule, scored config_iac
5.0 with neither checkov nor hadolint installed.
A hand-written list claimed builtin_rules covered secrets and
config_iac — it has never had a rule for either — and omitted
supply_chain, which it does cover. The set is derived from the standards
map now, so it cannot drift from the rules it describes.
Fixed — Terraform lines were not counted
checkov is in the floor and reads .tf, so its findings were scored
against a denominator that excluded every file it had read. .tf and
.tfvars join DEFAULT_INCLUDE_EXTS.
Fixed — SARIF raised thousands of alerts for test fixtures
SARIF is consumed by code-scanning platforms that turn each result into an
alert, and suppressions is the field they honour. We wrote
properties.suppressed instead — a field of our own invention no consumer
reads. So findings the operator had explicitly suppressed in
.scignore.yaml still became alerts, and so did every test-tree and
documentation finding.
On a large real repository that was 4,929 results of which 4,847 were test fixtures — 4,847 alerts for deliberately-vulnerable test data, burying 82 findings in the shipped source. The same audit now raises 82. Suppressed, not omitted: every finding stays in the document with its location, axis and justification.
Fixed — semgrep resolved but did not run
Semgrep deprecated python -m semgrep in 1.38.0: it prints a notice, exits
0, and analyses nothing. The adapter declared that fallback, which fires
whenever the module is importable but the binary is off PATH — the normal
state after installing njsscan, which pulls semgrep in as a dependency.
Coverage caught it, so no grade was ever claimed on a scanner that had not
run, but it reported "semgrep failed" to operators whose situation was
"semgrep is not on PATH". bandit, njsscan, checkov and pip-audit all still
run correctly under python -m and keep the fallback; a test now asserts
any declared python_module actually exposes a __main__.
Known, measured, not changed
A repository with large checked-in JSON artifacts scores better for having
them: the same tree measured A- (4.02) with 879k lines of generated reports
counted and D (1.57) without. docs/scoring.md defines LOC as "scanned
code volume", which those lines are not. The fix is the same open question
as the sqrt(LOC) size bias — what the denominator should measure — and is
recorded rather than guessed at.
0.6.0 — 2026-09-11
Work orders are the first-class output
The remediation prompt is now written on every run, alongside the report.
_resolve_outputs read config for the report and nothing at all for the
prompt, so the artifact that grades your code was always written and the
artifact that fixes it needed --prompt-output. prompt_path had been
declared in DEFAULT_OUTPUTS the whole time and the resolver never read it;
four of six keys were dead the same way. Set any outputs.*_path to null
to disable one — which the loader used to reject despite the docstring
promising it.
Findings are tiered: §FIX (patch it), §REVIEW (confirm first, reason
stated per finding), §ACCEPT (test tree and documentation — propose a
suppression, do not patch). A flat list gave a shell=True command
injection and a PASSWORD_FIELD = "password" name-match identical billing.
A noisy rule is demoted, never dropped. Bandit's B105 produced zero useful
hits out of 22 across the calibration corpus and still caught a planted
hardcoded credential, so the matched value is judged as well as the rule.
Work-order paths are repository-relative. §ACCEPT is summarised by rule
with a drafted suppression, and §FIX/§REVIEW cap at 40 blocks and say
what they left out — auditing this repository produced a 541KB,
15,390-line work order before that, now 4.8KB and 111 lines.
New — --verify-against, proving the work order helped
secure-code-agent . --verify-against secure-code-report.json
Re-audits and reports what was fixed, what is still open, what was silenced rather than repaired, and what this work introduced. Exits nonzero unless the run passes.
Silencing is the case worth having. Lint disable has been in the
README's anti-pattern table since the first commit and hard constraint 6
forbids it; forbidding is not detecting. Bandit honours # nosec itself, so
a silenced finding stops arriving and reads as repaired. The source is now
read back around the reported line.
Test-tree and documentation findings are reported but never required, since
§ACCEPT tells the agent not to patch them.
New — scan history and trend
Every run appends one line to .secure-code/history.jsonl and prints the
movement: 3.90 (B+) — up 3.86 from 0.04 (F), 3 scored runs. Append-only; a
malformed line costs one run rather than the history. A run too thinly
covered to grade records null and is skipped rather than plotted as a
collapse to zero.
Changed — repeats of one rule saturate
This moves scores. Sorted worst-first, a rule's k-th hit now counts
weight / sqrt(k). One rule firing 56 times is one fact observed 56 times,
not 56 independent defects; distinct rules still add in full.
Grading Django, FastAPI, httpx and Flask all F was measuring how talkative
Bandit is on large Python codebases. Django's "hardcoded passwords" are
EMAIL_HOST_PASSWORD = "" and SECRET_KEY = ""; its "SQL injection" hits
include an ImproperlyConfigured error message and a parameterised
cursor.execute. Corpus effect: httpx 0.13 F → 2.58 B−, fastapi 0.00 F →
2.47 C, requests 2.54 → 3.98 B+. No repository scored lower.
Fixed — 86% of findings carried no CWE
Bandit publishes a CWE for every plugin and gosec for every rule, and both
were discarded — gosec went as far as formatting its CWE into the message
text and never setting canonical_cwe, so scoring, the Top-25 multiplier
and the SARIF taxonomy all saw nothing. Corpus CWE coverage 14% → 100%;
OWASP is derived from the CWE where the curated map is silent, reaching 62%,
and stays null where the standard has no category rather than being invented.
Fixed — exclude_patterns never applied to gitleaks findings
gitleaks reports repository-relative paths and every other scanner reports
absolute ones. Every consumer that asked "where is this?" resolved a
relative path against the process working directory, so the answer came
back "not excluded" — silently, and fail-open. Four tests/certs/*.key
files held requests at F and six documentation examples held flask
there. Paths are now anchored at the one constructor every adapter passes
through.
Separately, **/ did not match at depth zero, so **/*_test.go never
matched a root-level context_test.go.
Fixed — gitleaks could not see the working tree
detect --source scans commits only, so a plaintext key sitting
uncommitted produced "no leaks found". Both gitleaks dir and gitleaks
git now run; the overlap merges.
Fixed — the tool audited its own output
secure-code-report.md lands in the audited tree and the next run scanned
it, finding a "secret" at line 11,529 of its own report. Two identical
audits of an unchanged repository returned 0.00 and 4.25. The run now drops
the paths it is about to write — and their lines — from both its findings
and its LOC denominator.
Fixed — other
B308/B703are the same Bandit check under two ids and were counted twice;merge_corroboratingcollapses them and records the second sighting rather than discarding it.sca.offline.ruby.code-injectionmatchedclass_eval(&block), Ruby's safe form. Offline profile bumped tosca-offline@1.2.0, and the ruleset digest is now pinned to the profile version.- A suppressed finding was republished as
axis: primary, scored: true. - A finding that tripped the gate could be absent from the report, and side-axis findings could never be baselined.
- Scanner versions were recorded as ANSI escape sequences when a tool
colourised
--version(njsscan read as[34m).
Calibration — the study was measuring the wrong population
Six of fourteen corpus repositories scored near-perfectly because nobody had
looked at them: Python produced 712–5,107 real findings each, everything
else 0–4. njsscan and rubocop were not installed; Go and Java cannot be
read by this floor at all (D12). The four unreadable repositories had their
own median of 5.00 and were holding the corpus median up.
The study now reports the distribution over repositories a language scanner actually read — examined median 3.38, in the B band, which is the target — and names what it excluded. No band edge needed moving.
njsscan and rubocop are required for a valid calibration run.
0.5.0 — 2026-09-10
Fixed — 0.4.0 reports itself as 0.3.0
__version__ was a literal in src/secure_code_audit/__init__.py and the
version was also a literal in pyproject.toml. They drifted: pyproject moved
to 0.4.0 and the package did not.
The released 0.4.0 wheel on PyPI contains __version__ = "0.3.0". Verified
by downloading it. Every artifact that build produces names the wrong producer:
SARIF tool.driver.version, the JSON report's version, the Markdown report
header, --version, and security-pillar.json's producer.version.
The release workflow verified the tag against pyproject's line and never read the package, so it checked the half that was right and shipped the half that was wrong.
PyPI is immutable, so 0.4.0 stays wrong. If you have artifacts produced by 0.4.0, their producer version is understated by one release. Upgrade to 0.5.0, which is the first build whose artifacts name themselves correctly.
The fix removes the duplication rather than policing it. pyproject.toml now
declares dynamic = ["version"] and reads
secure_code_audit.__version__, so there is one literal and the two cannot
disagree. The release workflow reads the package. A first attempt used
importlib.metadata instead and was worse — it reports whatever distribution
happens to be installed, which in a working checkout was a stale 0.1.0, and
provenance that depends on the reader's install state is not provenance.
Added
--security-pillar <path>emitssecurity-pillar.jsonformaintainability-agentto ingest (D3, contract indocs/ma-integration.md). Carries a practice level read from configuration and CI beside a code condition read from the scanners, as two values that are never averaged.conditionisnullwhenever coverage is incomplete. Standalone use is unaffected.- Dependency findings move to their own reported axis, still gated. A CVE
in a pinned dependency is fixed with a version bump; an injection flaw is
fixed with a rewrite. Measured as the largest remaining scoring distortion
after the test tree — median
worst_normalized15.36 against 7.32. maintainability-agent's P1–P8 promise table adopted intoproduct-intent.md, with P6 recorded as currently unmet because the calibration study ran Semgrep online.
0.4.0 — 2026-09-09
Correction. This build reports itself as
0.3.0in every artifact it produces — see 0.5.0 above. The release is otherwise as described; only the producer version is wrong, and PyPI cannot be amended in place.
All eight v0.3.0 release blockers are closed, and so are the four structural
problems the architecture audit ranked
(architecture.md §2, §3, §4). Two decisions remain
open on purpose and are named at the foot of this section.
Changed — behaviour that will move existing results
Pre-1.0, and these are corrections rather than features, but a repository audited with 0.3.0 can score differently under 0.4.0:
- Findings from paths matching
paths.exclude_patternsno longer appear. They did for ten of fifteen scanners, while the same patterns already excluded those files from the LOC denominator — so scores were computed with a numerator and a denominator measuring different repositories. Expect fewer findings and, in repositories with large excluded trees, a different score. - The markdown report and PR comment now withhold a grade whenever the JSON
does. If you have no
gates.require_scannersdeclared, output that used to readScore: … A+now readsFinding score (not a verified grade). Declare the scanner set to get a verified grade back. Scanner.run()is nowScanner.scan()and returns aScanResult. This breaks any out-of-tree scanner adapter. Nothing in the documented CLI or config surface changes; see D13 for the migration shape.
Added
- The offline Semgrep ruleset is now a versioned profile:
sca-offline, with a version and a SHA-256 digest of the shipped file, cited in scanner provenance assca-offline@1.1.0 (digest…). A finding can name the rules that produced it and be re-checked against the same baseline. The digest is evidence where the version is only an assertion — editing the ruleset inside an installed wheel leaves the version unchanged and the digest does not. See D10. - The profile is 20 rules across 5 languages — JavaScript/TypeScript, Go, Ruby, Java, and the two Python patterns Bandit measurably misses. Every rule declares a CWE, an OWASP bucket, a confidence and a version, and every rule is paired-tested: a fixture it must flag and a fixture it must not. Proving a rule fires is the easy half. A dedicated CI job runs both, because these tests skip where Semgrep is absent and a skip nobody notices is how a ruleset rots.
- The profile covers the languages the floor cannot read offline, not Python.
An intermediate revision carried 19 Python rules; measured against Bandit, 17
of them duplicated it. Bandit is in the floor, is Apache-2.0, and is already
offline — so the "offline coverage" argument that justified them was simply
wrong, and
CONTRIBUTING.mdhas forbidden a parallel ruleset since the first commit. Enforced bytest_no_python_rule_duplicates_bandit. See D11.
That entry originally continued "nothing in the floor reads JavaScript, Go,
Ruby or Java without the network". It was asserted without running any of
those tools, and two of them existed — see D12 below, which is the same
mistake caught a second time and is why the coverage check is now automated
per language.
- product-intent.md §5 principle 2 amended. It read as an unqualified refusal
to author rules, which we already violated twice over. The bound that
replaces it: our ruleset is the offline floor, not a competitor — language
primitives in, framework rules out, enforced by a test rather than by review.
See D9.
Added — the coverage check, and the tools it found
- The floor gained njsscan and RuboCop, and gosec as opt-in. D11 shrank the
Python rules against Bandit, then justified the remaining twenty-one with a
sentence nobody tested. Running the check found that njsscan (LGPL-3.0+)
covers Node's md5, sha1,
Math.randomand theNODE_TLS_REJECT_UNAUTHORIZEDform, and RuboCop's Security cops (MIT) cover Ruby'seval,Marshal.loadandYAML.load— both offline, both clean on the negative fixtures. Three rules were deleted and two narrowed to the half the tool leaves. See D12. - The gaps that remain are measured, not assumed. njsscan's exec/eval/DOM-XSS
rules are taint rules gated on an Express
(req, res)handler shape, so they are silent in a CLI script or a Lambda handler. gosec loads packages throughgo listand cannot read Go without the toolchain — the boundary MA's ADR-012 draws for SpotBugs, and why it is opt-in rather than floor. PMD's entire Java security category is two rules, neither of them ours. - Adapters now return a
ScanResultstating what happened, rather than signalling it by naming a finding.classify_executionis deleted, not deprecated. See D13. - Parsers are tested against real captured scanner output, not against
hand-written mocks — six tools captured, the other nine declared in
SCANNERS_WITHOUT_A_REAL_CAPTUREwith reasons. Plustests/integration/test_scoring_drift.py, whichCONTRIBUTING.mdhad cited for far longer than it existed. See D14.
Fixed — defects found by the tool auditing itself
paths.exclude_patternswas honoured by five of fifteen adapters. The rest have no flag for it, and nothing filtered centrally — so a config excludingtests/still reported findings from deliberately-vulnerable fixtures. Worse,exclude_patternsis also the LOC denominator, so findings from excluded paths were scored against lines that were never counted. Enforced once now, after collection, for every adapter and every SARIF import alike. Control findings are exempt, or a failed scanner would go silent.- Every tagged release would have failed at its own preflight. The release
workflow installed
.[dev,required-scanners]while the config requires the floor, leaving checkov, osv-scanner, trivy, semgrep and gitleaks unresolved. The floor install now lives in one composite action both workflows use. - Reports named a clean category as the worst one.
min()over the grade dict returns the first key in enum order on a tie, and every category grades 5.0 when nothing counts against it — so an all-informational finding set reported "worst category: secrets" on a repository with zero secrets findings. It isNonewhen nothing graded below the ceiling, and ties break by finding count rather than enum order. - The markdown report and PR comment claimed grades the JSON withheld.
Verdictdecides once whether a run can claim a verified grade, but two renderers were never wired to it and kept a weaker condition, so a run with nogates.require_scannersprinted a bare A+ in the two artifacts people actually read while the JSON reportedverified_grade: null. - The JSON report was the one output missing from
.gitignore.
Fixed — remaining known defects
- The default
secure-code-agent.jsonis read from the audited project, not the shell's working directory. The config was loaded before the target was resolved, so auditing another tree from this repository applied this repository's required scanners to it, and a tree carrying its own policy was audited without it. Release-blockers §7. - The emitted SARIF
$schemapointed at anoasis-tcsraw path that returns 404. It now uses the OASIS canonical URL, which returns 200 — both were checked rather than assumed. The documents always validated; only the locator a consumer would follow was broken. Release-blockers §8. - The release-blocker checklist listed every item twice, once ticked and once not, from a keep-both conflict resolution during a rebase. A checklist that says an item is both done and not done is worse than no checklist.
Security
- Repository-supplied configuration can no longer choose what the host
executes.
scanners.<name>.commandaccepted a relative path, resolved it under the audit target, and ran it — so a repository shipping its ownsecure-code-agent.jsonselected the auditing host's executable. That is threat model T1, realised by the tool. A config found inside the tree may no longer name a command that also resolves inside the tree; the scanner reportsunavailable, which fails required coverage. A config kept outside the tree is an operator artifact and keeps the documented tree-local interpreter workflow.--trust-target-configis the explicit opt-in, and is command-line-only so a config cannot grant itself the trust. See D1. - The containment check applies to the resolved path from every resolution
route, not only the relative-path branch, because
PATHcan contain.or a tree-local directory.
Fixed
- Withholding evidence can no longer buy a better grade. The score is a
rate over findings, so disabling scanners removed findings and the number
rose — on one tree, from 0.00/F to 5.00/A+, the best possible letter earned
by looking less hard. Scanner coverage did not catch it, because a disabled
scanner produces no execution record at all. A letter grade is now issued
only when
gates.require_scannersdeclares a scanner set and coverage confirms it ran; otherwiseverified_gradeis null with reasons. The numeric estimate is unchanged and still reported — it is honest about what was found, it simply is not a grade. Borrowed frommaintainability-agent's P3 and ADR 001. - The decision to qualify or withhold a score is made once, in
scoring.verdict(), instead of independently in five renderers — the duplicationarchitecture.md§3 recorded, where the SARIF site was missed on the first pass. - Unknown top-level configuration keys are rejected instead of ignored. The
schema declared
additionalProperties: falsewhile the loader accepted anything, so a typo'd gate name silently disabled a gate. See D2.
Documentation
docs/decisions.md— a decision register, recording the trust ruling and the three integration decisions taken for themaintainability-agentSecurity pillar.
Still open, deliberately
Neither is a defect, and neither can be settled by a refactor — both are about whether the number this tool reports means anything.
- The condition scale is uncalibrated (D5). The bands
were borrowed from
maintainability-agentwithout its calibration study, and thesqrt(LOC/1000)dampener is an invented normalizer with no corpus behind it.tests/integration/test_scoring_drift.pypins the scale's stability; nobody has established that A+ corresponds to anything real. Treat the letter as a relative signal, not an absolute one. - The score's null state is still "perfect"
(
architecture.md§5). A repository nothing could be scanned in produces zero findings and therefore A+. That is why coverage exists as a separate axis and why a verified grade is withheld without a declared scanner set — but it still asks a reader to combine two numbers. Whether the deliverable should be a single verdict is a product decision.
0.3.0 — 2026-08-09
Coverage-integrity release. A clean finding set and successful scanner coverage are now reported and gated separately.
Added
- Enforced
gates.require_scanners. Required scanners now fail coverage when unavailable, timed out, failed, emitted invalid output, were not applicable, or were excluded from the selected scan. - Structured scanner provenance in Markdown and JSON reports: outcome, resolved command, reported version, finding count, and failure reason.
- Explicit per-scanner
commandconfiguration with safeshell=Falseexecution, relative-path resolution from the target,PATHlookup, and Python module fallback for Bandit, pip-audit, Semgrep, and Checkov. - pip-audit input modes for recursively discovered or explicit requirements,
pyproject.tomlprojects, locked projects, and configured environments. - Focused regression tests for coverage gates, CLI exit behavior, executable resolution, pyproject auditing, lock mode, and invalid scanner output.
- PyYAML as an explicit bounded runtime dependency so the advertised
.scignore.yamlsecurity control works in clean installations. - A pinned
required-scannersextra and composite-action installation path for Bandit 1.9.4 and pip-audit 2.10.1. - A minimal pinned self-audit input for the package's supported runtime dependency floor, isolated from unrelated CI and development tools.
- Tag releases re-run lint, formatting, branch coverage, and required scanner coverage before attestation or publication.
--preflightresolves enabled scanners and reports each one's command, version, and install remedy without running an audit, exiting nonzero when a required scanner is unavailable or not enabled.- Per-scanner install guidance, surfaced in unavailable findings and preflight output. The agent still installs nothing itself; acquisition stays with the operator's package manager or a pinned CI action.
- A
python-scannersextra for Semgrep and Checkov, andrequired-scannersfolded intodevso a fresh checkout can run its own audit. - Imported SARIF now counts as scanner coverage and can satisfy
gates.require_scanners, which is the supported way to gate on scanners that ship as standalone binaries.--sarif-import NAME=PATHnames a run whose SARIF driver name does not match its configured id. - An install matrix in
docs/scanners.mdand a development setup section inCONTRIBUTING.md. - An exact Ruff pin and a Markdown exclusion, so
ruff format --checkis reproducible. Ruff 0.16 began formatting Python blocks embedded in Markdown; with an unpinned formatter that turned an upstream release into a red build with no change in this repository.
Fixed
- CI now uploads the audit SARIF to Code Scanning when required coverage fails.
The step carried no status-check function, so GitHub's implicit
success()skipped it exactly when the SARIF reportedexecutionSuccessful: false, leaving the Security tab showing an older clean result. - Gitleaks and TruffleHog no longer record a clean scan after signalling findings. Gitleaks exit 1 with an empty or non-array report, and TruffleHog exit 183 with empty output, previously returned zero findings and classified as a completed scan — discarding the scanner's own assertion that it found secrets, in the highest-weighted category. Both now fail coverage. A clean exit with no output remains a clean scan.
scanners.semgrep.online: falsenow genuinely runs offline. It selectedp/security-auditand described it as a bundled ruleset; that is a Semgrep Registry pack fetched over the network, so an air-gapped run failed on a certificate error while the config claimed offline coverage. A ten-rule ruleset now ships in the wheel and is used instead, with atool_errorif it is missing rather than a fallback that reaches the network. It is narrower than the Registry packs anddocs/scanners.mdsays so.- Semgrep findings now carry a CWE. Semgrep folds
metadata.cweintoproperties.tagsrather thanproperties.cwe, which the adapter alone read, so every Semgrep finding — Registry rules included — arrived unmapped and scored without CWE Top-25 weighting. - Semgrep runs with
--no-rewrite-rule-ids. Semgrep otherwise prefixes rule ids with the config file's path, which varies by install location and would destabilize baseline fingerprints across machines. --fail-on-gateis refused when no gate is configured. Previously an audit could detect a HIGH CWE-78 finding, score it 0.00/F, report it in full, and still exit 0, because every gate was absent and an absent gate does not trip. A gate key that is present but inert — an empty severity list, an empty cap map, amin_scoreof 0 — does not count as configured. Report-only audits without--fail-on-gateare unaffected.- Scanner timeouts, unexpected exits, missing output, and parse failures no longer silently look like successful clean scans in the updated adapters.
- Unreadable, malformed, or run-less
--sarif-importinput previously ingested zero findings with no warning and no coverage signal; it now fails the gate. An import that reports its ownexecutionSuccessful: falseis recorded as a failed execution rather than laundered into a clean result. - Imported SARIF is now recorded as
unverifiedrather thancompleted. We never observed the process, so a successful import means the operator vouched for the artifact, not that we watched it succeed —executionSuccessful: trueis a file describing itself. Unverified coverage still satisfiesrequire_scannersand does not degrade status toPARTIAL, but is named in every output: the terminal summary,coverage.unverifiedin JSON, andunverifiedScannersin the emitted SARIF. A local run outranks an import of the same scanner. - Structurally malformed imported SARIF is contained as failed coverage instead
of raising an uncaught
AttributeError. A non-object run or a non-arrayresultsfails that run and still emits the full evidence set; non-object entries inside a validresultsarray are skipped rather than discarding the scanner's other findings. - A scanner reported both by local execution and by SARIF import now resolves to its worst outcome, so a clean import cannot mask a failed local run.
- CLI
--fail-on-newnow ignores informational control findings consistently with configuredgates.fail_on_new. - Unknown configured or required scanner names fail configuration validation.
- The composite GitHub Action installs its own checked-out source instead of an unrelated latest PyPI release and now emits its advertised JSON report.
--changed-onlynow fails explicitly instead of silently running an unscoped audit; safe changed-file orchestration remains future work.- Multiple positional scan roots now fail explicitly instead of scanning only the first root and overstating coverage.
- The inert
asvs_levelsetting is now rejected and removed from the schema; this release does not claim an ASVS-level gate.
Documentation
- Corrected pip-audit project invocation, baseline approval, scanner network, cross-scanner deduplication, report escaping, and release-provenance claims so documented guarantees match executable behavior.
0.2.0 — 2026-05-13
The Tier-2 release. Six new scanners ship as full adapters with standards mappings and mocked-subprocess test coverage.
Added — Tier-2 scanners
- Trivy (
trivy) — containers / filesystem / IaC scanning. SARIF-native; output is routed intodependencies/config_iac/secretsby rule-id prefix (CVE-/GHSA-/AVD-/secret). - Checkov (
checkov) — IaC misconfig scanning across Terraform / CloudFormation / Helm / k8s / Dockerfile. SARIF-native; all findings land inconfig_iac. - Hadolint (
hadolint) — Dockerfile linting. Twelve security-relevant rule ids (DL3002USER root→ HIGH, DL3025 shell-form CMD → MEDIUM, etc.) get specific severities; the remaining DL/SC rules fall through to LOW. - OSV-Scanner (
osv_scanner) — multi-ecosystem SCA via osv.dev. Overlaps pip-audit + npm-audit by design; stable fingerprints support baseline matching, but this release did not yet deduplicate scores across scanners. - TruffleHog (
trufflehog) — verified secret scanning. Defaults to--only-verifiedso we ship live-confirmed matches at CRITICAL only; operators can opt into unverified viaextra_args: ["--no-only-verified"]. - OpenSSF Scorecard (
scorecard) — repo + supply-chain hygiene. Special- cased to operate against a remote GitHub URL (auto-detected viagit remote get-url origin). Score → severity:<3 → HIGH,<7 → MEDIUM,<10 → LOW,10 → no finding,<0 → INFORMATIONAL(inconclusive).
Added — standards mappings
New (scanner, rule_id) → CWE/OWASP/ASVS/SSDF mappings for all six Tier-2
scanners. Scorecard checks get per-check mappings for Branch-Protection,
Signed-Releases, Pinned-Dependencies, Token-Permissions,
Security-Policy, Dangerous-Workflow plus a wildcard fallback.
Fixed
owasp_url()now generates a deep-link to the specific Top-10 bucket (A03_2021-Injection) instead of always returning the index URL.fail_on_newgate no longer trips onINFORMATIONALfindings (e.g.tool_unavailablenotices). Those are awareness signals, not security defects.
Refactored
scoring.evaluate_gates()extracted into per-gate evaluators (cognitive complexity 17 → 8). Dogfooding the maintainability-agent standard.sarif.ingest()decomposed into_finding_from_run,_finding_from_result,_severity_from_result,_cwe_from_rule,_location_from_result(cognitive complexity 38 → 4 in the top-level function).
Tests
- 15 new unit tests across the six Tier-2 scanners, covering: unavailable- binary path, SARIF/JSON output parsing, severity routing, category routing, empty-output handling.
- Total: 76 tests pass.
0.1.0 — 2026-05-13
Initial public release.
Added
- Deterministic CLI gate over six Tier-1 scanners: Bandit, Semgrep, pip-audit, npm audit, Gitleaks, and a built-in regex rule pack.
- SARIF 2.1.0 emit + ingest. External SARIF (CodeQL, Snyk, Trivy, etc.) is combined with locally-run findings; identical findings are not collapsed.
- Standards taxonomy: mapped findings include a CWE id used in stable fingerprints, plus an OWASP Top 10 bucket, OWASP ASVS section, and NIST SSDF practice. Top-25 CWEs get a 1.25× scoring boost.
- Nine canonical audit categories:
secrets,dependencies,code_vulnerabilities,auth_authz,crypto,supply_chain,config_iac,logging_observability,policy_docs. - Scoring model with worst-category-drives-grade semantics (mirrors
maintainability-agent's pattern). Letter grade A+ → F. - Hard gates:
fail_on_severity,fail_on_category,fail_on_new,min_score,max_unsuppressed. Compose with OR semantics. - Suppressions (
.scignore.yaml) with mandatoryreason+expires(max 365 days). Past-expiry suppressions become CRITICAL findings on their own. - Baseline (
secure-code-baseline.json) for incremental adoption. Per-fingerprintbumped_byrecords the operator's gituser.email. - Bounded remediation prompt generator (
secure-code-remediation-prompt.md) — the differentiator. Hard constraints encoded against the documented AI-fix failure modes (crypto roulette, auth-flow rewrite, validation softening, test deletion, lint disable, scope creep). - Per-agent standards file emit via
--init-agent-standards. Targets:codex,claude-code,cursor,copilot,windsurf,generic. - Invokable skill bundle at
skills/secure-code-agent/(Anthropic-format SKILL.md + Codex + Copilot adapters) — portable across Claude Code, Codex, and Copilot Chat. - Comprehensive docs: design, standards anchors, scoring math, scanner caveats, remediation rationale, threat model.
Known limitations
- No CodeQL invocation — CodeQL runs in GitHub-hosted analysis; we ingest
its SARIF artifact via
--sarif-import. - Tier-2 scanners (Trivy, Checkov, Hadolint, OSV-Scanner, OpenSSF Scorecard) have protocol stubs but no full implementation in v0.1 — tracked for v0.2.
- No language coverage beyond what Tier-1 scanners support (Python, JS/TS). Go / Java / Rust / Ruby scanners (gosec, SpotBugs, Brakeman) tracked for v0.3.