Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions .claude/commands/audit-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,51 @@ diff "$SECURITY_AUDIT_ROOT"/core-a/openiap-google-*.cdx.json \
"$SECURITY_AUDIT_ROOT"/core-b/openiap-google-*.cdx.json
```

## 6. Workflow permissions and injection
## 6. Published current-release assets

Fresh local output does not prove that the public release asset is complete.
Download every newest component SBOM, bind it to the release tag and API digest,
validate its schema, and verify its exact signing workflow:

```bash
set -euo pipefail
git fetch origin main --tags
gh api --paginate --slurp \
"repos/hyodotdev/openiap/releases?per_page=100" \
> "$SECURITY_AUDIT_ROOT/releases.json"

node scripts/generate-sbom.mjs missing-release-tags \
"$SECURITY_AUDIT_ROOT/releases.json" \
> "$SECURITY_AUDIT_ROOT/missing-tags.txt"
if [ -s "$SECURITY_AUDIT_ROOT/missing-tags.txt" ]; then
cat "$SECURITY_AUDIT_ROOT/missing-tags.txt"
exit 1
fi

node scripts/generate-sbom.mjs latest-release-assets \
"$SECURITY_AUDIT_ROOT/releases.json" \
> "$SECURITY_AUDIT_ROOT/latest-assets.tsv"
mkdir -p "$SECURITY_AUDIT_ROOT/published"
while IFS=$'\t' read -r tag name digest; do
gh release download "$tag" --repo hyodotdev/openiap \
-p "$name" -D "$SECURITY_AUDIT_ROOT/published" || exit 1
file="$SECURITY_AUDIT_ROOT/published/$name"
node scripts/generate-sbom.mjs verify-file "$file" \
--tag "$tag" --digest "$digest" || exit 1
cyclonedx validate --input-file "$file" --input-format json \
--input-version v1_6 --fail-on-errors || exit 1
cert_identity=https://github.com/hyodotdev/openiap
cert_identity="$cert_identity/.github/workflows/sbom.yml@refs/heads/main"
gh attestation verify "$file" --repo hyodotdev/openiap \
--cert-identity "$cert_identity" \
--deny-self-hosted-runners || exit 1
done < "$SECURITY_AUDIT_ROOT/latest-assets.tsv"
```

An existing asset without the required generator commit is a failure even when
its dependency list, schema, and attestation are otherwise valid.

## 7. Workflow permissions and injection

Least privilege, and no untrusted value interpolated into a shell command:

Expand All @@ -142,14 +186,14 @@ gh api repos/hyodotdev/openiap/dependency-graph/sbom || \
Pass values through `env:` instead of interpolating them. OpenSSF Scorecard's
Dangerous-Workflow check flags the same pattern.

## 7. Generated SBOMs stay out of git
## 8. Generated SBOMs stay out of git

```bash
git check-ignore -v sbom/ && echo "ignored" || echo "GAP: sbom/ is committable"
git ls-files '*.cdx.json' | head # must be empty
```

## 8. Documentation matches the code
## 9. Documentation matches the code

Documentation drift is the most common finding, because prose has no compiler.

Expand All @@ -174,7 +218,7 @@ Also check for **hardcoded counts** — "nine workflows", "43 of 47
dependencies". They are true on the day they are written and wrong later.
Prefer a described property or a command that prints the live number.

## 9. Release integrity still holds
## 10. Release integrity still holds

```bash
node --test scripts/release-branch-policy.test.mjs \
Expand All @@ -183,7 +227,7 @@ node --test scripts/release-branch-policy.test.mjs \
node scripts/release-branch-policy.mjs audit
```

## 10. Report
## 11. Report

State each check as pass, gap, or not-applicable with the command output that
justifies it. For every gap, either fix it in the same pass or record why it is
Expand Down
107 changes: 40 additions & 67 deletions .github/workflows/sbom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ name: "Security: SBOM"
# any missed dispatch for the newest release of each component.

on:
release:
types: [published]
push:
branches: [main]
paths:
Expand All @@ -27,7 +25,7 @@ on:
type: string

concurrency:
group: sbom-${{ github.event.release.tag_name || inputs.tag }}
group: sbom-${{ inputs.tag || github.ref }}
cancel-in-progress: false

permissions:
Expand Down Expand Up @@ -67,7 +65,9 @@ jobs:

sbom:
name: Generate and publish SBOM
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
if: >-
github.event_name == 'workflow_dispatch' &&
github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
permissions:
contents: write # upload the SBOM as a release asset
Expand All @@ -77,7 +77,7 @@ jobs:
- name: Resolve release tag
id: tag
env:
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
if [ -z "$RELEASE_TAG" ]; then
echo "::error::No release tag available"
Expand Down Expand Up @@ -126,6 +126,7 @@ jobs:
id: existing
if: ${{ steps.component.outputs.matched == 'true' }}
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPAIR_DIGEST: ${{ steps.component.outputs.repair-digest }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
Expand All @@ -142,18 +143,36 @@ jobs:
STAGED_ASSET=$(jq -c --arg name "$SBOM_NAME.replacement" \
'[.assets[] | select(.name == $name)][0] // empty' \
<<< "$RELEASE_JSON")
if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then
ASSET_DIGEST=$(jq -r '.digest // ""' <<< "$ASSET")
if [ -n "$ASSET" ] && \
{ [ -z "$REPAIR_DIGEST" ] || [ "$ASSET_DIGEST" != "$REPAIR_DIGEST" ]; }; then
ASSET_ID=$(jq -r '.id' <<< "$ASSET")
EXISTING_FILE="$RUNNER_TEMP/$SBOM_NAME"
gh api "repos/$GITHUB_REPOSITORY/releases/assets/$ASSET_ID" \
-H "Accept: application/octet-stream" > "$EXISTING_FILE"
node scripts/generate-sbom.mjs verify-file "$EXISTING_FILE" \
--tag "$RELEASE_TAG" --digest "$ASSET_DIGEST"
CERT_IDENTITY="https://github.com/$GITHUB_REPOSITORY/.github/workflows/sbom.yml@refs/heads/$DEFAULT_BRANCH"
gh attestation verify "$EXISTING_FILE" \
--repo "$GITHUB_REPOSITORY" \
--cert-identity "$CERT_IDENTITY" \
--deny-self-hosted-runners
if [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then
STAGED_ASSET_ID=$(jq -r '.id' <<< "$STAGED_ASSET")
gh api --method DELETE \
"repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
echo "::notice::Removed a stale staged repair after verifying $SBOM_NAME."
fi
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::$SBOM_NAME is verified and will be preserved."
elif [ -n "$REPAIR_DIGEST" ] && [ -n "$STAGED_ASSET" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::A staged legacy repair will be reconciled."
elif [ -z "$ASSET" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
elif [ -n "$REPAIR_DIGEST" ] && \
[ "$(jq -r '.digest // ""' <<< "$ASSET")" = "$REPAIR_DIGEST" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::$SBOM_NAME matches a known inaccurate legacy digest and will be replaced once."
else
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::$SBOM_NAME is already attached; preserving it."
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::$SBOM_NAME matches a guarded legacy digest and will be replaced once."
fi

# CI tests the generator and its historical fixtures in their owning tree.
Expand Down Expand Up @@ -196,52 +215,12 @@ jobs:
if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
env:
SBOM_FILE: ${{ steps.generate.outputs.sbom-file }}
EXPECTED_VERSION: ${{ steps.component.outputs.version }}
EXPECTED_GENERATOR_COMMIT: ${{ steps.generator.outputs.commit }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
# A version mismatch means the tag and the manifest disagree, which
# would attach a misleading inventory to a real release.
ACTUAL_VERSION=$(jq -r '.metadata.component.version' "$SBOM_FILE")
ACTUAL_TAG=$(jq -r '
.metadata.component.properties[]
| select(.name == "openiap:release:tag") | .value
' "$SBOM_FILE")
ACTUAL_COMMIT=$(jq -r '
.metadata.component.properties[]
| select(.name == "openiap:release:commit") | .value
' "$SBOM_FILE")
ACTUAL_GENERATOR_COMMIT=$(jq -r '
.metadata.tools.components[]
| select(.name == "openiap-sbom-generator")
| .properties[]
| select(.name == "openiap:generator:commit") | .value
' "$SBOM_FILE")

if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then
echo "::error::SBOM version $ACTUAL_VERSION does not match tag version $EXPECTED_VERSION"
exit 1
fi
if [ "$ACTUAL_TAG" != "$RELEASE_TAG" ]; then
echo "::error::SBOM tag $ACTUAL_TAG does not match release tag $RELEASE_TAG"
exit 1
fi
if [ "$ACTUAL_COMMIT" != "$(git rev-parse HEAD)" ]; then
echo "::error::SBOM commit $ACTUAL_COMMIT does not match the released commit"
exit 1
fi
if [ "$ACTUAL_GENERATOR_COMMIT" != "$EXPECTED_GENERATOR_COMMIT" ]; then
echo "::error::SBOM generator $ACTUAL_GENERATOR_COMMIT does not match $EXPECTED_GENERATOR_COMMIT"
exit 1
fi

# Fail closed if a local path ever reaches a published document.
if grep -qE '/Users/|/home/[a-z]|/tmp/' "$SBOM_FILE"; then
echo "::error::SBOM contains a local filesystem path"
exit 1
fi

echo "SBOM verified for $RELEASE_TAG at $ACTUAL_COMMIT"
node scripts/generate-sbom.mjs verify-file "$SBOM_FILE" \
--tag "$RELEASE_TAG" \
--generator-commit "$EXPECTED_GENERATOR_COMMIT"

- name: Attest SBOM provenance
if: ${{ steps.component.outputs.matched == 'true' && steps.existing.outputs.exists == 'false' }}
Expand Down Expand Up @@ -293,15 +272,6 @@ jobs:
return 1
}

if [ -n "$CANONICAL_ASSET_ID" ] && [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then
if [ -n "$STAGED_ASSET_ID" ]; then
gh api --method DELETE \
"repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
fi
echo "::notice::$SBOM_NAME is already corrected; any staged repair was removed."
exit 0
fi

if [ -z "$CANONICAL_ASSET_ID" ]; then
if [ -n "$STAGED_ASSET_ID" ] && [ "$STAGED_DIGEST" = "$LOCAL_DIGEST" ]; then
finalize_staged_asset
Expand Down Expand Up @@ -335,13 +305,16 @@ jobs:
exit 1
fi

if [ "$CANONICAL_DIGEST" != "$REPAIR_DIGEST" ]; then
echo "::error::$SBOM_NAME changed during repair; leaving the staged asset for verified reconciliation."
exit 1
fi

CURRENT_DIGEST=$(gh api \
"repos/$GITHUB_REPOSITORY/releases/assets/$CANONICAL_ASSET_ID" \
--jq '.digest // ""')
if [ "$CURRENT_DIGEST" != "$REPAIR_DIGEST" ]; then
gh api --method DELETE \
"repos/$GITHUB_REPOSITORY/releases/assets/$STAGED_ASSET_ID"
echo "::error::The legacy SBOM asset changed during repair; refusing to replace it."
echo "::error::The legacy SBOM asset changed during repair; leaving the staged asset for verified reconciliation."
exit 1
fi
gh api --method DELETE \
Expand Down
6 changes: 5 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ check whether a specific version declares a given dependency:
```bash
gh release download react-native-iap-16.3.0 \
--repo hyodotdev/openiap -p '*.cdx.json'
gh attestation verify react-native-iap-16.3.0.cdx.json --repo hyodotdev/openiap
CERT_IDENTITY=https://github.com/hyodotdev/openiap
CERT_IDENTITY="$CERT_IDENTITY/.github/workflows/sbom.yml@refs/heads/main"
gh attestation verify react-native-iap-16.3.0.cdx.json \
--repo hyodotdev/openiap --cert-identity "$CERT_IDENTITY" \
--deny-self-hosted-runners
```

- [`security/SBOM.md`](security/SBOM.md) — what the SBOMs cover, how they are
Expand Down
7 changes: 5 additions & 2 deletions packages/docs/src/pages/docs/security/sbom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,11 @@ flutter_inapp_purchase-10.3.0.cdx.json`}</code>
OpenIAP&apos;s CI produced it rather than trusting the file on sight:
</p>
<pre>
<code>{`gh attestation verify react-native-iap-16.3.0.cdx.json \\
--repo hyodotdev/openiap`}</code>
<code>{`CERT_IDENTITY=https://github.com/hyodotdev/openiap
CERT_IDENTITY="$CERT_IDENTITY/.github/workflows/sbom.yml@refs/heads/main"
gh attestation verify react-native-iap-16.3.0.cdx.json \\
--repo hyodotdev/openiap --cert-identity "$CERT_IDENTITY" \\
--deny-self-hosted-runners`}</code>
</pre>
<p>And validate it against the CycloneDX schema:</p>
<pre>
Expand Down
19 changes: 18 additions & 1 deletion scripts/audit-security.test.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import test from "node:test";
Expand Down Expand Up @@ -63,3 +63,20 @@ test("empty workflow scans fail instead of reporting a vacuous pass", async (t)
test("empty URL extraction is explicit", () => {
assert.deepEqual(extractExternalUrls("no links"), []);
});

test("published SBOM audit fails fast and trusts only main", () => {
const source = readFileSync(
new URL("../.claude/commands/audit-security.md", import.meta.url),
"utf8",
);
const block = source.match(
/## 6\. Published current-release assets[\s\S]*?```bash\n([\s\S]*?)```/u,
)?.[1];

assert.ok(block, "published asset audit command is missing");
assert.match(block, /^set -euo pipefail$/mu);
assert.match(block, /@refs\/heads\/main/u);
assert.match(block, /--cert-identity "\$cert_identity"/u);
assert.match(block, /--deny-self-hosted-runners \|\| exit 1/u);
assert.doesNotMatch(block, /--signer-workflow/u);
});
Loading
Loading