diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 071bf40..9202e0c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,9 +6,7 @@ # is advisory and only nudges GitHub's PR review-request UI. # # Owner is an individual until a security team exists; switch to a team -# handle (e.g., @valory-xyz/) once one is created. Path scope below -# covers only files that exist today; the next supply-chain PR will add -# lines for `.supply-chain/`, scripts/, and governance docs as they land. +# handle (e.g., @valory-xyz/) once one is created. # Default owner: any unowned path falls back here. * @Tanya-atatakai @@ -20,8 +18,16 @@ /package.json @Tanya-atatakai /yarn.lock @Tanya-atatakai -# Disclosure policy. +# Node-version pin enforced by Corepack in CI. +/.nvmrc @Tanya-atatakai + +# Supply-chain controls: scripts that gate CI + allowlists they consult. +/scripts/ @Tanya-atatakai +/.supply-chain/ @Tanya-atatakai + +# Disclosure + threat-model docs. /SECURITY.md @Tanya-atatakai +/SUPPLY-CHAIN-SECURITY.md @Tanya-atatakai # AI / contributor onboarding context — changes here shape future agent behavior. /CLAUDE.md @Tanya-atatakai diff --git a/.github/workflows/deploy-subgraph.yaml b/.github/workflows/deploy-subgraph.yaml index 39209e1..135c8ca 100644 --- a/.github/workflows/deploy-subgraph.yaml +++ b/.github/workflows/deploy-subgraph.yaml @@ -143,12 +143,19 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24" + node-version-file: .nvmrc cache: "yarn" + - name: Activate pinned Yarn via Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + actual="$(yarn --version)" + [ "$actual" = "1.22.22" ] || { echo "::error::corepack activation failed (got $actual)"; exit 1; } + - name: Install subgraph packages working-directory: subgraphs/${{ inputs.folder }} - run: yarn install + run: yarn install --frozen-lockfile - name: Authenticate working-directory: subgraphs/${{ inputs.folder }} diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..00068ed --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,68 @@ +name: Gitleaks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: gitleaks-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + # Full history for `gitleaks detect --log-opts="--all"` on push to main. + # PR runs only need the diff range (resolved below). + fetch-depth: 0 + + - name: Install gitleaks (pinned + checksum-verified) + # GITLEAKS_SHA256 is the SHA-256 of gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz + # from the upstream gitleaks_${GITLEAKS_VERSION}_checksums.txt file. Without + # this, a poisoned GitHub release would silently install a backdoored gitleaks + # on every CI run. When bumping GITLEAKS_VERSION, fetch the upstream checksum + # in the same commit; reviewers should re-fetch and confirm. + env: + GITLEAKS_VERSION: '8.30.1' + GITLEAKS_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb' + run: | + set -euo pipefail + TARBALL=/tmp/gitleaks.tar.gz + curl -sSL -o "$TARBALL" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c - + tar -xz -C /tmp -f "$TARBALL" gitleaks + sudo install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks + gitleaks version + + - name: Detect secrets + # --redact masks any matched secret in CI logs. + # PR: scan only the diff range to keep PR cycles fast. + # push to main: scan full history to catch any rewrites. + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + gitleaks detect \ + --source=. \ + --no-banner \ + --redact \ + --log-opts="origin/${{ github.base_ref }}..HEAD" \ + --report-format json \ + --report-path /tmp/leaks.json \ + -v + else + gitleaks detect \ + --source=. \ + --no-banner \ + --redact \ + --log-opts="--all" \ + --report-format json \ + --report-path /tmp/leaks.json \ + -v + fi diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..fc110d1 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,116 @@ +name: Supply Chain + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: supply-chain-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: Dependency audit (root tree) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version-file: .nvmrc + + - name: Activate pinned Yarn via Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + [ "$(yarn --version)" = "1.22.22" ] || { echo "::error::corepack activation failed"; exit 1; } + + # `yarn audit` reads yarn.lock directly; no install needed. + - name: Run audit + run: yarn audit:prod + + install-hooks: + name: Install-hook audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version-file: .nvmrc + + - name: Activate pinned Yarn via Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + [ "$(yarn --version)" = "1.22.22" ] || { echo "::error::corepack activation failed"; exit 1; } + + # `--ignore-scripts` so the hooks don't run while we're enumerating them. + - name: Install dependencies (no hooks) + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Run install-hook audit + run: yarn audit:install-hooks + + lockfile-lint: + name: Lockfile validation + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 13 paths: root + 12 subgraphs. Mirrors test.yaml's matrix shape; + # every yarn.lock gets linted. + path: + - "." + - "subgraphs/liquidity" + - "subgraphs/liquidity-l2" + - "subgraphs/tokenomics-eth" + - "subgraphs/tokenomics-l2" + - "subgraphs/governance" + - "subgraphs/staking" + - "subgraphs/service-registry" + - "subgraphs/legacy-mech-fees" + - "subgraphs/new-mech-fees" + - "subgraphs/predict/predict-omen" + - "subgraphs/predict/predict-polymarket" + - "subgraphs/babydegen/babydegen-optimism" + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version-file: .nvmrc + + # Catches non-registry deps (e.g. codeload.github.com), HTTP-only sources, + # and missing integrity hashes. See SUPPLY-CHAIN-SECURITY.md §6. + - name: Lint ${{ matrix.path }}/yarn.lock + working-directory: ${{ matrix.path }} + run: | + npx --yes lockfile-lint \ + --path yarn.lock \ + --type yarn \ + --validate-https \ + --allowed-hosts yarn npm \ + --empty-hostname false + + # Aggregator that fails iff any of the above fails. Promote this single + # check to required-status in branch protection (rather than each job + # individually) for a stable interface that survives matrix changes. + all-checks-passed: + name: All checks passed + needs: [audit, install-hooks, lockfile-lint] + if: always() + runs-on: ubuntu-latest + steps: + - run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]] || \ + [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]] || \ + [[ "${{ contains(needs.*.result, 'skipped') }}" == "true" ]]; then + echo "::error::One or more required jobs did not succeed" + exit 1 + fi + echo "All supply-chain checks passed." diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c8053f6..902558b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -62,7 +62,14 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24" + node-version-file: .nvmrc + + - name: Activate pinned Yarn via Corepack + run: | + corepack enable + corepack prepare yarn@1.22.22 --activate + actual="$(yarn --version)" + [ "$actual" = "1.22.22" ] || { echo "::error::corepack activation failed (got $actual)"; exit 1; } - name: Install dependencies working-directory: ${{ matrix.path }} diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..ae5b6ba --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,14 @@ +title = "autonolas-subgraph-studio gitleaks config" + +[extend] +useDefault = true + +[allowlist] +description = "Public on-chain EVM token contract addresses (40-hex) in babydegen mappers and example docs are not secrets" +paths = [ + '''shared/babydegen/mappers/.+\.ts''', + '''subgraphs/babydegen/.+\.ts''', + '''subgraphs/liquidity/README\.md''', +] +regexes = ['''0x[a-fA-F0-9]{40}'''] +regexTarget = "match" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.supply-chain/audit-allowlist.json b/.supply-chain/audit-allowlist.json new file mode 100644 index 0000000..b8a7b15 --- /dev/null +++ b/.supply-chain/audit-allowlist.json @@ -0,0 +1,221 @@ +{ + "_doc": "Allowlist for yarn audit high/critical advisories. Every entry needs a reason and a review date. See SUPPLY-CHAIN-SECURITY.md §5.", + "_fields": { + "id": "npm advisory numeric ID — REQUIRED", + "ghsa": "GitHub Security Advisory ID — for humans", + "package": "package name — for humans", + "severity": "advisory severity — for humans", + "reason": "why the advisory is allowlisted — REQUIRED", + "added": "YYYY-MM-DD — REQUIRED", + "review": "YYYY-MM-DD — REQUIRED, expired prints warning but does not fail" + }, + "entries": [ + { + "id": 1117592, + "ghsa": "GHSA-6chq-wfr3-2hj9", + "package": "axios", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1117590, + "ghsa": "GHSA-pf86-5x62-jrwf", + "package": "axios", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1117575, + "ghsa": "GHSA-pmwg-cvhr-8vh7", + "package": "axios", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1117068, + "ghsa": "GHSA-wf6x-7x77-mvgw", + "package": "immutable", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1115806, + "ghsa": "GHSA-r5fr-rjxr-66jc", + "package": "lodash", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1115552, + "ghsa": "GHSA-c2c7-rcm5-vvqj", + "package": "picomatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1114639, + "ghsa": "GHSA-v9p9-hfj2-hcw8", + "package": "undici", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1114637, + "ghsa": "GHSA-vrm6-8vpv-qv8q", + "package": "undici", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1114591, + "ghsa": "GHSA-f269-vfmq-vjvj", + "package": "undici", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113552, + "ghsa": "GHSA-23c5-xmqv-rm74", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113548, + "ghsa": "GHSA-23c5-xmqv-rm74", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113546, + "ghsa": "GHSA-23c5-xmqv-rm74", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113544, + "ghsa": "GHSA-7r86-cg39-jmmj", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113540, + "ghsa": "GHSA-7r86-cg39-jmmj", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113538, + "ghsa": "GHSA-7r86-cg39-jmmj", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113465, + "ghsa": "GHSA-3ppc-4f35-3m26", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113461, + "ghsa": "GHSA-3ppc-4f35-3m26", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1113459, + "ghsa": "GHSA-3ppc-4f35-3m26", + "package": "minimatch", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1117857, + "ghsa": "GHSA-43fc-jf86-j433", + "package": "axios", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1112921, + "ghsa": "GHSA-c2qf-rxjj-qqgw", + "package": "semver", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1111034, + "ghsa": "GHSA-jr5f-v2jv-69x6", + "package": "axios", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1109843, + "ghsa": "GHSA-5j98-mcp5-4vw2", + "package": "glob", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + }, + { + "id": 1104664, + "ghsa": "GHSA-3xgq-45jj-v275", + "package": "cross-spawn", + "severity": "high", + "reason": "Baselined at PR3 — transitive of @graphprotocol/graph-cli; latest stable graph-cli (0.98.1) does not refresh this advisory. Re-evaluate when graph-cli is bumped or a yarn resolution becomes feasible.", + "added": "2026-05-07", + "review": "2026-08-05" + } + ] +} diff --git a/.supply-chain/install-hooks.allowlist b/.supply-chain/install-hooks.allowlist new file mode 100644 index 0000000..b5f9200 --- /dev/null +++ b/.supply-chain/install-hooks.allowlist @@ -0,0 +1,11 @@ +# .supply-chain/install-hooks.allowlist +# +# Every package in node_modules that declares a non-trivial +# preinstall / install / postinstall script. Regenerate with +# `node scripts/audit-install-hooks.mjs --update` after any +# dependency change. CI runs the same script without --update +# and fails if this file drifts from the tree. +# +# See SUPPLY-CHAIN-SECURITY.md §7 for the per-package rationale +# (node-gyp-build native bindings, benign shim resolvers, etc.). + diff --git a/CLAUDE.md b/CLAUDE.md index eeb3c8c..3098c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,11 @@ abis/ # Shared ABI files (referenced by all subgraphs) scripts/ generate-manifests.js # Generates network manifests from templates pol-aggregation.js # Cross-chain POL + protocol fees report (queries all liquidity subgraphs + Solana RPC) + audit.mjs # Wraps `yarn audit --json` with allowlist (.supply-chain/audit-allowlist.json). Run as `yarn audit:prod`. + audit-install-hooks.mjs # Diffs node_modules install-hooks against .supply-chain/install-hooks.allowlist +.supply-chain/ + audit-allowlist.json # Time-bounded suppressions for high/critical advisories + install-hooks.allowlist # Approved packages with non-trivial install hooks shared/ constants.ts # Shared constants across subgraphs subgraphs/ @@ -28,8 +33,9 @@ subgraphs/ ## Tech Stack - **Language**: AssemblyScript (compiled to WASM by Graph CLI) -- **Framework**: The Graph (graph-cli ^0.97.0, graph-ts ^0.38.0) -- **Testing**: Matchstick (matchstick-as 0.5.0) +- **Framework**: The Graph (graph-cli 0.98.1, graph-ts 0.38.2 — exact pins, no carets, all 13 package.json files converged) +- **Testing**: Matchstick (matchstick-as 0.6.0 — exact pin) +- **Node**: 22.x via `.nvmrc`; `packageManager: "yarn@1.22.22"` enforced via Corepack in CI. - **Deployment**: CI/CD → The Graph Studio / Alchemy ## Multi-Network Patterns @@ -53,6 +59,10 @@ yarn test # Run Matchstick tests CI runs on every PR via `.github/workflows/test.yaml` — a matrix over all 12 subgraph targets runs `yarn graph codegen` followed by `yarn graph test` (Matchstick) for each. Template subgraphs run `yarn generate-manifests` first; per-network subgraphs symlink a representative manifest (`subgraph.gnosis.yaml`) before testing. Deployment is handled via `.github/workflows/deploy-subgraph.yaml` (manual dispatch from main). +Two additional CI workflows enforce supply-chain hygiene (advisory-only at first; promote to required-status when the team is ready): +- `.github/workflows/supply-chain.yml` — matrix audit + install-hook + lockfile-lint over all 13 paths. +- `.github/workflows/gitleaks.yml` — secret scanning with SHA-256 verified gitleaks binary. + ## Conventions - Entity IDs: typically address-based (e.g., safe address, `
-`, `
-`) @@ -61,6 +71,25 @@ CI runs on every PR via `.github/workflows/test.yaml` — a matrix over all 12 s - Shared ABIs live in root `abis/` directory - Each subgraph has its own `schema.graphql`, `subgraph.yaml`, `src/`, and optional `tests/` +## Supply chain & security + +This repo's deployments serve indexed on-chain data to **every Olas dashboard, frontend, and analytics consumer**. A compromised subgraph deploy has org-wide blast radius — far beyond what the small dep tree might suggest. Treat the deploy auth secret (`SUBGRAPH_STUDIO_KEY`) and the `@graphprotocol/graph-cli` toolchain as crown-jewel surfaces. + +- **Threat model + controls + response playbook**: [`SUPPLY-CHAIN-SECURITY.md`](SUPPLY-CHAIN-SECURITY.md). +- **Disclosure policy**: [`SECURITY.md`](SECURITY.md). +- **Audit allowlist**: [`.supply-chain/audit-allowlist.json`](.supply-chain/audit-allowlist.json) — every entry needs `id`, `reason`, `added`, `review` (all required). +- **Install-hook allowlist**: [`.supply-chain/install-hooks.allowlist`](.supply-chain/install-hooks.allowlist) — drift in either direction (new hook OR removed hook) fails CI. + +**Yarn 1 gotcha**: the audit gate is invoked as `yarn audit:prod`, NOT `yarn audit`. Yarn 1.x's built-in `yarn audit` shadows same-named scripts in `package.json`, so naming the script `audit` would silently invoke the built-in instead. Same care with `audit:install-hooks` (the install-hook gate). + +After any dep change, refresh the install-hooks allowlist: + +```bash +yarn install +yarn audit:install-hooks:update +git add .supply-chain/install-hooks.allowlist +``` + ## CLAUDE.md Maintenance Each subgraph should have its own `CLAUDE.md` with subgraph-specific context (entities, handlers, business logic, contracts). When making feature changes to a subgraph: diff --git a/SUPPLY-CHAIN-SECURITY.md b/SUPPLY-CHAIN-SECURITY.md new file mode 100644 index 0000000..8f9eb28 --- /dev/null +++ b/SUPPLY-CHAIN-SECURITY.md @@ -0,0 +1,128 @@ +# Supply chain security + +This document describes the supply-chain threat model for `autonolas-subgraph-studio`, the controls in place, and how to respond when something breaks. + +## 1. Why this repo's supply chain matters + +The deliverables here are not user-facing apps — they are **The Graph** subgraphs deployed to Subgraph Studio that index on-chain data for the Olas ecosystem. **Every Olas dashboard, frontend app, and analytics view that queries an `OPERATE_SUBGRAPH_URL`-style endpoint depends on the data shipped from this repo.** A compromised deployment can serve manipulated on-chain data to every downstream consumer; the blast radius is org-wide, not repo-local. + +The supply-chain surface is: +- The CI runner that builds (`graph codegen` + `graph build`) and pushes (`graph deploy`). +- The dependency tree that runs at install + build time on dev machines and CI (where install scripts execute). +- The deploy auth secret (`SUBGRAPH_STUDIO_KEY`). +- The `@graphprotocol/graph-cli` toolchain that compiles AssemblyScript mappings to WASM. + +Because the deliverable is data, not a downloadable artifact, there is no Docker image to scan, no CSP to write, no end-user bundle to pin. Hardening is concentrated in CI/secrets/dep-pinning rather than container or runtime defenses. + +## 2. Threat model + +| # | Threat | Concrete example | Control | +|---|---|---|---| +| T1 | Supply-chain compromise via a transitive dep of `@graphprotocol/graph-cli` | `event-stream` (2018), `xz-utils` (2024), repeated nx/sourcemap CDN incidents (2024-2025) | Audit gate (§5), install-hook gate (§7), lockfile-lint (§6), SHA-pinned actions, **Dependabot alerts in repo Settings (§4)** | +| T2 | Stolen `SUBGRAPH_STUDIO_KEY` | Phished maintainer, leaked CI log, exfiltration via compromised dep with install hook | Quarterly key rotation (§3), gitleaks scan over full history (§8), least-privilege workflow `permissions: contents: read` | +| T3 | GitHub Action tag-mutation | `tj-actions/changed-files` (March 2025) — maintainer's PAT compromised, every action tag rewrites to dump CI secrets | All actions SHA-pinned to commit hash, not tag | +| T4 | Compromised maintainer account → `workflow_dispatch` shell injection | `folder` / `name` / `manifest` interpolated into shell commands | Regex validation on every dispatch input + `permissions: contents: read` | +| T5 | Historical secret leak in git or CI logs | `SUBGRAPH_STUDIO_KEY` was historically passed as a positional cmdline arg to `graph auth`; redaction is best-effort | Gitleaks scans every PR + full history; rotate key on incident | + +## 3. Secrets inventory + +| Name | Used by | Location | Rotation cadence | How to rotate | +|---|---|---|---|---| +| `SUBGRAPH_STUDIO_KEY` | `.github/workflows/deploy-subgraph.yaml` | GitHub repo secrets | **Quarterly** | Subgraph Studio → Account → Deploy Key → Regenerate. Update the GitHub repo secret. Re-trigger deployments. | + +There are no other secrets currently in use. If the repo gains additional secrets (oracle keys, RPC endpoints, monitoring tokens), update this table and add a control for each. + +### `SUBGRAPH_STUDIO_KEY` cmdline residual exposure (current state) + +`graph-cli` 0.97 / 0.98 accepts the deploy key only as a positional CLI argument — there is no `--access-token` flag, no env-var support, no stdin reading. The key is therefore passed as `yarn graph auth ${{ secrets.SUBGRAPH_STUDIO_KEY }}`. GitHub Actions auto-redacts secret literals in logs (`***`), so the practical exposure is limited to `/proc//cmdline` on the runner during the brief auth step. Hosted runners isolate that surface from other tenants. + +When `graph-cli` adds env-var support upstream, switch the deploy workflow to that and remove the cmdline path. Until then, the quarterly rotation cadence is the mitigation. + +## 4. Dependabot alerts (one-time setup) + +This repo uses **Dependabot alerts only** — no automated PR raising for routine version bumps. + +To enable: + +1. Repo Settings → Code security and analysis → **Dependabot alerts** → Enable. +2. (Optional, recommended) Same page → **Dependabot security updates** → Enable. This will open PRs *only* for known-vulnerability fixes — not for routine version bumps. Expect a small initial wave (~10-15 PRs in the first weeks for the existing High advisories), then steady-state of a handful per month based on advisory cadence. + +We deliberately **do NOT add `.github/dependabot.yml`** for `package-ecosystem: npm` version updates — that would generate routine PR noise on every dep release. If your team later wants opt-in version updates, add a minimal `dependabot.yml` then. + +## 5. Audit gate (`yarn audit:prod`) + +Yarn 1.x `yarn audit` exits with a *severity bitmask*, not a threshold, and has no suppression mechanism — a single unfixable transitive advisory blocks every PR. To work around this, [`scripts/audit.mjs`](scripts/audit.mjs) wraps `yarn audit --json` and: + +1. Fails on any **high** or **critical** advisory not listed in [`.supply-chain/audit-allowlist.json`](.supply-chain/audit-allowlist.json). +2. Surfaces allowlist entries whose `review` date has passed as a **CI warning** (does not fail; review and renew or remove). +3. Surfaces allowlist entries that no longer match a current advisory as a **CI warning** (drift — remove from the allowlist). + +**Critical naming detail**: the script is exposed as `yarn audit:prod`, NOT `yarn audit`. Yarn 1.x's built-in `yarn audit` shadows same-named scripts in `package.json`, so naming the script `audit` would silently invoke the built-in instead. + +Allowlist policy: every entry needs `id`, `reason`, `added`, `review` (all required), plus optional `ghsa`, `package`, `severity` for human readability. An expired entry prints a warning but does not block CI — the team is expected to refresh or remove on review. + +## 6. Lockfile lint + +[`.github/workflows/supply-chain.yml`](.github/workflows/supply-chain.yml) runs `lockfile-lint` on every `yarn.lock`: + +``` +npx --yes lockfile-lint --path yarn.lock --type yarn --validate-https \ + --allowed-hosts yarn npm --empty-hostname false +``` + +Catches non-registry deps (e.g., `codeload.github.com` URLs from forked-and-patched packages), HTTP-only sources, and missing integrity hashes. A new GitHub-source dep should be allowlisted explicitly with a comment naming the package — never blanket-allow. + +## 7. Install-hook gate + +[`scripts/audit-install-hooks.mjs`](scripts/audit-install-hooks.mjs) enumerates every package in `node_modules` that declares a non-trivial `preinstall` / `install` / `postinstall` script and diffs the list against [`.supply-chain/install-hooks.allowlist`](.supply-chain/install-hooks.allowlist). Drift in either direction (new hook OR removed hook) fails the job — the latter catches stale allowlist entries. + +A new package with an install hook NOT in the allowlist requires an explicit decision: vet what the hook does, then run `yarn audit:install-hooks:update` to add it (with an inline comment describing what the hook does). Anything you can't justify in a sentence shouldn't go in. + +The Graph CLI's transitive tree includes `node-gyp-build` and similar legitimate native-binding bootstrappers; those are expected and allowlisted. Anything else is suspicious. + +## 8. Secret scanning (gitleaks) + +[`.github/workflows/gitleaks.yml`](.github/workflows/gitleaks.yml) runs gitleaks on every push + PR. Configuration notes: + +- The gitleaks binary is downloaded with a **pinned version + checksum-verified** SHA-256 (otherwise an unverified `wget` in the gate that's checking for compromise is itself a hole). +- PR runs scan only the diff against the base branch (fast). +- `push` runs to `main` scan the latest commit. +- A one-time **full-history scan** (`gitleaks detect --log-opts="--all"`) should be run before the gate becomes blocking; surface any historical leaks before they get re-discovered later. + +When bumping `GITLEAKS_VERSION`, fetch the upstream `gitleaks_${VERSION}_checksums.txt` from the GitHub release and update `GITLEAKS_SHA256` in the same commit. Reviewers should re-fetch and confirm. + +## 9. CI control summary + +| Workflow | Triggers | Required (branch protection)? | Failure mode | +|---|---|---|---| +| [`test.yaml`](.github/workflows/test.yaml) | PR + push to main | Yes (assumed) | Blocks merge | +| [`deploy-subgraph.yaml`](.github/workflows/deploy-subgraph.yaml) | `workflow_dispatch` only, main branch only | n/a (manual) | Validates inputs, then deploys | +| [`supply-chain.yml`](.github/workflows/supply-chain.yml) | PR + push to main | **Advisory at first; promote when team is ready** | Currently does not block merge | +| [`gitleaks.yml`](.github/workflows/gitleaks.yml) | PR + push | **Advisory at first; promote when team is ready** | Currently does not block merge | + +To promote `supply-chain.yml` and `gitleaks.yml` to required: Settings → Branches → main → Branch protection rules → Require status checks → add `All checks passed` (the supply-chain.yml aggregator) **and** `Gitleaks / scan` (cross-workflow `needs:` is not supported — both must be listed separately). + +## 10. Response playbook + +If a critical advisory is reported against a published subgraph, OR `SUBGRAPH_STUDIO_KEY` is suspected leaked, OR a malicious dep is detected: + +1. **Rotate `SUBGRAPH_STUDIO_KEY`** immediately (Subgraph Studio UI). Update GitHub repo secret. **This stops further malicious deploys but does NOT undeploy what has already been published.** +2. **Identify the affected subgraphs** — review recent deploy history in Subgraph Studio for unexpected publish events. +3. **Re-deploy known-good versions** of every affected subgraph. The previous-known-good version label is in the deploy history; trigger `workflow_dispatch` for each subgraph + network combination. Document the exact `yarn graph deploy` invocation in this PR description for the affected subgraphs. +4. **Open an incident issue** referencing this playbook, with timeline + scope. +5. **Notify downstream consumers** — Olas dashboards, frontends, and analytics teams should know to re-validate their cached data. + +The metric for response readiness: could the team re-deploy all 12 subgraphs to known-good versions in **under an hour**? If not, drill the playbook quarterly. + +## 11. Repo-specific watches + +These dependencies and patterns deserve special attention because of the repo's shape: + +- **`@graphprotocol/graph-cli`** — the largest dep tree by transitive footprint. Track upstream releases at [graph-tooling/releases](https://github.com/graphprotocol/graph-tooling/releases). Quarterly: check for security patches and prioritize the bump. +- **`SUBGRAPH_STUDIO_KEY`** — the only secret with org-wide blast radius. The cmdline-arg residual exposure is tracked in §3. +- **Service-registry template/manifest setup** — currently brittle (running `yarn generate-manifests` for `service-registry` overwrites hand-crafted mainnet/matic/optimism manifests with broken or lossy template output). Out of scope for a supply-chain PR but tracked here as it intersects with deploy correctness. +- **AssemblyScript runtime version** carried by `@graphprotocol/graph-ts` — a runtime change can produce subtly-different WASM output. Bumps require a staging deploy + cross-query against prod. + +## Contact + +Security disclosures: **info@valory.xyz** (see [SECURITY.md](SECURITY.md)). diff --git a/package.json b/package.json index ed133b1..70a929e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,14 @@ { "name": "autonolas-subgraph-studio", + "engines": { + "node": "24.x" + }, + "packageManager": "yarn@1.22.22", + "scripts": { + "audit:prod": "node scripts/audit.mjs", + "audit:install-hooks": "node scripts/audit-install-hooks.mjs", + "audit:install-hooks:update": "node scripts/audit-install-hooks.mjs --update" + }, "dependencies": { "@graphprotocol/graph-cli": "0.98.1", "@graphprotocol/graph-ts": "0.38.2" diff --git a/scripts/audit-install-hooks.mjs b/scripts/audit-install-hooks.mjs new file mode 100644 index 0000000..d308575 --- /dev/null +++ b/scripts/audit-install-hooks.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * Enumerate every package in node_modules that declares a non-trivial + * preinstall / install / postinstall script, and diff the list against + * a checked-in allowlist at .supply-chain/install-hooks.allowlist. + * + * New names in the tree but not in the allowlist = fail. Names in the + * allowlist but not in the tree = fail (drift — allowlist is stale). + * + * Use `--update` to regenerate the allowlist from the current tree. + * Run after any dependency change: + * yarn install + * node scripts/audit-install-hooks.mjs --update + * git add .supply-chain/install-hooks.allowlist + * + * See SUPPLY-CHAIN-SECURITY.md §7 for rationale. + */ + +import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// Paths are anchored to the current working directory, not a CLI argument. +// yarn scripts always run from the workspace root, which is what we want. +// Taking a root path from argv would let it flow into readFileSync / +// writeFileSync as an unvalidated path (a path-traversal sink flagged by +// static analysis). The only CLI option the script accepts is `--update`, +// matched as a literal string below — it is never used as a file path. +const ROOT = resolve('.'); +const ALLOWLIST_PATH = resolve(ROOT, '.supply-chain/install-hooks.allowlist'); +const NODE_MODULES = resolve(ROOT, 'node_modules'); +const UPDATE = process.argv.includes('--update'); + +const HOOK_KEYS = ['preinstall', 'install', 'postinstall']; + +// Defence-in-depth: bound recursion into nested node_modules in case +// a pathological tree (symlink loop, malicious self-containment) exists. +// Real hoisted trees never exceed single-digit depth. +const MAX_DEPTH = 20; + +// Hook commands we treat as trivial (no-op / log only). Everything else +// counts as "carries an install hook". +// +// The echo pattern uses a negative lookahead to reject any shell metachar +// that could chain a real command (e.g. `echo "ok" && node install.js`, +// `echo $(curl …)`). Without this, an attacker prefixing `echo ` would slip +// past the trivial filter. \n and \r are included because package.json +// `scripts` strings can contain literal newlines after JSON decoding, and +// `echo ok\nrm -rf /` would otherwise be classified as trivial. +const TRIVIAL = [ + /^(?!.*[&|;`$()<>\n\r])echo(\s|$)/, + /^true$/, + /^:$/, + /^exit\s+0$/, +]; + +function isTrivial(cmd) { + if (!cmd || typeof cmd !== 'string') return true; + const t = cmd.trim(); + if (!t) return true; + return TRIVIAL.some((r) => r.test(t)); +} + +/** + * Recursively walk node_modules, yielding every package.json path. + * Symlinked entries are skipped (Dirent.isDirectory() is false on a symlink) — + * rare in this tree because Yarn 1.x + Nx use TS path aliases for internal + * libs rather than node_modules symlinks. Out of scope for the registry- + * published-malicious-package threat model; if a workflow change ever + * introduces symlinked deps, this needs to follow symlinks via realpathSync + * with cycle detection. + */ +function* walkPackageJsons(dir, depth = 0) { + if (depth > MAX_DEPTH) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = join(dir, entry.name); + // Scoped packages: recurse into @scope/ to find @scope/pkg/package.json + if (entry.name.startsWith('@')) { + yield* walkPackageJsons(full, depth + 1); + continue; + } + const pkgJson = join(full, 'package.json'); + if (existsSync(pkgJson)) { + try { + if (statSync(pkgJson).isFile()) yield pkgJson; + } catch {} + } + // Recurse into nested node_modules (hoisting-related) + const nested = join(full, 'node_modules'); + if (existsSync(nested)) yield* walkPackageJsons(nested, depth + 1); + } +} + +function collectHooks() { + if (!existsSync(NODE_MODULES)) { + console.error(`node_modules not found at ${NODE_MODULES} — run \`yarn install\` first.`); + process.exit(2); + } + const found = new Map(); // name -> Set of "hook:cmd" + for (const path of walkPackageJsons(NODE_MODULES)) { + let pkg; + try { + pkg = JSON.parse(readFileSync(path, 'utf8')); + } catch { + continue; + } + if (!pkg.name || !pkg.scripts) continue; + for (const hook of HOOK_KEYS) { + const cmd = pkg.scripts[hook]; + if (!cmd || isTrivial(cmd)) continue; + if (!found.has(pkg.name)) found.set(pkg.name, new Set()); + found.get(pkg.name).add(`${hook}: ${cmd.replace(/\s+/g, ' ').trim()}`); + } + } + return found; +} + +function loadAllowlist() { + if (!existsSync(ALLOWLIST_PATH)) return new Set(); + const raw = readFileSync(ALLOWLIST_PATH, 'utf8'); + const names = new Set(); + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + // Allow either "name" or "name # comment" — strip inline comment. + const name = trimmed.split(/\s+#/)[0].trim(); + if (name) names.add(name); + } + return names; +} + +function writeAllowlist(hooks) { + const names = [...hooks.keys()].sort(); + const lines = [ + '# .supply-chain/install-hooks.allowlist', + '#', + '# Every package in node_modules that declares a non-trivial', + '# preinstall / install / postinstall script. Regenerate with', + '# `node scripts/audit-install-hooks.mjs --update` after any', + '# dependency change. CI runs the same script without --update', + '# and fails if this file drifts from the tree.', + '#', + '# See SUPPLY-CHAIN-SECURITY.md §7 for the per-package rationale', + '# (node-gyp-build native bindings, benign shim resolvers, etc.).', + '', + ]; + for (const name of names) { + const hookLines = [...hooks.get(name)].sort(); + lines.push(`${name} # ${hookLines.join(' | ')}`); + } + writeFileSync(ALLOWLIST_PATH, lines.join('\n') + '\n'); +} + +const found = collectHooks(); + +if (UPDATE) { + writeAllowlist(found); + console.log(`Wrote ${found.size} entries to ${ALLOWLIST_PATH}.`); + process.exit(0); +} + +const allowed = loadAllowlist(); +const foundNames = new Set(found.keys()); +const unexpected = [...foundNames].filter((n) => !allowed.has(n)).sort(); +const missing = [...allowed].filter((n) => !foundNames.has(n)).sort(); + +if (unexpected.length === 0 && missing.length === 0) { + console.log(`install-hooks: OK (${foundNames.size} allowlisted).`); + process.exit(0); +} + +if (unexpected.length > 0) { + console.error('::error::install-hook audit found NEW packages with install hooks not in the allowlist:'); + for (const name of unexpected) { + console.error(` + ${name}`); + for (const hook of found.get(name)) console.error(` ${hook}`); + } + console.error(''); + console.error('Review the hook. If it is legitimate, add the package to'); + console.error('.supply-chain/install-hooks.allowlist (run: yarn audit:install-hooks:update).'); +} + +if (missing.length > 0) { + console.error('::error::install-hook allowlist has entries no longer in the tree (drift):'); + for (const name of missing) console.error(` - ${name}`); + console.error(''); + console.error('Remove the stale entries (run: yarn audit:install-hooks:update).'); +} + +process.exit(1); diff --git a/scripts/audit.mjs b/scripts/audit.mjs new file mode 100644 index 0000000..155301f --- /dev/null +++ b/scripts/audit.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/** + * Run `yarn audit --groups dependencies` and fail on high/critical + * advisories in the production tree — unless the advisory is listed in + * .supply-chain/audit-allowlist.json with a reason and review date. + * + * Necessary because the stock Yarn 1.x `yarn audit` has no suppression + * mechanism. Without this, a single unfixable transitive advisory + * (e.g. an abandoned upstream package with no patch, or one whose only + * fix requires a major framework migration) blocks every PR. + * + * See SUPPLY-CHAIN-SECURITY.md §5. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +const ROOT = resolve('.'); +const ALLOWLIST_PATH = resolve(ROOT, '.supply-chain/audit-allowlist.json'); + +function loadAllowlist() { + if (!existsSync(ALLOWLIST_PATH)) return { entries: [] }; + let data; + try { + data = JSON.parse(readFileSync(ALLOWLIST_PATH, 'utf8')); + } catch (err) { + console.error(`::error::failed to parse ${ALLOWLIST_PATH}: ${err.message}`); + process.exit(2); + } + for (const entry of data.entries || []) { + const errors = []; + if (typeof entry.id !== 'number') errors.push('`id` must be a number'); + if (typeof entry.reason !== 'string' || !entry.reason.trim()) errors.push('`reason` is required'); + if (typeof entry.added !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.added)) { + errors.push('`added` must be YYYY-MM-DD'); + } + if (typeof entry.review !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.review)) { + errors.push('`review` must be YYYY-MM-DD'); + } + if (errors.length) { + console.error(`::error::malformed entry in ${ALLOWLIST_PATH}: ${errors.join('; ')} — ${JSON.stringify(entry)}`); + process.exit(2); + } + } + return data; +} + +function runYarnAudit() { + return new Promise((resolvePromise) => { + // `shell: true` is required on Windows so the `yarn.cmd` shim in + // PATH resolves; harmless on Linux/macOS runners where `yarn` is a + // plain executable. + const child = spawn('yarn', ['audit', '--groups', 'dependencies', '--json'], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + child.on('close', (code) => resolvePromise({ stdout, stderr, code })); + }); +} + +function parseAdvisories(stdout) { + const advisories = new Map(); + for (const line of stdout.split('\n')) { + if (!line.trim()) continue; + let row; + try { + row = JSON.parse(line); + } catch { + continue; + } + if (row.type !== 'auditAdvisory') continue; + const a = row.data.advisory; + const key = a.id; + if (!advisories.has(key)) advisories.set(key, { advisory: a, paths: new Set() }); + advisories.get(key).paths.add(row.data.resolution.path); + } + return [...advisories.values()]; +} + +const allowlist = loadAllowlist(); +const allowed = new Map(); +for (const entry of allowlist.entries || []) { + if (typeof entry.id !== 'number') continue; + allowed.set(entry.id, entry); +} + +const { stdout, stderr, code } = await runYarnAudit(); + +// Yarn 1.x exits non-zero even on success when advisories exist; we +// don't gate on exit code — we parse the JSON and apply our own gate. +if (!stdout) { + console.error('::error::`yarn audit` produced no output.'); + if (stderr) console.error(stderr); + process.exit(2); +} + +const advisories = parseAdvisories(stdout); + +const blocking = []; +const suppressed = []; +const expired = []; +const stale = []; + +const today = new Date().toISOString().slice(0, 10); + +for (const { advisory, paths } of advisories) { + const sev = advisory.severity; + if (sev !== 'high' && sev !== 'critical') continue; + const entry = allowed.get(advisory.id); + if (!entry) { + blocking.push({ advisory, paths }); + continue; + } + suppressed.push({ advisory, paths, entry }); + if (entry.review && entry.review < today) { + expired.push({ advisory, entry }); + } +} + +// Entries allowlisted but no longer suppressing a high/critical advisory — drift. +// Compare against the *suppressed* set, not the full advisory list: an advisory +// whose severity has dropped below high/critical still appears in `advisories`, +// but the allowlist entry is no longer doing any work. +const suppressedIds = new Set(suppressed.map(({ advisory }) => advisory.id)); +for (const entry of allowlist.entries || []) { + if (!suppressedIds.has(entry.id)) { + stale.push(entry); + } +} + +if (suppressed.length > 0) { + console.log(`Allowlisted (${suppressed.length}):`); + for (const { advisory, paths, entry } of suppressed) { + console.log(` [${advisory.severity}] ${advisory.module_name} ${advisory.vulnerable_versions}`); + console.log(` advisory ${advisory.id} (${advisory.github_advisory_id || 'no GHSA'})`); + console.log(` ${paths.size} path(s). reason: ${entry.reason}`); + console.log(` added ${entry.added}, review by ${entry.review}`); + } + console.log(''); +} + +for (const { advisory, entry } of expired) { + console.log( + `::warning::Allowlist entry for advisory ${advisory.id} (${advisory.module_name}) expired on ${entry.review}. Review and either update the review date with fresh justification, or remove if a fix is available.`, + ); +} + +for (const entry of stale) { + console.log( + `::warning::Allowlist entry ${entry.id} (${entry.package}) is no longer in the production tree. Remove it from .supply-chain/audit-allowlist.json.`, + ); +} + +if (blocking.length > 0) { + console.error(''); + console.error(`::error::${blocking.length} HIGH/CRITICAL advisory/advisories in the production tree are not allowlisted:`); + for (const { advisory, paths } of blocking) { + console.error(` [${advisory.severity}] ${advisory.module_name} ${advisory.vulnerable_versions} → fix in ${advisory.patched_versions}`); + console.error(` advisory ${advisory.id} (${advisory.github_advisory_id || 'no GHSA'})`); + console.error(` ${advisory.title}`); + console.error(` ${paths.size} path(s), e.g. ${[...paths][0]}`); + console.error(` fix: bump the dep, add a Yarn resolution, or allowlist in .supply-chain/audit-allowlist.json with a reason + review date.`); + console.error(''); + } + process.exit(1); +} + +console.log(`yarn audit: OK (${suppressed.length} allowlisted, no unlisted high/critical).`); +process.exit(0);