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
30 changes: 27 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ jobs:
migrations: ${{ steps.filter.outputs.migrations }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
# Pinned to a commit SHA, not the `v3` tag: a tag is mutable and can
# be repointed upstream without review on our side.
- uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
id: filter
with:
filters: |
Expand Down Expand Up @@ -325,13 +327,19 @@ jobs:
needs: money-policy
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
# Pinned to a commit SHA, not the `v2` tag (see other pins in this
# workflow for rationale).
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: |
contracts
Expand Down Expand Up @@ -378,8 +386,24 @@ jobs:
name: contracts-wasm
path: contracts/target/wasm32-unknown-unknown/release/*.wasm
if-no-files-found: error
# SLSA build provenance: generates a signed (Sigstore keyless, via
# GitHub's OIDC token) in-toto attestation binding these wasm
# artifacts to this workflow run, commit, and builder. Verify with
# `gh attestation verify <file> --repo <owner>/<repo>`.
#
# Restricted to `push` (i.e. post-merge on main): GitHub does not
# issue OIDC tokens to `pull_request` runs triggered from a fork, so
# this step cannot succeed there — and attesting a build from an
# unmerged fork PR isn't meaningful provenance anyway.
- name: Attest wasm build provenance
if: github.event_name == 'push'
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2
with:
subject-path: contracts/target/wasm32-unknown-unknown/release/*.wasm
- name: Install tarpaulin
uses: taiki-e/install-action@v2
# Pinned to a commit SHA, not the `v2` tag (see other pins in this
# workflow for rationale).
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2
with:
tool: cargo-tarpaulin
- name: Run coverage
Expand Down
15 changes: 12 additions & 3 deletions .github/workflows/security-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ jobs:
name: Semgrep SAST
runs-on: ubuntu-latest
container:
image: semgrep/semgrep:latest
# Pinned to a specific release, not `latest`: a mutable tag lets a
# compromised or buggy upstream image silently change what every PR
# is scanned with.
image: semgrep/semgrep:1.174.0
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config p/ci --error --exclude='**/node_modules/**' --exclude='**/target/**'
Expand All @@ -28,7 +31,10 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
# Pinned to a commit SHA (not the `v2` tag) so the action's code can't
# change under us without a reviewed bump here — a tag can be
# repointed by whoever controls the upstream repo.
- uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -37,7 +43,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
# Was pinned to `@master`, a mutable branch — the least hermetic
# reference possible for a security-scanning action. Pinned to a
# commit SHA on a tagged release instead.
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: .
Expand Down
9 changes: 9 additions & 0 deletions backend/src/routes/adminRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { strictRateLimiter } from '../middleware/rateLimiter.js';
import { validateBody } from '../middleware/validation.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { auditLog } from '../middleware/auditLog.js';
import { idempotencyMiddleware } from '../middleware/idempotency.js';
import { defaultChecker } from '../services/defaultChecker.js';
import {
createWebhookSubscription,
Expand Down Expand Up @@ -37,6 +38,7 @@ router.post(
requireJwtAuth,
requireRoles('admin'),
auditLog,
idempotencyMiddleware,
buildRejectLoanTx,
);
/**
Expand Down Expand Up @@ -93,6 +95,8 @@ router.get('/loan-disputes', requireApiKey('admin:disputes'), listLoanDisputes);
router.post(
'/loan-disputes/:disputeId/resolve',
requireApiKey('admin:disputes'),
auditLog,
idempotencyMiddleware,
resolveLoanDispute,
);
// New admin JWT-protected endpoints
Expand All @@ -102,12 +106,16 @@ router.post(
'/disputes/:disputeId/resolve',
requireJwtAuth,
requireRoles('admin'),
auditLog,
idempotencyMiddleware,
resolveLoanDispute,
);
router.post(
'/disputes/:disputeId/reject',
requireJwtAuth,
requireRoles('admin'),
auditLog,
idempotencyMiddleware,
rejectLoanDispute,
);

Expand Down Expand Up @@ -160,6 +168,7 @@ router.post(
requireApiKey('admin:loans'),
strictRateLimiter,
auditLog,
idempotencyMiddleware,
validateBody(checkDefaultsBodySchema),
asyncHandler(async (req, res) => {
const result = await defaultChecker.checkOverdueLoans(req.body.loanIds);
Expand Down
10 changes: 10 additions & 0 deletions contracts/agent_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ impl AgentVault {
to: Address,
amount: i128,
) -> Result<(), VaultError> {
// `from` and `to` must differ: read_vault(from) and read_vault(to)
// would otherwise alias the same storage key, and the second
// write_vault call below would silently clobber the first, minting
// `amount` of float out of thin air for the agent on every
// self-transfer. Checked before require_auth so a self-transfer
// never needs (and can't double-request) authorization for the
// same address.
if from == to {
return Err(VaultError::InvalidParams);
}
from.require_auth();
to.require_auth();
if amount <= 0 {
Expand Down
23 changes: 23 additions & 0 deletions contracts/agent_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,29 @@ fn test_transfer_float_atomic() {
);
}

/// Regression test: transferring float to yourself must be rejected, not
/// silently double-credited. `transfer_float` reads `from`'s and `to`'s
/// vaults into two separate in-memory structs, then writes each back with
/// `write_vault`. When `from == to` both reads observe the same starting
/// balance and the second write clobbers the first, so a naive
/// implementation nets the agent `+amount` float per call with no
/// corresponding mint or collateral movement — an unbounded free-float
/// exploit that also breaks the global solvency invariant.
#[test]
fn test_transfer_float_rejects_self_transfer() {
let s = setup();
let a = agent(&s.env);
fund(&s, &a, 1_000);
s.client.mint_float(&a, &500);

assert_eq!(
s.client.try_transfer_float(&a, &a, &100),
Err(Ok(VaultError::InvalidParams))
);
// Float must be unchanged — no free minting via self-transfer.
assert_eq!(s.client.get_vault(&a).float, 500);
}

#[test]
fn test_transfer_float_respects_recipient_bound() {
let s = setup();
Expand Down
Loading