Skip to content

Commit 6d04322

Browse files
Haofeiclaude
andcommitted
Add SARIF output + release/provenance workflow (review #6)
- `reir report-pr --sarif` emits SARIF 2.1.0 (missing capability = error, excess = warning, with source locations from evidence). The action now also writes a SARIF file and exposes it as a `sarif` output, so a workflow can upload it via github/codeql-action/upload-sarif for inline PR / code-scanning annotations. - .github/workflows/release.yml builds pinned, checksummed rss + reir binaries on tag with actions/attest-build-provenance — the trust artifact a gate should pin to instead of @main / ambient PATH tools (verify with gh attestation verify). - test: SARIF reports a missing capability as an error result. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b5b9181 commit 6d04322

4 files changed

Lines changed: 174 additions & 1 deletion

File tree

.github/actions/rsscript-review/action.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ outputs:
4343
comment:
4444
description: 'The review comment text'
4545
value: ${{ steps.review.outputs.comment }}
46+
sarif:
47+
description: 'SARIF 2.1.0 output path (upload via github/codeql-action/upload-sarif for inline PR annotations)'
48+
value: ${{ steps.review.outputs.sarif }}
4649
ci_json:
4750
description: 'CI gate JSON output path'
4851
value: ${{ steps.review.outputs.ci_json }}
@@ -153,6 +156,17 @@ runs:
153156
fi
154157
echo "ci_json=$CI_JSON_FILE" >> "$GITHUB_OUTPUT"
155158
159+
# SARIF for GitHub code scanning / inline PR annotations.
160+
SARIF_FILE="$TEMP_DIR/rsscript-review.sarif"
161+
reir report-pr \
162+
--required "$REQUIRED_REIR_JSON" \
163+
--granted "$INPUT_GRANTS" \
164+
--target "$INPUT_TARGET" \
165+
--sarif > "$SARIF_FILE" 2>/dev/null || true
166+
if [ -s "$SARIF_FILE" ]; then
167+
echo "sarif=$SARIF_FILE" >> "$GITHUB_OUTPUT"
168+
fi
169+
156170
if [ -s "$CI_JSON_FILE" ]; then
157171
REVIEW_STATUS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("status","unknown"))' "$CI_JSON_FILE")
158172
elif [ $EXIT_CODE -ne 0 ]; then

.github/workflows/release.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Release
2+
3+
# Build pinned, checksummed `rss` + `reir` binaries with build provenance on tag.
4+
# This is the trust artifact a supply-chain gate should pin to (instead of @main
5+
# / ambient PATH tools): download the release binary, verify the checksum, and
6+
# verify provenance with `gh attestation verify`.
7+
8+
on:
9+
push:
10+
tags:
11+
- "v*"
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: write
16+
id-token: write
17+
attestations: write
18+
19+
jobs:
20+
release:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Install Rust
27+
uses: dtolnay/rust-toolchain@stable
28+
29+
- name: Build release binaries
30+
run: |
31+
cargo build --release --bin rss -p rsscript
32+
cargo build --release --bin reir -p reir
33+
mkdir -p dist
34+
cp target/release/rss dist/rss-linux-x86_64
35+
cp target/release/reir dist/reir-linux-x86_64
36+
37+
- name: Checksums
38+
working-directory: dist
39+
run: sha256sum rss-linux-x86_64 reir-linux-x86_64 > SHA256SUMS
40+
41+
- name: Attest build provenance
42+
uses: actions/attest-build-provenance@v1
43+
with:
44+
subject-path: |
45+
dist/rss-linux-x86_64
46+
dist/reir-linux-x86_64
47+
48+
- name: Upload release assets
49+
if: startsWith(github.ref, 'refs/tags/')
50+
uses: softprops/action-gh-release@v2
51+
with:
52+
files: |
53+
dist/rss-linux-x86_64
54+
dist/reir-linux-x86_64
55+
dist/SHA256SUMS

reir/src/format.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,95 @@ pub fn format_ci_gate_json(output: &CiGateOutput) -> String {
494494
serde_json::to_string_pretty(output).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
495495
}
496496

497+
/// Render reconciliation results as SARIF 2.1.0 so CI can upload them to GitHub
498+
/// code scanning and get inline PR annotations. Missing capabilities are errors;
499+
/// excess grants are warnings.
500+
pub fn format_sarif(reconciliations: &[Reconciliation]) -> String {
501+
let mut results = Vec::new();
502+
for reconciliation in reconciliations {
503+
let (rule_id, level) = match reconciliation.kind {
504+
ReconciliationKind::MissingCapability => ("missing_capability", "error"),
505+
ReconciliationKind::ExcessCapability => ("excess_capability", "warning"),
506+
_ => continue,
507+
};
508+
let capability = reconciliation
509+
.capability
510+
.as_ref()
511+
.map(|capability| {
512+
let mut parts = vec![format!("{:?}", capability.category)];
513+
for field in [&capability.provider, &capability.service, &capability.action] {
514+
if let Some(value) = field {
515+
parts.push(value.clone());
516+
}
517+
}
518+
parts.join(" / ")
519+
})
520+
.unwrap_or_else(|| "capability".to_string());
521+
let text = match reconciliation.kind {
522+
ReconciliationKind::MissingCapability => {
523+
format!("Required capability not granted by target: {capability}")
524+
}
525+
ReconciliationKind::ExcessCapability => {
526+
format!("Granted capability exceeds requirements (over-privilege): {capability}")
527+
}
528+
_ => unreachable!(),
529+
};
530+
let mut result = serde_json::json!({
531+
"ruleId": rule_id,
532+
"level": level,
533+
"message": { "text": text },
534+
});
535+
// Attach a source location when the evidence carries one.
536+
if let Some(evidence) = reconciliation
537+
.evidence
538+
.iter()
539+
.find(|evidence| evidence.file.is_some())
540+
{
541+
let uri = evidence.file.clone().unwrap_or_default();
542+
let mut region = serde_json::Map::new();
543+
if let Some(line) = evidence.line {
544+
region.insert("startLine".to_string(), serde_json::json!(line.max(1)));
545+
}
546+
if let Some(column) = evidence.column {
547+
region.insert("startColumn".to_string(), serde_json::json!(column.max(1)));
548+
}
549+
result["locations"] = serde_json::json!([{
550+
"physicalLocation": {
551+
"artifactLocation": { "uri": uri },
552+
"region": serde_json::Value::Object(region),
553+
}
554+
}]);
555+
}
556+
results.push(result);
557+
}
558+
559+
let bundle = serde_json::json!({
560+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
561+
"version": "2.1.0",
562+
"runs": [{
563+
"tool": {
564+
"driver": {
565+
"name": "rsscript-reir",
566+
"informationUri": "https://github.com/Haofei/rsscript",
567+
"version": env!("CARGO_PKG_VERSION"),
568+
"rules": [
569+
{
570+
"id": "missing_capability",
571+
"shortDescription": { "text": "Required capability is not granted by the deployment target." }
572+
},
573+
{
574+
"id": "excess_capability",
575+
"shortDescription": { "text": "Granted capability exceeds what the code requires (over-privilege)." }
576+
}
577+
]
578+
}
579+
},
580+
"results": results,
581+
}]
582+
});
583+
serde_json::to_string_pretty(&bundle).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
584+
}
585+
497586
fn is_capability_fact(fact: &Fact, role: FactRole) -> bool {
498587
fact.kind == FactKind::Capability && fact.role == Some(role) && fact.capability.is_some()
499588
}
@@ -1534,6 +1623,17 @@ mod tests {
15341623
assert_eq!(strict.status, CiGateStatus::Fail);
15351624
}
15361625

1626+
#[test]
1627+
fn sarif_reports_missing_capability_as_error() {
1628+
let sarif = format_sarif(&[missing_reconciliation()]);
1629+
let value: serde_json::Value = serde_json::from_str(&sarif).expect("valid SARIF JSON");
1630+
assert_eq!(value["version"], "2.1.0");
1631+
let results = value["runs"][0]["results"].as_array().expect("results array");
1632+
assert_eq!(results.len(), 1);
1633+
assert_eq!(results[0]["ruleId"], "missing_capability");
1634+
assert_eq!(results[0]["level"], "error");
1635+
}
1636+
15371637
#[test]
15381638
fn error_diagnostic_makes_bundle_invalid_for_gating() {
15391639
let required = capability_fact("fact.req", FactRole::Required, FactValue::True);

reir/src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ fn try_run_report_pr(args: &[String]) -> Result<(ExitCode, String), CliError> {
305305
let mut granted = None;
306306
let mut target = None;
307307
let mut ci_json = false;
308+
let mut sarif = false;
308309
let mut policy_file = None;
309310
// CLI flag overrides, layered on top of any --policy file.
310311
let mut cli = reir::TargetGatePolicy::default();
@@ -317,6 +318,7 @@ fn try_run_report_pr(args: &[String]) -> Result<(ExitCode, String), CliError> {
317318
"--target" => target = Some(take_value(args, &mut index, "--target")?),
318319
"--policy" => policy_file = Some(take_value(args, &mut index, "--policy")?),
319320
"--ci-json" => ci_json = true,
321+
"--sarif" => sarif = true,
320322
"--fail-on-unknown" => cli.fail_on_unknown = Some(true),
321323
"--fail-on-excess" => cli.fail_on_excess = Some(true),
322324
"--require-verified-capabilities" => {
@@ -361,7 +363,9 @@ fn try_run_report_pr(args: &[String]) -> Result<(ExitCode, String), CliError> {
361363
&reconciliations,
362364
policy,
363365
);
364-
let output = if ci_json {
366+
let output = if sarif {
367+
reir::format_sarif(&reconciliations)
368+
} else if ci_json {
365369
reir::format_ci_gate_json(&ci_output)
366370
} else {
367371
format_pr_review_comment(

0 commit comments

Comments
 (0)