Skip to content

Commit 7347d0a

Browse files
Merge branch 'main' into fix/error-message
2 parents 6f0db48 + bb3d10d commit 7347d0a

14 files changed

Lines changed: 741 additions & 297 deletions

File tree

.github/workflows/trusted-sonarcloud-pr.yml

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ jobs:
1717
github.event.workflow_run.repository.full_name == github.repository
1818
runs-on: ubuntu-24.04
1919
concurrency:
20-
# One group per PR lets a newer revision cancel stale analysis. The helper
21-
# still binds every materialized object to this run's verified head SHA.
22-
group: sonar-pr-${{ github.event.workflow_run.pull_requests[0].number }}
20+
# GitHub can omit pull_requests for fork runs. Use the PR number when it is
21+
# present and a stable fork-repository/branch identity otherwise so newer
22+
# revisions cancel stale analysis without colliding with unrelated forks.
23+
group: sonar-pr-${{ github.event.workflow_run.pull_requests[0].number || format('{0}-{1}', github.event.workflow_run.head_repository.id, github.event.workflow_run.head_branch) }}
2324
cancel-in-progress: true
2425
steps:
2526
# These helpers come only from protected main. Pull-request source is never
@@ -61,22 +62,54 @@ jobs:
6162
.event == $event and .repository.full_name == $repository and
6263
(.head_sha | test("^[0-9a-f]{40}$")) and
6364
(.head_repository.full_name | type == "string" and length > 0) and
64-
(.pull_requests | type == "array" and length == 1)' <<<"$run" >/dev/null
65+
(.head_repository.owner.login | type == "string" and length > 0) and
66+
(.head_branch | type == "string" and length > 0) and
67+
(.pull_requests | type == "array" and (length == 0 or length == 1))' <<<"$run" >/dev/null
6568
66-
pr_number="$(jq -er '.pull_requests[0].number | select(type == "number" and . > 0)' <<<"$run")"
6769
head_sha="$(jq -er '.head_sha' <<<"$run")"
6870
run_head_repository="$(jq -er '.head_repository.full_name' <<<"$run")"
71+
head_owner="$(jq -er '.head_repository.owner.login' <<<"$run")"
72+
head_ref="$(jq -er '.head_branch' <<<"$run")"
73+
associated_pr_count="$(jq -er '.pull_requests | length' <<<"$run")"
74+
75+
if (( associated_pr_count == 1 )); then
76+
pr_number="$(jq -er '.pull_requests[0].number | select(type == "number" and . > 0)' <<<"$run")"
77+
else
78+
candidates="$(gh api --method GET "repos/${repository}/pulls" \
79+
-f state=open \
80+
-f base="$expected_branch" \
81+
-f head="${head_owner}:${head_ref}" \
82+
-f per_page=2)"
83+
pr_number="$(jq -er \
84+
--arg repository "$repository" \
85+
--arg branch "$expected_branch" \
86+
--arg head_repository "$run_head_repository" \
87+
--arg head_ref "$head_ref" \
88+
--arg sha "$head_sha" \
89+
'if length != 1 then
90+
error("expected exactly one open pull request for verified workflow run head")
91+
elif (.[0].state != "open" or
92+
.[0].base.repo.full_name != $repository or .[0].base.ref != $branch or
93+
.[0].head.repo.full_name != $head_repository or .[0].head.ref != $head_ref or
94+
.[0].head.sha != $sha) then
95+
error("pull request candidate does not match verified workflow run head")
96+
else
97+
.[0].number | select(type == "number" and . > 0)
98+
end' <<<"$candidates")"
99+
fi
100+
69101
pull_request="$(gh api "repos/${repository}/pulls/${pr_number}")"
70102
jq -e \
71103
--arg repository "$repository" \
72104
--arg branch "$expected_branch" \
73105
--arg head_repository "$run_head_repository" \
106+
--arg head_ref "$head_ref" \
74107
--arg sha "$head_sha" \
75108
--argjson pr_number "$pr_number" \
76-
'.number == $pr_number and .base.repo.full_name == $repository and
77-
.base.ref == $branch and .head.repo.full_name == $head_repository and
109+
'.number == $pr_number and .state == "open" and
110+
.base.repo.full_name == $repository and .base.ref == $branch and
111+
.head.repo.full_name == $head_repository and .head.ref == $head_ref and
78112
.head.sha == $sha' <<<"$pull_request" >/dev/null
79-
head_ref="$(jq -er '.head.ref | select(type == "string" and length > 0)' <<<"$pull_request")"
80113
81114
artifacts="$(gh api "repos/${repository}/actions/runs/${RUN_ID}/artifacts?per_page=100")"
82115
artifact_id="$(jq -er --arg name "$expected_artifact" --arg sha "$head_sha" --argjson run_id "$RUN_ID" \

docs/ci-security.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ The `Lint and Test` workflow's **Advisory workflow policy feedback (not required
4141

4242
## Pull-request reporting
4343

44-
The pull-request execution job is intentionally limited to `contents: read` and does not receive `SONAR_TOKEN`, a protected environment, or another privileged credential. It uploads the inert JUnit report for check publication and selects only `.coverage/out` and `.coverage/report-junit.xml` for a deterministic seven-day `sonar-reports-<run-id>-<attempt>` artifact. GitHub's artifact action strips their common `.coverage` parent, so the downloaded artifact contract is exactly two root-level regular files named `out` and `report-junit.xml`. After validating names, types, and size bounds, protected code normalizes those files under `reports/.coverage/` so the trusted scanner keeps its established report paths without broadening the accepted artifact surface. Missing coverage is a visible failed Sonar preparation result; it is never a silently skipped analysis.
44+
The pull-request execution job is intentionally limited to `contents: read` and does not receive `SONAR_TOKEN`, a protected environment, or another privileged credential. It uploads the inert JUnit report for check publication and selects only `.coverage/out` and `.coverage/report-junit.xml` for a deterministic seven-day `sonar-reports-<run-id>-<attempt>` artifact. GitHub's artifact action strips their common `.coverage` parent, so the downloaded artifact contract is exactly two root-level regular files named `out` and `report-junit.xml`. After validating names, types, and size bounds, protected code requires canonical repository-relative Go coverage records, prefixes each record path with the fixed `source/` scanner namespace used by API materialization, and normalizes both files under `reports/.coverage/`. This preserves the established report locations and aligns coverage keys with `analysis/source` without broadening the accepted artifact surface. Missing or malformed coverage is a visible failed Sonar preparation result; it is never a silently skipped analysis.
4545

4646
## Trusted SonarCloud pull-request analysis
4747

4848
`Trusted SonarCloud pull-request analysis` is a protected-default-branch `workflow_run` workflow for completed `Lint and Test` pull-request runs. It is the only PR path that can receive `SONAR_TOKEN`. The workflow has only `contents: read` permission, uses no job container or services, restores or saves no cache, and supports both same-repository and fork pull requests without executing either source tree.
4949

50-
Before any secret-bearing step, protected code resolves and binds the expected workflow and event, base repository and `main` branch, verified head repository (including fork identity), pull-request number, full current head SHA, run ID, run attempt, and exactly one unexpired `sonar-reports-<run-id>-<attempt>` artifact. The report download remains tied to that run and revision. Its protected validator accepts only the bounded regular coverage and JUnit files and rejects missing reports, oversized content, symlinks, special files, traversal-derived paths, and unexpected entries.
50+
Before any secret-bearing step, protected code resolves and binds the expected workflow and event, base repository and `main` branch, verified head repository (including fork identity), pull-request number, full current head SHA, run ID, run attempt, and exactly one unexpired `sonar-reports-<run-id>-<attempt>` artifact. GitHub may omit pull-request associations from a fork-originated Actions run. In that case, protected code queries only open pull requests targeting `Ensono/eirctl:main` by the verified fork owner and exact head branch, requires exactly one result, and revalidates its state, base repository/ref, head repository/ref, and immutable SHA against the run. Missing, ambiguous, closed, stale, or mismatched candidates fail closed before artifact download. Concurrency uses the event pull-request number when present and otherwise a stable workflow-run head-repository/branch identity, so unrelated forks do not collide while newer revisions cancel stale analysis. The report download remains tied to that run and revision. Its protected validator accepts only bounded regular UTF-8 coverage and JUnit files and rejects missing reports, malformed coverage modes or records, unsafe coverage paths, invalid encoding, oversized content, symlinks, special files, traversal-derived paths, and unexpected entries.
5151

5252
Pull-request source is never passed to `actions/checkout`, `git checkout`, `git fetch`, an archive extractor, or another source action. After writing trusted `analysis/sonar-project.properties` outside the source root, the protected standard-library helper resolves the exact head commit and root tree through the verified head repository's Git Data API, requires a complete non-truncated recursive tree, validates every canonical path and mode, and retrieves each selected regular non-executable `.go` blob by its tree-recorded SHA. It verifies the API identity, declared and decoded size, and Git blob identity before making an exclusive `0644` write beneath the newly created `analysis/source` root. Symlinks, submodules, special or unknown entries, unsafe or duplicate paths, executable `.go` files, non-Go files, and changed PR heads fail closed or remain unmaterialized. The helper rechecks the current PR head after all writes.
5353

internal/schema/github.go

Lines changed: 101 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -46,49 +46,22 @@ func (events *GithubTriggerEvents) Has(name string) bool {
4646
// scalar event, a sequence of events, or a mapping containing event options.
4747
func (events *GithubTriggerEvents) UnmarshalYAML(node *yaml.Node) error {
4848
*events = GithubTriggerEvents{configured: map[string]struct{}{}}
49-
configure := func(name string, value *yaml.Node) error {
50-
events.configured[name] = struct{}{}
51-
if value == nil || value.Tag == "!!null" {
52-
return nil
53-
}
54-
switch name {
55-
case "push":
56-
return value.Decode(&events.Push)
57-
case "pull_request":
58-
return value.Decode(&events.PullRequest)
59-
case "pull_request_target":
60-
return value.Decode(&events.PullRequestTarget)
61-
case "issue_comment":
62-
return value.Decode(&events.IssueComment)
63-
case "repository_dispatch":
64-
return value.Decode(&events.RepositoryDispatch)
65-
case "schedule":
66-
return value.Decode(&events.Schedule)
67-
case "workflow_dispatch":
68-
return value.Decode(&events.WorkflowDispatch)
69-
case "workflow_run":
70-
return value.Decode(&events.WorkflowRun)
71-
default:
72-
return nil
73-
}
74-
}
75-
7649
switch node.Kind {
7750
case yaml.ScalarNode:
78-
return configure(node.Value, nil)
51+
return events.configure(node.Value, nil)
7952
case yaml.SequenceNode:
8053
for _, value := range node.Content {
8154
if value.Kind != yaml.ScalarNode {
8255
return fmt.Errorf("github trigger: expected an event name but got %v", value.Kind)
8356
}
84-
if err := configure(value.Value, nil); err != nil {
57+
if err := events.configure(value.Value, nil); err != nil {
8558
return err
8659
}
8760
}
8861
return nil
8962
case yaml.MappingNode:
9063
for i := 0; i+1 < len(node.Content); i += 2 {
91-
if err := configure(node.Content[i].Value, node.Content[i+1]); err != nil {
64+
if err := events.configure(node.Content[i].Value, node.Content[i+1]); err != nil {
9265
return fmt.Errorf("github trigger %s: %w", node.Content[i].Value, err)
9366
}
9467
}
@@ -98,6 +71,33 @@ func (events *GithubTriggerEvents) UnmarshalYAML(node *yaml.Node) error {
9871
}
9972
}
10073

74+
func (events *GithubTriggerEvents) configure(name string, value *yaml.Node) error {
75+
events.configured[name] = struct{}{}
76+
if value == nil || value.Tag == "!!null" {
77+
return nil
78+
}
79+
switch name {
80+
case "push":
81+
return value.Decode(&events.Push)
82+
case "pull_request":
83+
return value.Decode(&events.PullRequest)
84+
case "pull_request_target":
85+
return value.Decode(&events.PullRequestTarget)
86+
case "issue_comment":
87+
return value.Decode(&events.IssueComment)
88+
case "repository_dispatch":
89+
return value.Decode(&events.RepositoryDispatch)
90+
case "schedule":
91+
return value.Decode(&events.Schedule)
92+
case "workflow_dispatch":
93+
return value.Decode(&events.WorkflowDispatch)
94+
case "workflow_run":
95+
return value.Decode(&events.WorkflowRun)
96+
default:
97+
return nil
98+
}
99+
}
100+
101101
// PushEvent represents a push event trigger configuration.
102102
type GithubPushEvent struct {
103103
Branches []string `json:"branches,omitempty" yaml:"branches,omitempty"`
@@ -226,21 +226,26 @@ func (job *GithubJob) UnmarshalYAML(node *yaml.Node) error {
226226
return fmt.Errorf("github job: expected a mapping node but got %v", node.Kind)
227227
}
228228
values := yamlMapping(node)
229-
decode := func(name string, target any) error {
230-
value := values[name]
231-
if value == nil {
232-
return nil
233-
}
234-
if err := value.Decode(target); err != nil {
235-
return fmt.Errorf("github job field %s: %w", name, err)
236-
}
237-
return nil
238-
}
239-
240229
*job = GithubJob{configured: make(map[string]struct{}, len(values))}
241230
for name := range values {
242231
job.configured[name] = struct{}{}
243232
}
233+
if err := decodeGithubJobFields(values, job); err != nil {
234+
return err
235+
}
236+
if err := decodeGithubJobNeeds(values["needs"], job); err != nil {
237+
return err
238+
}
239+
if err := decodeGithubJobEnvironment(values["environment"], job); err != nil {
240+
return err
241+
}
242+
if err := decodeGithubJobContainer(values["container"], job); err != nil {
243+
return err
244+
}
245+
return validateGithubJobSteps(job.Steps)
246+
}
247+
248+
func decodeGithubJobFields(values map[string]*yaml.Node, job *GithubJob) error {
244249
for name, target := range map[string]any{
245250
"name": &job.Name,
246251
"runs-on": &job.RunsOn,
@@ -253,47 +258,70 @@ func (job *GithubJob) UnmarshalYAML(node *yaml.Node) error {
253258
"services": &job.Services,
254259
"concurrency": &job.Concurrency,
255260
} {
256-
if err := decode(name, target); err != nil {
257-
return err
261+
value := values[name]
262+
if value == nil {
263+
continue
264+
}
265+
if err := value.Decode(target); err != nil {
266+
return fmt.Errorf("github job field %s: %w", name, err)
258267
}
259268
}
269+
return nil
270+
}
260271

261-
if needs := values["needs"]; needs != nil {
262-
switch needs.Kind {
263-
case yaml.ScalarNode:
264-
job.Needs = []string{needs.Value}
265-
case yaml.SequenceNode:
266-
if err := needs.Decode(&job.Needs); err != nil {
267-
return fmt.Errorf("github job field needs: %w", err)
268-
}
269-
default:
270-
return fmt.Errorf("github job field needs: expected a scalar or sequence but got %v", needs.Kind)
272+
func decodeGithubJobNeeds(needs *yaml.Node, job *GithubJob) error {
273+
if needs == nil {
274+
return nil
275+
}
276+
switch needs.Kind {
277+
case yaml.ScalarNode:
278+
job.Needs = []string{needs.Value}
279+
return nil
280+
case yaml.SequenceNode:
281+
if err := needs.Decode(&job.Needs); err != nil {
282+
return fmt.Errorf("github job field needs: %w", err)
271283
}
284+
return nil
285+
default:
286+
return fmt.Errorf("github job field needs: expected a scalar or sequence but got %v", needs.Kind)
272287
}
288+
}
273289

274-
if environment := values["environment"]; environment != nil {
275-
if environment.Kind == yaml.ScalarNode {
276-
job.Environment = environment.Value
277-
} else {
278-
var configured struct {
279-
Name string `yaml:"name"`
280-
}
281-
if err := environment.Decode(&configured); err != nil {
282-
return fmt.Errorf("github job field environment: %w", err)
283-
}
284-
job.Environment = configured.Name
285-
}
290+
func decodeGithubJobEnvironment(environment *yaml.Node, job *GithubJob) error {
291+
if environment == nil {
292+
return nil
293+
}
294+
if environment.Kind == yaml.ScalarNode {
295+
job.Environment = environment.Value
296+
return nil
297+
}
298+
var configured struct {
299+
Name string `yaml:"name"`
286300
}
301+
if err := environment.Decode(&configured); err != nil {
302+
return fmt.Errorf("github job field environment: %w", err)
303+
}
304+
job.Environment = configured.Name
305+
return nil
306+
}
287307

288-
if container := values["container"]; container != nil && container.Tag != "!!null" {
289-
job.Container = &GithubContainer{}
290-
if container.Kind == yaml.ScalarNode {
291-
job.Container.Image = container.Value
292-
} else if err := container.Decode(job.Container); err != nil {
293-
return fmt.Errorf("github job field container: %w", err)
294-
}
308+
func decodeGithubJobContainer(container *yaml.Node, job *GithubJob) error {
309+
if container == nil || container.Tag == "!!null" {
310+
return nil
311+
}
312+
job.Container = &GithubContainer{}
313+
if container.Kind == yaml.ScalarNode {
314+
job.Container.Image = container.Value
315+
return nil
316+
}
317+
if err := container.Decode(job.Container); err != nil {
318+
return fmt.Errorf("github job field container: %w", err)
295319
}
296-
for index, step := range job.Steps {
320+
return nil
321+
}
322+
323+
func validateGithubJobSteps(steps []*GithubStep) error {
324+
for index, step := range steps {
297325
if step == nil {
298326
return fmt.Errorf("github job step %d must be a mapping", index)
299327
}

internal/schema/github_test.go

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,26 @@ func TestGithubWorkflowUnmarshalTriggerForms(t *testing.T) {
5050
if err := yaml.Unmarshal([]byte(contents), &workflow); err != nil {
5151
t.Fatal(err)
5252
}
53-
for _, trigger := range tt.triggers {
54-
if !workflow.On.Has(trigger) {
55-
t.Errorf("On.Has(%q) = false", trigger)
56-
}
57-
}
58-
if workflow.On.Has("workflow_run") {
59-
if got := workflow.On.WorkflowRun.Workflows; len(got) != 1 || got[0] != "Lint and Test" {
60-
t.Fatalf("WorkflowRun.Workflows = %#v", got)
61-
}
62-
}
53+
assertGithubTriggers(t, workflow.On, tt.triggers)
6354
})
6455
}
6556
}
6657

58+
func assertGithubTriggers(t *testing.T, events *GithubTriggerEvents, triggers []string) {
59+
t.Helper()
60+
for _, trigger := range triggers {
61+
if !events.Has(trigger) {
62+
t.Errorf("On.Has(%q) = false", trigger)
63+
}
64+
}
65+
if !events.Has("workflow_run") {
66+
return
67+
}
68+
if got := events.WorkflowRun.Workflows; len(got) != 1 || got[0] != "Lint and Test" {
69+
t.Fatalf("WorkflowRun.Workflows = %#v", got)
70+
}
71+
}
72+
6773
func TestGithubJobUnmarshalPolicyFields(t *testing.T) {
6874
contents := []byte(`on: [push]
6975
jobs:

0 commit comments

Comments
 (0)