Repository navigation
791 lines (728 loc) · 38.1 KB
/
Copy pathsecurity.yml
File metadata and controls
791 lines (728 loc) · 38.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
# Self-contained org-wide PR security scanner.
# Copy to: .github/workflows/security.yml in any bookmd repo. No external engine repo required.
#
# Jobs: deps (OSV-Scanner) -> ai-scan (Claude on Bedrock, phases 1-4) -> report (scanner-stats ingest)
# Secrets (org level): SCANNER_STATS_URL + SCANNER_STATS_TOKEN (stats, optional), SOCKET_API_TOKEN (optional)
# Bedrock auth is GitHub OIDC -> IAM role; no API key secret needed.
name: Security
on:
pull_request:
# Pushes are debounced before scanning so the required verdict still covers the
# latest head without paying for every commit in a burst.
types: [opened, synchronize, reopened, ready_for_review, labeled]
concurrency:
group: security-scan-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
id-token: write
contents: read
pull-requests: write
issues: write
actions: read
env:
AWS_REGION: us-east-1
BEDROCK_ROLE_ARN: arn:aws:iam::232282424912:role/bedrock-cicd-scanner
# IT sets APPSEC_BEDROCK_MODEL_ARN to the approved Sonnet 5 application inference
# profile. Keep the known-good Opus profile as a fail-safe until that variable exists.
BEDROCK_MODEL_ARN: ${{ vars.APPSEC_BEDROCK_MODEL_ARN || 'arn:aws:bedrock:us-east-1:232282424912:application-inference-profile/kaofehoxbka6' }}
SCANNER_TAG: org-v3-cost-optimized
jobs:
deps:
name: Dependency scan (OSV)
if: >-
github.event.pull_request.draft == false &&
(github.event.action != 'labeled' || github.event.label.name == 'appsec-rescan')
runs-on: ubuntu-latest
steps:
- name: Debounce pushed commits
if: github.event.action == 'synchronize'
# A newer push cancels this run through the per-PR concurrency group.
run: sleep 600
- uses: actions/checkout@v4
- name: Run OSV-Scanner
run: |
curl -sSL -o /usr/local/bin/osv-scanner \
https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
chmod +x /usr/local/bin/osv-scanner
osv-scanner --format json --recursive . > osv-results.json || true
jq -e . osv-results.json >/dev/null 2>&1 || echo '{"results":[]}' > osv-results.json
echo "OSV result groups: $(jq '.results | length' osv-results.json)"
- uses: actions/upload-artifact@v4
with:
name: osv-results
path: osv-results.json
ai-scan:
name: AI security scan (phases 1-4)
needs: deps
if: >-
github.event.action != 'labeled' || github.event.label.name == 'appsec-rescan'
runs-on: ubuntu-latest
# Hard cap so a hung model call can never pin the check (and the PR) indefinitely.
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
name: osv-results
- name: Select scan policy
id: policy
env:
PR_ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.base_ref }}
run: |
mode=full
reason=
if [ "$PR_ACTOR" = "vim-gitops-app[bot]" ]; then
changed_files="$(git diff --name-only "origin/$BASE_REF...HEAD")"
if [ -n "$changed_files" ] &&
! printf '%s\n' "$changed_files" | grep -Ev '(^|/)dummy\.txt$' >/dev/null; then
mode=dependency-only
reason="verified GitOps trigger-only PR (all changed files are dummy.txt)"
fi
fi
echo "mode=$mode" >> "$GITHUB_OUTPUT"
if [ "$mode" = dependency-only ]; then
echo "Generated-code bot '$PR_ACTOR': $reason; run OSV and deterministic reporting."
else
echo "Run full scan for '$PR_ACTOR'; bot identity alone never bypasses SAST."
fi
- uses: aws-actions/configure-aws-credentials@v4
if: steps.policy.outputs.mode == 'full'
with:
role-to-assume: ${{ env.BEDROCK_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Prepare scan workspace + Socket MCP config
env:
SOCKET_API_TOKEN: ${{ secrets.SOCKET_API_TOKEN }}
run: |
mkdir -p .security-scan
mv osv-results.json .security-scan/osv-results.json
if [ -n "$SOCKET_API_TOKEN" ]; then
jq -n --arg t "$SOCKET_API_TOKEN" \
'{mcpServers:{socket:{type:"http",url:"https://mcp.socket.dev/",headers:{Authorization:("Bearer "+$t)}}}}' \
> .security-scan/mcp.json
else
echo '{"mcpServers":{"socket":{"type":"http","url":"https://mcp.socket.dev/"}}}' > .security-scan/mcp.json
fi
- name: Record dependency-only scan
if: steps.policy.outputs.mode == 'dependency-only'
env:
PR_ACTOR: ${{ github.actor }}
run: |
jq --arg actor "$PR_ACTOR" '
{
scan_policy: "dependency-only",
policy_reason: ("Bedrock scan skipped for generated-code bot " + $actor),
phases_completed: ["deps"],
findings: [],
dependency_findings: [
.results[]?.packages[]?
| .package as $p
| (.vulnerabilities // []) as $v
| select(($v | length) > 0)
| {
package: $p.name,
version: $p.version,
ecosystem: (($p.ecosystem // "unknown") | ascii_downcase),
osv_ids: [$v[]?.id] | map(select(. != null)),
reachable: null,
action: (
if any(
$v[];
((.database_specific.severity // "") | ascii_upcase) == "CRITICAL"
or ((.database_specific.severity // "") | ascii_upcase) == "HIGH"
)
then "BLOCK"
else "WARN"
end
)
}
]
}
' .security-scan/osv-results.json > .security-scan/findings.json
# The scan prompt is shared between attempt 1 and the retry — kept in a file to stay DRY.
- name: Write scan prompt
if: steps.policy.outputs.mode == 'full'
env:
REPO: ${{ github.repository }}
run: |
mkdir -p .security-scan
cat > .security-scan/prompt.txt <<'PROMPT'
You are the bookmd PR security scanner. Repo: __REPO__, PR #$PR_NUMBER,
head $HEAD_SHA, base branch $BASE_REF. Analyze ONLY risk introduced or made reachable by this PR
(get the diff with: git diff origin/$BASE_REF...HEAD).
Run four phases:
1. **pr-review** — line-level vulnerabilities in the diff: injection (SQL/NoSQL/command/template),
XSS, path traversal, SSRF, insecure deserialization, hardcoded secrets, weak crypto,
missing authn/authz, unsafe redirects, prototype pollution.
2. **architecture** — trust-boundary and design issues: new endpoints without guards, broadened
permissions, disabled validation, unsafe defaults, CORS/CSP regressions.
3. **taint** — source-to-sink: trace user-controlled input across the changed files to dangerous
sinks (queries, exec, fs, network, eval, innerHTML). Follow calls into unchanged files when needed.
4. **deps** — read `.security-scan/osv-results.json`. For each vulnerable or newly added package,
use the Socket MCP `depscore` tool when available. Judge REACHABILITY from this codebase:
action=BLOCK only if vulnerable AND likely reachable; WARN if vulnerable but unproven
reachability or depscore < 0.5; otherwise INFO.
Severity rubric: critical = exploitable now with material impact; high = exploitable with
preconditions; medium = defense-in-depth gap; low = hardening; info = note.
REQUIRED OUTPUT 1 — write the file `.security-scan/findings.json` exactly in this shape
(valid JSON, no trailing commas; omit unknown optional fields):
{
"phases_completed": ["pr-review","architecture","taint","deps"],
"findings": [
{"phase":"pr-review|architecture|taint","rule":"kebab-case-rule-id",
"severity":"critical|high|medium|low|info","confidence":"high|medium|low",
"title":"one line","description":"what/why","recommendation":"fix",
"file_path":"path/from/repo/root.ts","line_start":1,"line_end":2,
"cwe":"CWE-89","posted_inline":false}
],
"dependency_findings": [
{"package":"name","version":"1.2.3","ecosystem":"npm","osv_ids":["GHSA-..."],
"advisory_severity":"critical|high|medium|low|info","socket_depscore":0.42,
"reachable":true,"action":"WARN|BLOCK|INFO"}
]
}
Report ALL findings in the file, even info-level. `phases_completed` MUST list every phase you
actually ran — the gate treats a missing phase as "scan did not complete". Only write the file
once you have genuinely completed pr-review, architecture, and taint.
Do NOT post any PR comments and do NOT try to make the job pass or fail. Deterministic
workflow steps own everything downstream: they render findings.json into the sticky PR
summary comment (severity table + every finding with its file:line and remediation) and
into inline comments on each critical finding, and the "Security gate" step reads
findings.json and decides whether to block. Your ONLY job is to write an accurate,
complete findings.json — include `recommendation` on every finding, because that text is
what the developer sees as the remediation in the Vim AppSec Agent PR comment.
PROMPT
sed -i "s|__REPO__|${REPO}|g" .security-scan/prompt.txt
# Expose the prompt to both scan attempts as a multiline env var (DRY).
{
echo "SCAN_PROMPT<<SCAN_PROMPT_EOF"
cat .security-scan/prompt.txt
echo "SCAN_PROMPT_EOF"
} >> "$GITHUB_ENV"
- name: Claude security scan (attempt 1)
id: scan1
if: steps.policy.outputs.mode == 'full'
continue-on-error: true
# Pin the reviewed release. Claude Code 2.1.293 supports the Sonnet 5 Bedrock
# profile; the older 2.1.210 runner stalled until the 25-minute job timeout.
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 (Claude Code 2.1.293)
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
with:
# explicit token skips the Claude GitHub App token exchange (which requires
# the workflow file to exist identically on the default branch)
github_token: ${{ github.token }}
use_bedrock: "true"
# Policy selection handles the one verified trigger-only GitOps shape. Keep the
# action enabled for every other bot (including the publish bot, Dependabot, and
# Renovate), otherwise it silently skips and the fail-closed gate sees no findings.json.
allowed_bots: "*"
claude_args: |
--model ${{ env.BEDROCK_MODEL_ARN }}
--mcp-config .security-scan/mcp.json
--allowedTools "Read,Grep,Glob,Write,Bash(git diff:*),Bash(git log:*),Bash(gh pr view:*),mcp__socket__depscore"
prompt: ${{ env.SCAN_PROMPT }}
# A known no-op can return success without writing findings.json; retry only that
# shape with backoff. Explicit action failures fail closed immediately so denied,
# invalid, or misconfigured requests are never amplified.
# The happy path (attempt 1 completes) skips all of this.
- name: Check attempt 1 output
id: check1
if: always() && steps.policy.outputs.mode == 'full'
run: |
if jq -e '[("pr-review","architecture","taint") as $p | ([.phases_completed[]?] | index($p))] | all(. != null)' \
.security-scan/findings.json >/dev/null 2>&1; then
echo "complete=true" >> "$GITHUB_OUTPUT"
else
echo "complete=false" >> "$GITHUB_OUTPUT"
fi
- name: Classify attempt 1 failure
id: retry1
if: always() && steps.policy.outputs.mode == 'full' && steps.check1.outputs.complete != 'true'
env:
ATTEMPT_OUTCOME: ${{ steps.scan1.outcome }}
run: |
retryable=true
reason="successful action produced no complete output (known transient no-op)"
if [ "$ATTEMPT_OUTCOME" = "failure" ]; then
retryable=false
reason="action failed explicitly (do not amplify auth/model/invalid-request failures)"
fi
echo "retryable=$retryable" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
echo "attempt 1: $reason; retryable=$retryable"
- name: Back-off before attempt 2
if: steps.policy.outputs.mode == 'full' && steps.check1.outputs.complete != 'true' && steps.retry1.outputs.retryable == 'true'
run: sleep 45
- name: Claude security scan (attempt 2 — retry)
id: scan2
if: steps.policy.outputs.mode == 'full' && steps.check1.outputs.complete != 'true' && steps.retry1.outputs.retryable == 'true'
continue-on-error: true
# Pinned — see the note on attempt 1 above.
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 (Claude Code 2.1.293)
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
with:
github_token: ${{ github.token }}
use_bedrock: "true"
allowed_bots: "*"
claude_args: |
--model ${{ env.BEDROCK_MODEL_ARN }}
--mcp-config .security-scan/mcp.json
--allowedTools "Read,Grep,Glob,Write,Bash(git diff:*),Bash(git log:*),Bash(gh pr view:*),mcp__socket__depscore"
prompt: ${{ env.SCAN_PROMPT }}
- name: Check attempt 2 output
id: check2
if: always() && steps.policy.outputs.mode == 'full'
run: |
if jq -e '[("pr-review","architecture","taint") as $p | ([.phases_completed[]?] | index($p))] | all(. != null)' \
.security-scan/findings.json >/dev/null 2>&1; then
echo "complete=true" >> "$GITHUB_OUTPUT"
else
echo "complete=false" >> "$GITHUB_OUTPUT"
fi
- name: Classify attempt 2 failure
id: retry2
if: always() && steps.policy.outputs.mode == 'full' && steps.scan2.outcome != 'skipped' && steps.check2.outputs.complete != 'true'
env:
ATTEMPT_OUTCOME: ${{ steps.scan2.outcome }}
run: |
retryable=true
reason="successful action produced no complete output (known transient no-op)"
if [ "$ATTEMPT_OUTCOME" = "failure" ]; then
retryable=false
reason="action failed explicitly (do not amplify auth/model/invalid-request failures)"
fi
echo "retryable=$retryable" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
echo "attempt 2: $reason; retryable=$retryable"
- name: Back-off before attempt 3
if: steps.policy.outputs.mode == 'full' && steps.check2.outputs.complete != 'true' && steps.retry2.outputs.retryable == 'true'
run: sleep 90
- name: Claude security scan (attempt 3 — final retry)
id: scan3
if: steps.policy.outputs.mode == 'full' && steps.check2.outputs.complete != 'true' && steps.retry2.outputs.retryable == 'true'
continue-on-error: true
# Pinned — see the note on attempt 1 above.
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 (Claude Code 2.1.293)
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
with:
github_token: ${{ github.token }}
use_bedrock: "true"
allowed_bots: "*"
claude_args: |
--model ${{ env.BEDROCK_MODEL_ARN }}
--mcp-config .security-scan/mcp.json
--allowedTools "Read,Grep,Glob,Write,Bash(git diff:*),Bash(git log:*),Bash(gh pr view:*),mcp__socket__depscore"
prompt: ${{ env.SCAN_PROMPT }}
- name: Validate scan output
id: validate
if: always()
env:
SCAN_POLICY: ${{ steps.policy.outputs.mode }}
run: |
F=.security-scan/findings.json
scan_ok=true; reason=""
if [ ! -f "$F" ]; then
scan_ok=false; reason="no findings.json written"
elif ! jq -e . "$F" >/dev/null 2>&1; then
scan_ok=false; reason="findings.json is not valid JSON"
elif [ "$SCAN_POLICY" = "dependency-only" ]; then
if ! jq -e '(.phases_completed // []) | index("deps") != null' "$F" >/dev/null 2>&1; then
scan_ok=false; reason="dependency-only scan did not complete deps phase"
fi
else
missing=$(jq -r '(["pr-review","architecture","taint"] - [.phases_completed[]?]) | join(", ")' "$F" 2>/dev/null || echo "all")
if [ -n "$missing" ]; then scan_ok=false; reason="phases did not complete: ${missing}"; fi
fi
echo "scan_ok=$scan_ok" >> "$GITHUB_OUTPUT"
echo "reason=$reason" >> "$GITHUB_OUTPUT"
echo "scan_ok=$scan_ok ${reason:+($reason)}"
- name: Ensure findings file exists
if: always()
run: |
mkdir -p .security-scan
[ -f .security-scan/findings.json ] || \
echo '{"phases_completed":[],"findings":[],"dependency_findings":[]}' > .security-scan/findings.json
# Deterministic sticky summary comment rendered from findings.json (NOT model-posted),
# so a completed scan always explains its findings + remediation on the PR. continue-on-error
# keeps a comment/API hiccup from failing the required check — the gate owns the verdict.
- name: Post findings summary comment
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BLOCK_ON_SEVERITY: high
SCAN_OK: ${{ steps.validate.outputs.scan_ok }}
SCAN_REASON: ${{ steps.validate.outputs.reason }}
run: |
set -uo pipefail
F=.security-scan/findings.json
M=.security-scan/summary.md
sev_heading() { case "$1" in
critical) printf 'Critical';; high) printf 'High';; medium) printf 'Medium';;
low) printf 'Low';; info) printf 'Info';; *) printf '%s' "$1";; esac; }
crit=$(jq '[.findings[]?|select((.severity|ascii_downcase)=="critical")]|length' "$F")
high=$(jq '[.findings[]?|select((.severity|ascii_downcase)=="high")]|length' "$F")
med=$(jq '[.findings[]?|select((.severity|ascii_downcase)=="medium")]|length' "$F")
low=$(jq '[.findings[]?|select((.severity|ascii_downcase)=="low")]|length' "$F")
info=$(jq '[.findings[]?|select((.severity|ascii_downcase)=="info")]|length' "$F")
total=$(jq '[.findings[]?]|length' "$F")
depblock=$(jq '[.dependency_findings[]?|select(.action=="BLOCK")]|length' "$F")
depwarn=$(jq '[.dependency_findings[]?|select(.action=="WARN")]|length' "$F")
scan_policy=$(jq -r '.scan_policy // "full"' "$F")
policy_reason=$(jq -r '.policy_reason // ""' "$F")
case "${BLOCK_ON_SEVERITY}" in
info) threshold=0 ;; low) threshold=1 ;; medium) threshold=2 ;;
high) threshold=3 ;; critical) threshold=4 ;; *) threshold=4 ;;
esac
blocking=$(jq --argjson t "$threshold" '
[ .findings[]? | (.severity|ascii_downcase) as $s
| ({info:0,low:1,medium:2,high:3,critical:4}[$s] // 0)
| select(. >= $t) ] | length' "$F")
if [ "${SCAN_OK}" != "true" ]; then
verdict="**Incomplete** — scan did not finish (${SCAN_REASON:-unknown}). Re-run the job; this is not a clean bill of health."
elif [ "${BLOCK_ON_SEVERITY}" != "none" ] && { [ "${blocking}" -gt 0 ] || [ "${depblock}" -gt 0 ]; }; then
verdict="**Blocked** — ${blocking} finding(s) at/above \`${BLOCK_ON_SEVERITY}\`"
[ "${depblock}" -gt 0 ] && verdict="${verdict}, ${depblock} dependency BLOCK(s)"
verdict="${verdict}."
elif [ "$scan_policy" = "dependency-only" ]; then
verdict="**Passed (dependency-only policy)** — OSV completed; Bedrock phases were intentionally skipped."
elif [ "$total" -eq 0 ] && [ "$depblock" -eq 0 ] && [ "$depwarn" -eq 0 ]; then
verdict="**Passed** — no findings introduced by this PR."
else
verdict="**Passed** — findings are below the gate (blocks at \`${BLOCK_ON_SEVERITY}\` or dependency BLOCK)."
fi
{
echo "<!-- security-scanner-results:v1 -->"
echo "## Vim AppSec Agent"
echo
echo "$verdict"
echo
echo "| Critical | High | Medium | Low | Info | Dep BLOCK | Dep WARN |"
echo "|:-:|:-:|:-:|:-:|:-:|:-:|:-:|"
echo "| **$crit** | **$high** | **$med** | **$low** | **$info** | **$depblock** | **$depwarn** |"
echo
if [ "$scan_policy" = "dependency-only" ]; then
echo "> **Scan policy:** ${policy_reason}"
echo
fi
if [ "$total" -gt 0 ]; then
echo "### Findings"
echo
for sev in critical high medium low info; do
count=$(jq --arg s "$sev" '[.findings[]?|select((.severity|ascii_downcase)==$s)]|length' "$F")
[ "$count" -gt 0 ] || continue
echo "#### $(sev_heading "$sev") ($count)"
echo
jq -r --arg s "$sev" '
.findings // []
| map(select((.severity|ascii_downcase)==$s))
| .[]
| [ (.title // "Untitled"),
((.file_path // "n/a") + (if .line_start then ":" + (.line_start|tostring) else "" end)),
(.recommendation // "—"),
(.cwe // "") ] | @tsv
' "$F" | while IFS=$'\t' read -r title loc rec cwe; do
printf -- '**%s**\n' "$title"
printf -- '- **Where:** `%s`\n' "$loc"
[ -n "$cwe" ] && printf -- '- **CWE:** %s\n' "$cwe"
printf -- '- **Remediation:** %s\n' "$rec"
echo
done
done
fi
if [ "$depblock" -gt 0 ] || [ "$depwarn" -gt 0 ]; then
echo "### Dependencies"
echo
jq -r '.dependency_findings // [] | map(select(.action=="BLOCK" or .action=="WARN")) | .[]
| [ .action, (.package + "@" + (.version // "?")), ((.osv_ids // [])|join(", ")) ] | @tsv' "$F" \
| while IFS=$'\t' read -r act pkg ids; do
printf -- '- **%s** `%s`' "$act" "$pkg"
[ -n "${ids:-}" ] && printf ' — %s' "$ids"
printf '\n'
done
echo
fi
if [ "$scan_policy" = "full" ] && [ "$total" -eq 0 ] && [ "$depblock" -eq 0 ] && [ "$depwarn" -eq 0 ] && [ "${SCAN_OK}" = "true" ]; then
echo "_Nothing to remediate on this PR._"
echo
fi
echo "---"
echo
echo "Gate blocks at severity **${BLOCK_ON_SEVERITY}** or a dependency marked **BLOCK**."
echo
echo "Questions, false positives, or feedback — ping **@abir-vim**."
} > "$M"
existing=$(gh api repos/"$REPO"/issues/"$PR_NUMBER"/comments --paginate \
--jq '.[] | select(.body | contains("security-scanner-results:v1")) | .id' | head -1)
if [ -n "$existing" ]; then
gh api -X PATCH repos/"$REPO"/issues/comments/"$existing" -F body=@"$M" >/dev/null && echo "updated summary comment $existing"
else
gh api repos/"$REPO"/issues/"$PR_NUMBER"/comments -F body=@"$M" >/dev/null && echo "created summary comment"
fi
# Deterministic inline comments for critical findings (idempotent via a per-location marker),
# then mark those findings posted_inline=true so scanner-stats reflects it.
- name: Post inline comments for critical findings
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -uo pipefail
F=.security-scan/findings.json
[ -f "$F" ] || exit 0
gh api repos/"$REPO"/pulls/"$PR_NUMBER"/comments --paginate --jq '.[].body' \
> .security-scan/existing-inline.txt 2>/dev/null || true
jq -r '
.findings // [] | to_entries[]
| .key as $i | .value
| select((.severity|ascii_downcase)=="critical" and .file_path and .line_start)
| [ ($i|tostring), .file_path, (.line_start|tostring), .title, (.recommendation // "See the summary comment.") ] | @tsv
' "$F" > .security-scan/crit.tsv || true
posted=""
while IFS=$'\t' read -r idx path line title rec; do
[ -n "${path:-}" ] || continue
marker="<!-- ss-inline:${path}:${line} -->"
if grep -qF "$marker" .security-scan/existing-inline.txt 2>/dev/null; then
echo "inline exists, skip: $path:$line"; posted="$posted $idx"; continue
fi
body=$(printf '**Vim AppSec Agent** — critical\n\n**%s**\n\n**Remediation:** %s\n\nQuestions or false positives — ping @abir-vim.\n%s' "$title" "$rec" "$marker")
if gh api repos/"$REPO"/pulls/"$PR_NUMBER"/comments \
-f body="$body" -f commit_id="$HEAD_SHA" -f path="$path" -F line="$line" -f side=RIGHT >/dev/null 2>&1; then
echo "posted inline: $path:$line"; posted="$posted $idx"
else
echo "::warning title=Vim AppSec Agent::could not post inline comment for $path:$line (line may be outside the PR diff)"
fi
done < .security-scan/crit.tsv
for idx in $posted; do
jq --argjson i "$idx" '.findings[$i].posted_inline = true' "$F" > "$F.tmp" && mv "$F.tmp" "$F"
done
- uses: actions/upload-artifact@v4
if: always()
with:
name: scan-findings
path: .security-scan/findings.json
# Single required check with deterministic semantics. Runs even if the model steps errored
# (they are continue-on-error), so the gate — not an infra hiccup — owns the verdict.
- name: Security gate
if: always()
env:
# Block when a finding at or above this severity is present.
# One of: critical | high | medium | low | none. 'none' = advisory only.
BLOCK_ON_SEVERITY: high
SCAN_OK: ${{ steps.validate.outputs.scan_ok }}
SCAN_REASON: ${{ steps.validate.outputs.reason }}
run: |
set -uo pipefail
F=.security-scan/findings.json
if [ "${BLOCK_ON_SEVERITY}" = "none" ]; then
echo "Security gate disabled (BLOCK_ON_SEVERITY=none) — advisory only."
exit 0
fi
# Distinguish "scan could not complete" (transient: retry) from a real security verdict.
# This is NOT a silent pass (closes the empty-findings false negative) and NOT a security
# block — it tells the developer to re-run, and fails closed only after the in-run retry.
if [ "${SCAN_OK}" != "true" ]; then
echo "::error title=Vim AppSec Agent::Scan did not complete (${SCAN_REASON:-unknown}). This is a scanner/infra issue, not a security finding — re-run this job. Failing closed until a clean scan is recorded."
exit 1
fi
case "${BLOCK_ON_SEVERITY}" in
info) threshold=0 ;; low) threshold=1 ;; medium) threshold=2 ;;
high) threshold=3 ;; critical) threshold=4 ;; *) threshold=4 ;;
esac
blocking=$(jq --argjson t "$threshold" '
[ .findings[]? | (.severity|ascii_downcase) as $s
| ({info:0,low:1,medium:2,high:3,critical:4}[$s] // 0)
| select(. >= $t) ] | length' "$F")
depblock=$(jq '[ .dependency_findings[]? | select(.action=="BLOCK") ] | length' "$F")
scan_policy=$(jq -r '.scan_policy // "full"' "$F")
echo "Findings at or above '${BLOCK_ON_SEVERITY}': ${blocking}"
echo "Dependency findings marked BLOCK: ${depblock}"
if [ "${blocking}" -gt 0 ] || [ "${depblock}" -gt 0 ]; then
echo "::error title=Vim AppSec Agent::BLOCKED — ${blocking} finding(s) at/above ${BLOCK_ON_SEVERITY} and ${depblock} dependency BLOCK(s). See the Vim AppSec Agent PR comment for details."
exit 1
fi
if [ "$scan_policy" = "dependency-only" ]; then
echo "Security gate passed — explicit dependency-only policy completed; Bedrock was not invoked."
exit 0
fi
echo "Security gate passed — scan completed cleanly, no findings at or above '${BLOCK_ON_SEVERITY}'."
report:
name: Report to scanner-stats
needs: [deps, ai-scan]
if: >-
always() &&
github.event.pull_request.draft == false &&
(github.event.action != 'labeled' || github.event.label.name == 'appsec-rescan')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
continue-on-error: true
with:
path: artifacts
merge-multiple: true
- name: Emit scan statistics
# advisory reporter — never let it fail the workflow, even on an unexpected error
continue-on-error: true
uses: actions/github-script@v7
env:
SCANNER_STATS_URL: ${{ secrets.SCANNER_STATS_URL }}
SCANNER_STATS_TOKEN: ${{ secrets.SCANNER_STATS_TOKEN }}
WORKFLOW_VARIANT: org
SCANNER_TAG: ${{ env.SCANNER_TAG }}
MODEL_ARN: ${{ env.BEDROCK_MODEL_ARN }}
SCAN_RUNNER: ubuntu-latest
AI_SCAN_RESULT: ${{ needs.ai-scan.result }}
with:
script: |
const url = (process.env.SCANNER_STATS_URL || '').replace(/\/+$/, '');
const token = process.env.SCANNER_STATS_TOKEN || '';
if (!url || !token) { core.warning('SCANNER_STATS_URL / SCANNER_STATS_TOKEN not set; skipping stats report'); return; }
const fs = require('fs');
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
let run = null;
try { run = (await github.rest.actions.getWorkflowRun({ owner, repo, run_id: context.runId })).data; } catch {}
const scanOut = readJson('artifacts/findings.json') || {};
const osv = readJson('artifacts/osv-results.json');
const SEVS = ['critical', 'high', 'medium', 'low', 'info'];
const norm = (s, dflt = 'medium') => {
s = String(s || '').toLowerCase();
if (s === 'moderate') s = 'medium';
return SEVS.includes(s) ? s : dflt;
};
const PHASES = ['pr-review', 'architecture', 'taint'];
const findings = (Array.isArray(scanOut.findings) ? scanOut.findings : [])
.filter((f) => f && f.title)
.map((f) => ({
phase: PHASES.includes(f.phase) ? f.phase : 'pr-review',
category: f.category ?? undefined,
rule: f.rule ?? undefined,
severity: norm(f.severity),
confidence: f.confidence ?? undefined,
title: String(f.title).slice(0, 500),
description: f.description ?? undefined,
recommendation: f.recommendation ?? undefined,
file_path: f.file_path ?? undefined,
line_start: Number.isInteger(f.line_start) ? f.line_start : undefined,
line_end: Number.isInteger(f.line_end) ? f.line_end : undefined,
cwe: f.cwe ?? undefined,
posted_inline: Boolean(f.posted_inline),
}));
let deps = (Array.isArray(scanOut.dependency_findings) ? scanOut.dependency_findings : [])
.filter((d) => d && d.package)
.map((d) => ({
package: String(d.package),
version: d.version ?? undefined,
ecosystem: d.ecosystem ?? 'npm',
osv_ids: Array.isArray(d.osv_ids) ? d.osv_ids.map(String) : [],
advisory_severity: d.advisory_severity ? norm(d.advisory_severity) : undefined,
socket_depscore: typeof d.socket_depscore === 'number' ? d.socket_depscore : undefined,
reachable: typeof d.reachable === 'boolean' ? d.reachable : undefined,
action: ['WARN', 'BLOCK', 'INFO'].includes(d.action) ? d.action : 'INFO',
}));
// Fallback: if the AI phase produced no dependency findings, map raw OSV results.
if (deps.length === 0 && osv && Array.isArray(osv.results)) {
for (const result of osv.results) {
for (const p of result.packages ?? []) {
const pkg = p.package ?? {};
const vulns = p.vulnerabilities ?? [];
if (!pkg.name || vulns.length === 0) continue;
const sev = norm(vulns.map((v) => v.database_specific?.severity).find(Boolean), 'medium');
deps.push({
package: pkg.name,
version: pkg.version,
ecosystem: String(pkg.ecosystem || 'npm').toLowerCase(),
osv_ids: vulns.map((v) => v.id).filter(Boolean),
advisory_severity: sev,
action: sev === 'critical' || sev === 'high' ? 'WARN' : 'INFO',
});
}
}
}
const resultMap = { success: 'success', failure: 'failure' };
const payload = {
schema_version: 1,
scan: {
repo: `${owner}/${repo}`,
workflow_variant: process.env.WORKFLOW_VARIANT,
workflow_run_id: context.runId,
run_attempt: Number(process.env.GITHUB_RUN_ATTEMPT || 1),
workflow_run_url: run?.html_url ?? `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`,
pr_number: pr?.number,
pr_title: pr?.title,
pr_author: pr?.user?.login,
branch: pr?.head?.ref,
base_sha: pr?.base?.sha,
head_sha: pr?.head?.sha,
trigger_event: context.eventName,
scanner_tag: process.env.SCANNER_TAG,
model: process.env.MODEL_ARN,
runner: process.env.SCAN_RUNNER,
ci_wait_seconds: process.env.CI_WAIT_SECONDS ? Number(process.env.CI_WAIT_SECONDS) : undefined,
started_at: run?.run_started_at ?? undefined,
finished_at: new Date().toISOString(),
status: resultMap[process.env.AI_SCAN_RESULT] ?? 'partial',
phases_completed: Array.isArray(scanOut.phases_completed) ? scanOut.phases_completed : [],
},
findings,
dependency_findings: deps,
};
// Reporting is best-effort: an unreachable/slow/erroring stats server must NEVER
// fail this advisory job. Bounded timeout + a couple of retries, then give up with a warning.
const body = JSON.stringify(payload);
const TIMEOUT_MS = 10000;
const ATTEMPTS = 3;
let reported = false;
for (let attempt = 1; attempt <= ATTEMPTS && !reported; attempt++) {
try {
const res = await fetch(`${url}/api/v1/ingest/scan`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const text = await res.text().catch(() => '');
if (res.ok) {
let id = 'ok';
try { id = JSON.parse(text).scan_run_id ?? 'ok'; } catch {}
core.notice(`scanner-stats: recorded scan ${id}`);
reported = true;
} else if (res.status === 401 || res.status === 422) {
// client errors won't fix themselves on retry — stop early
core.warning(`scanner-stats ingest rejected: HTTP ${res.status} ${text.slice(0, 300)}`);
break;
} else {
core.warning(`scanner-stats ingest attempt ${attempt}/${ATTEMPTS} failed: HTTP ${res.status}`);
}
} catch (err) {
const reason = err && err.name === 'TimeoutError'
? `timed out after ${TIMEOUT_MS}ms`
: (err && err.message) || String(err);
core.warning(`scanner-stats ingest attempt ${attempt}/${ATTEMPTS} could not reach server: ${reason}`);
}
if (!reported && attempt < ATTEMPTS) await new Promise((r) => setTimeout(r, 2000 * attempt));
}
if (!reported) {
core.warning('scanner-stats: giving up after retries; scan results were not recorded (workflow still succeeds — reporting is advisory)');
}