Repository navigation
Share scanner finding conversion across integrations #67643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d1bdf6d
Share scanner finding conversion across integrations
pelikhan 67438b9
Add draft ADR-67643 for shared scanner finding conversion
github-actions[bot] 21aa2f7
Merge remote-tracking branch 'origin/main' into pelikhan-shared-scann…
Copilot de643e7
Use named params for shared scanner findings
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
79 changes: 79 additions & 0 deletions
79
docs/adr/67643-share-scanner-finding-conversion-via-constructor-helper.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # ADR-67643: Share Scanner Finding Conversion via a Constructor Helper | ||
|
|
||
| **Date**: 2026-10-10 | ||
| **Status**: Proposed | ||
| **Deciders**: pelikhan (PR author); gh-aw maintainers (review pending) | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| Four scanner integrations in `pkg/cli` — zizmor (`zizmor.go`), poutine (`poutine.go`), grype (`grype.go`), | ||
| and runner-guard (`runner_guard.go`) — each parse a third-party tool's own JSON schema into a | ||
| tool-specific wire struct, then hand-write a near-identical conversion into the shared | ||
| `scanfindings.Finding` type (`pkg/scanfindings/scanfindings.go`). The copy-pasted blocks repeated the same | ||
| `RuleID` / `ParseSeverity(...)` / `File` / `Line` / `Column` / `ContextLines(...)` mapping, so every new | ||
| scanner integration paid a copy-paste tax and risked diverging on severity normalization or context | ||
| attachment (issue #67202). The native wire structs themselves must stay separate because they mirror each | ||
| tool's external JSON contract, and the user-visible behaviour of each scanner — message formatting, | ||
| location selection, per-scanner defaults, filtering, and rendering — must not change. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will add a single shared constructor, `scanfindings.NewFinding(params)`, taking a | ||
| `scanfindings.NewFindingParams` value with named fields for the rule, severity label, message, display | ||
| location, and source lines. It owns the common mapping: severity normalization via `ParseSeverity` and | ||
| source-context attachment via `ContextLines`. Named fields make call sites self-documenting and prevent | ||
| same-typed values from being silently transposed. Each of the four adapters supplies values it has | ||
| already computed, so scanner-specific message formatting, location selection, and defaults remain in the | ||
| adapter where they belong. The helper centralizes only the mechanical, identical part of the conversion | ||
| and leaves the semantics of each scanner untouched. `AuditFinding` and `SecurityFinding` are explicitly | ||
| out of scope for this change. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: A `rawScanFinding` interface implemented by each wire struct | ||
|
|
||
| The original issue suggested defining an interface (e.g. `RuleID() string`, `Severity() string`, | ||
| `Location() (string, int, int)`) that each tool-specific struct implements, with one generic conversion | ||
| function. This was rejected because the per-scanner logic that actually differs — message composition from | ||
| rule tables, multi-location selection, fallback file paths, row/column offsets — is not uniform enough to | ||
| hide behind accessor methods; each struct would need bespoke methods that simply relocate the same code | ||
| behind more indirection, while making the adapters harder to read. | ||
|
|
||
| #### Alternative 2: Leave the duplication in place | ||
|
|
||
| Four short, obvious literal constructions are cheap to read, and duplication in adapters is a common, | ||
| defensible pattern. This was rejected because the duplication is exact rather than incidental: severity | ||
| normalization and context attachment are invariants of the shared `Finding` type, and letting each adapter | ||
| restate them invites silent drift as scanners are added — the concrete motivation recorded in issue #67202. | ||
|
|
||
| #### Alternative 3: Use positional constructor parameters | ||
|
|
||
| A constructor with one argument per field would avoid duplicating conversion logic, but adjacent | ||
| same-typed values could be accidentally transposed without a compiler error. A parameter struct was | ||
| chosen instead so each call site names the field it supplies. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - New scanner integrations get severity normalization and context attachment for free by calling one helper. | ||
| - Severity/context behaviour can be changed in one place instead of four, removing a class of drift bugs. | ||
| - Named parameter fields make scanner call sites clearer and avoid silent argument-order mistakes. | ||
| - Native-JSON regression tests added for all four adapters pin the existing output semantics, so future | ||
| refactors of the helper are guarded. | ||
|
|
||
| #### Negative | ||
| - The helper always calls `ContextLines`; an adapter that wants a finding without context must pass `nil`, | ||
| which couples all adapters to one context policy. | ||
|
|
||
| #### Neutral | ||
| - Adapters keep their own wire structs and message-formatting code, so the duplication reduction is partial | ||
| by design. | ||
| - `AuditFinding` (`audit_report.go`) and `SecurityFinding` (`markdown_security_scanner.go`) remain separate | ||
| cousin types; folding them in is deferred to a future decision. | ||
| - No public CLI behaviour or JSON schema changes; the change is internal to `pkg/cli` and `pkg/scanfindings`. | ||
|
|
||
| --- | ||
|
|
||
| *ADR proposed by the PR author; acceptance is pending maintainer review.* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "testing" | ||
|
|
||
| "github.com/github/gh-aw/pkg/scanfindings" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestZizmorFindingsToSharedPreservesNativeSemantics(t *testing.T) { | ||
| var findings []zizmorFinding | ||
| require.NoError(t, json.Unmarshal([]byte(`[ | ||
| { | ||
| "ident":"template-injection","desc":"untrusted input","url":"https://example.com/rule", | ||
| "determinations":{"severity":"High"}, | ||
| "locations":[ | ||
| {"symbolic":{"key":{"Local":{"given_path":"first.yml"}}}, | ||
| "concrete":{"location":{"start_point":{"row":2,"column":4}}}}, | ||
| {"symbolic":{"key":{"Local":{"given_path":"second.yml"}}}, | ||
| "concrete":{"location":{"start_point":{"row":8,"column":9}}}} | ||
| ] | ||
| }, | ||
| {"ident":"no-location","determinations":{"severity":"Critical"}}, | ||
| {"ident":"informational","desc":"notice","determinations":{"severity":"note"}, | ||
| "locations":[{"concrete":{"location":{"start_point":{"row":0,"column":0}}}}]} | ||
| ]`), &findings)) | ||
|
|
||
| lines := []string{"one", "two", "three", "four", "five"} | ||
| require.Equal(t, []scanfindings.Finding{ | ||
| { | ||
| RuleID: "template-injection", Severity: scanfindings.SeverityHigh, | ||
| Message: "[High] template-injection: untrusted input (https://example.com/rule)", | ||
| File: "display.yml", Line: 3, Column: 5, Context: lines, | ||
| }, | ||
| { | ||
| RuleID: "informational", Severity: scanfindings.SeverityInfo, | ||
| Message: "[note] informational: notice", | ||
| File: "display.yml", Line: 1, Column: 1, Context: []string{"one"}, | ||
| }, | ||
| }, zizmorFindingsToShared("display.yml", findings, lines)) | ||
| } | ||
|
|
||
| func TestPoutineFindingsToSharedPreservesNativeSemantics(t *testing.T) { | ||
| var output poutineOutput | ||
| require.NoError(t, json.Unmarshal([]byte(`{ | ||
| "findings":[ | ||
| {"rule_id":"known","purl":"pkg:github/example", | ||
| "meta":{"path":"native.yml","line":3,"details":"extra detail"}}, | ||
| {"rule_id":"missing","meta":{}}, | ||
| {"rule_id":"note","meta":{"path":"note.yml","line":-1}} | ||
| ], | ||
| "rules":{ | ||
| "known":{"id":"known","title":"Rule title","description":"unused","level":"error"}, | ||
| "note":{"id":"note","level":"note"} | ||
| } | ||
| }`), &output)) | ||
|
|
||
| lines := []string{"one", "two", "three", "four", "five"} | ||
| require.Equal(t, []scanfindings.Finding{ | ||
| { | ||
| RuleID: "known", Severity: scanfindings.SeverityHigh, | ||
| Message: "[error] known: Rule title - extra detail", | ||
| File: "native.yml", Line: 3, Column: 1, Context: lines, | ||
| }, | ||
| { | ||
| RuleID: "missing", Severity: scanfindings.SeverityMedium, | ||
| Message: "[warning] missing: missing", | ||
| File: "fallback.yml", Line: 1, Column: 1, Context: []string{"one"}, | ||
| }, | ||
| { | ||
| RuleID: "note", Severity: scanfindings.SeverityInfo, | ||
| Message: "[note] note: note", | ||
| File: "note.yml", Line: -1, Column: 1, | ||
| }, | ||
| }, poutineFindingsToShared(output.Findings, output.Rules, "fallback.yml", lines)) | ||
| } | ||
|
|
||
| func TestGrypeFindingsToSharedPreservesNativeSemantics(t *testing.T) { | ||
| var output grypeOutput | ||
| require.NoError(t, json.Unmarshal([]byte(`{"matches":[ | ||
| { | ||
| "vulnerability":{"id":"CVE-1","dataSource":"https://example.com/CVE-1", | ||
| "severity":"Critical","fix":{"versions":["1.2","1.3"],"state":"fixed"}}, | ||
| "artifact":{"name":"lib","version":"1.0","type":"deb"} | ||
| }, | ||
| {"vulnerability":{"id":"CVE-2"},"artifact":{"name":"other","version":"2.0"}}, | ||
| {"vulnerability":{"id":"CVE-3","severity":"Negligible"}, | ||
| "artifact":{"name":"minor","version":"3.0"}} | ||
| ]}`), &output)) | ||
|
|
||
| require.Equal(t, []scanfindings.Finding{ | ||
| { | ||
| RuleID: "CVE-1", Severity: scanfindings.SeverityCritical, | ||
| Message: "[Critical] CVE-1: lib@1.0 (fix: 1.2, 1.3) (https://example.com/CVE-1)", | ||
| File: "image:tag", Line: 1, Column: 1, | ||
| }, | ||
| { | ||
| RuleID: "CVE-2", Severity: scanfindings.SeverityUnknown, | ||
| Message: "[Unknown] CVE-2: other@2.0", | ||
| File: "image:tag", Line: 1, Column: 1, | ||
| }, | ||
| { | ||
| RuleID: "CVE-3", Severity: scanfindings.SeverityLow, | ||
| Message: "[Negligible] CVE-3: minor@3.0", | ||
| File: "image:tag", Line: 1, Column: 1, | ||
| }, | ||
| }, grypeFindingsToShared("image:tag", output.Matches)) | ||
| } | ||
|
|
||
| func TestRunnerGuardFindingsToSharedPreservesNativeSemantics(t *testing.T) { | ||
| var output runnerGuardOutput | ||
| require.NoError(t, json.Unmarshal([]byte(`{"score":50,"grade":"D","findings":[ | ||
| {"rule_id":"RGS-001","name":"Rule name","severity":"HIGH", | ||
| "description":"extra detail","remediation":"unused","file":"native.yml", | ||
| "job_id":"agent","line":3}, | ||
| {"rule_id":"RGS-002","name":"Unnamed severity","file":"default.yml","line":0}, | ||
| {"rule_id":"RGS-003","severity":"low","file":"negative.yml","line":-1} | ||
| ]}`), &output)) | ||
|
|
||
| lines := []string{"one", "two", "three", "four", "five"} | ||
| require.Equal(t, []scanfindings.Finding{ | ||
| { | ||
| RuleID: "RGS-001", Severity: scanfindings.SeverityHigh, | ||
| Message: "[HIGH] RGS-001: Rule name - extra detail", | ||
| File: "native.yml", Line: 3, Column: 1, Context: lines, | ||
| }, | ||
| { | ||
| RuleID: "RGS-002", Severity: scanfindings.SeverityUnknown, | ||
| Message: "RGS-002: Unnamed severity", | ||
| File: "default.yml", Line: 1, Column: 1, Context: []string{"one"}, | ||
| }, | ||
| { | ||
| RuleID: "RGS-003", Severity: scanfindings.SeverityLow, | ||
| Message: "[low] RGS-003", | ||
| File: "negative.yml", Line: -1, Column: 1, | ||
| }, | ||
| }, runnerGuardFindingsToShared(output.Findings, lines)) | ||
| } | ||
|
|
||
| func TestScannerFindingsToSharedEmptyInput(t *testing.T) { | ||
| require.Empty(t, zizmorFindingsToShared("file.yml", nil, nil)) | ||
| require.Empty(t, poutineFindingsToShared(nil, nil, "file.yml", nil)) | ||
| require.Empty(t, grypeFindingsToShared("image:tag", nil)) | ||
| require.Empty(t, runnerGuardFindingsToShared(nil, nil)) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.