Security and supply chain
fremforge ships a layered supply-chain security stack included in every seat plan. Controls run at four choke points: the push (secret scanning), the PR (dependency scanning, SAST), the artifact (container scanning, SBOM, SLSA provenance), and the commit (SSH-key or GPG signing).
For the contractual posture (BSI C5, ISO 27001/27017/27018, TISAX, GDPR, the Schrems II / CLOUD Act analysis) see frem.sh/trust.
Secret scanning (push protection)
Gitleaks runs as a pre-receive hook on every push, before the commit is accepted into the repository. Push protection is a platform floor: it cannot be disabled by any tenant or repo owner.
What gets blocked
Gitleaks matches 120+ high-confidence patterns including:
| Category | Example patterns |
|---|---|
| Cloud provider credentials | AWS access/secret keys, GCP service account JSON, Azure storage connection strings |
| Source control tokens | GitHub PATs, GitLab PATs, Bitbucket app passwords |
| Payment keys | Stripe secret keys, Stripe webhook signing secrets |
| Communication tokens | Slack bot tokens, Slack webhook URLs, Twilio auth tokens |
| Private keys | RSA/EC/Ed25519 private keys, PEM-encoded certificates |
| Generic secrets | High-entropy strings matching the generic-api-key pattern, JWT signing secrets |
| Database URLs | PostgreSQL, MySQL, MongoDB, Redis connection strings with embedded credentials |
When a push is blocked
The push is rejected at the remote with an error message that includes the matched pattern type and the file + line number. The commit is not accepted.
Do not just delete the file and re-commit. The secret is in git history and will be blocked again. The correct remediation:
- Rotate the secret immediately at the issuing service (AWS IAM, Stripe dashboard, etc.). Assume it is compromised.
- Rewrite history to remove the secret from every affected commit:
# Install git-filter-repo (https://github.com/newren/git-filter-repo)
pip install git-filter-repo
# Create a replacements file
echo '<secret-value>==>REDACTED' > replacements.txt
# Rewrite history
git filter-repo --replace-text replacements.txt
# Force-push the cleaned branch
git push --force-with-lease origin <branch>- Notify anyone who may have cloned the repo before the rewrite.
Override flow
If a blocked secret is intentional (internal tooling token, published test credential), an org owner can add a scoped override:
- Go to Org admin → Push protection → Active overrides → New override.
- Specify: Repository (or select “All repos”), secret pattern (from the Gitleaks rule ID), and justification (free text, logged in the audit trail).
- Save. The override takes effect on the next push.
All overrides are visible to other org owners and appear in the audit log with actor, timestamp, and justification. Overrides can be revoked at any time from the same page.
Overriding a push rejection
When a push is rejected by the secret scanning gate, the developer sees the rejection reason in the terminal output, the matched pattern type, the file path, and the line number.
An org owner can approve an override at Org admin → Push protection → Recent rejections. Each rejection entry shows: the repository, the committer, the timestamp, the secret type detected, and the affected file path.
Override scopes (select when approving):
- This commit only, approves the specific commit SHA. The same secret in a future commit will be rejected again.
- This file path, approves the secret at that path in any future commit. Use for test fixtures or deliberately non-sensitive placeholder values.
- This repository, approves any occurrence of this secret type in the repository. Use sparingly.
- Org-wide, approves the secret type across all repositories. Only for declared false positives (e.g., a custom token format that matches a rule pattern but is not a real secret).
Override reason is required (free text). It is written to the audit log.
The developer must re-push after an override is granted. The override does not retroactively accept the rejected push.
Custom allowlist
To silence known false positives in a specific repository (test fixtures, example keys in documentation, intentional public credentials), add a .gitleaks.toml at the repo root:
[extend]
useDefault = true
[[allowlists]]
description = "Test fixture AWS keys"
paths = ["tests/fixtures/.*", "docs/examples/.*"]
[[allowlists]]
description = "Example key in README"
regexes = ["AKIAIOSFODNN7EXAMPLE"]The allowlist is evaluated per-repo during the pre-receive hook. It does not override org-level push protection for patterns not in the allowlist.
Dependency scanning
Renovate (hosted, per-tenant bot user) raises PRs when dependencies have published CVEs above the configured severity threshold.
Enable and configure
Enable at Org admin → Dependency updates → Enable hosted Renovate. Once enabled, the Renovate orchestrator runs every 15 minutes (cron */15 * * * *) and raises PRs against every repository in the org as dependency manifests change.
Per-repo configuration lives in renovate.json at the repo root. The full schema is at docs.renovatebot.com. fremforge runs upstream Renovate unmodified, so every config option applies.
CVE policy and severity thresholds
Set the org-wide threshold at Org admin → Dependency updates → CVE policy:
| Threshold | Behavior |
|---|---|
| LOW | PRs for all CVE-affected versions, including informational findings |
| MEDIUM | PRs for MEDIUM, HIGH, and CRITICAL CVEs |
| HIGH (default) | PRs for HIGH and CRITICAL CVEs only |
| CRITICAL | PRs for CRITICAL CVEs only |
Merge-block policy
By default, Renovate opens PRs for dependency updates but does not block merges on unpatched vulnerabilities.
To enable merge-blocking: Org admin → Code security → Dependency scanning → Merge-block policy → Enable.
Once enabled, any PR that introduces or retains a dependency with a CVSS score ≥ 7.0 (HIGH or CRITICAL) is blocked from merging until either: (a) the dependency is updated to a patched version, or (b) an org owner approves an exception at Org admin → Code security → Dependency scanning → Merge-block exceptions.
Exception approval requires a reason (written to the audit log). Exceptions expire after 30 days and must be renewed.
The merge-block appears as a required status check on the PR. The check links to the finding detail showing the CVE ID, affected package, fixed version, and CVSS score.
False positive path: if a finding is incorrect (e.g., the CVSS database has wrong version metadata), open a support ticket at support@frem.sh with the CVE ID and affected package. Confirmed false positives are suppressed at the platform level within one business day.
Supported manifests
| Ecosystem | Files |
|---|---|
| JavaScript / Node.js | package.json, package-lock.json, yarn.lock, pnpm-lock.yaml |
| Go | go.mod, go.sum |
| Rust | Cargo.toml, Cargo.lock |
| Python | requirements.txt, Pipfile, pyproject.toml, poetry.lock |
| Ruby | Gemfile, Gemfile.lock |
| Java | pom.xml, build.gradle, build.gradle.kts |
| Containers | Dockerfile, docker-compose.yml, docker-compose.yaml |
| CI workflows | .forgejo/workflows/*.yaml (action version pinning) |
Container image scanning
Trivy scans every OCI image pushed to the fremforge package registry. Results appear at Org admin → Code security → Container images.
Each finding includes:
| Field | Description |
|---|---|
| CVE ID | e.g. CVE-2024-12345 with link to NVD |
| Severity | CRITICAL / HIGH / MEDIUM / LOW / UNKNOWN |
| Affected package | Package name and installed version |
| Fix version | First version that resolves the CVE, if available |
| EPSS score | Exploit Prediction Scoring System probability |
CI pipeline scanning
Enable the Trivy CI workflow template at Org admin → Code security → Container images → Enable pipeline scanning. This provisions a image-scan-trivy.yaml workflow in your org’s .forgejo/workflows/ default template set:
name: Image scan
on:
push:
branches: [main]
pull_request:
jobs:
trivy:
runs-on: fremforge
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t ${{ github.repository }}:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ github.repository }}:${{ github.sha }}
format: table
exit-code: "1"
severity: HIGH,CRITICALAdjust severity to match your org CVE policy threshold.
SBOM generation
Syft generates CycloneDX 1.6 SBOMs for every container image pushed to the registry and for every release tag (SPDX is accepted at ingest, not generated). SBOM generation is on by default for every org, there is no enable step.
Download
From the package registry UI: open the image → Attestations tab → Download SBOM.
Via API:
curl -H "Authorization: token <your-pat>" \
https://frem.sh/_app/api/v1/orgs/<org>/findings/attestations \
-o sbom.jsonThe SBOM is CycloneDX JSON and includes all transitive dependencies with their PURL identifiers, license expressions, and version ranges.
SAST
OpenGrep runs static analysis on every PR. SAST is on by default for every org, there is no enable step. Findings are managed at Org admin → Code security → SAST findings.
Supported languages
Python, JavaScript, TypeScript, Go, Java, Ruby, PHP, C, C++.
Findings
Findings appear as a PR comment plus a fremforge/sast commit status and are aggregated at Org admin → Code security → SAST findings for org-level review. The severity floor (block PR on ERROR vs. WARNING) is configurable per org.
To use a custom ruleset, set Custom rules URL under the SAST settings to any URL that serves a valid OpenGrep rules YAML file.
Signed commits
fremforge supports two commit-signing paths, both verified natively by Forgejo’s “Verified” badge in the web UI:
- SSH-key signing (recommended default), reuses the SSH key you already have registered for
git push. No new key material to manage, no third-party sub-processor, no transparency-log dependency, no US-jurisdiction transit. This is the canonical fremforge path. (For new orgs created after 2026-05-22, SSH transport is disabled by default; SSH-key signing still works regardless of whether you use SSH or HTTPS forgit push.) - GPG signing, classical OpenPGP key. Use this if your organisation already has a GPG key-management policy in place (smartcard, HSM, central keyserver).
Keyless commit signing IS available at fremforge via a self-hosted Sigstore stack: Fulcio CA at sign.frem.sh and a TSA at tsa.frem.sh, both running on T Cloud Public in eu-de with the root CA in DEW KMS. The public Linux Foundation Sigstore instance is NOT used — fremforge does not route any signing or verification traffic through sigstore.dev / rekor.sigstore.dev, so the no-US-sub-processor posture holds. There is deliberately no Rekor / transparency log; TSA-anchored timestamping is the integrity primitive instead. See keyless commit signing for the customer-side gitsign setup. (Note: build/release provenance is a separate path — fremforge SLSA provenance is signed with a platform-held Ed25519 key, NOT through Fulcio. See the SLSA section below.)
SSH-key signing, configure
# Tell Git to use SSH for signing (Git ≥ 2.34)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
# (Optional) point Git at your local allowedSignersFile for verify
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signersAdd the matching SSH key to your fremforge user profile under Settings → SSH and GPG keys → Add signing key (Forgejo accepts the same key for both push and signing, or a separate signing-only key).
On the next git commit, Git signs with your SSH key. Forgejo verifies the signature against the org’s allowedSignersFile (auto-derived from members’ registered signing keys) and renders the “Verified” badge in the web UI.
Verify
git log --show-signature HEAD~5..HEADEach commit shows a Good "git" signature for <user>@<org> line. The Forgejo web UI shows a green “Verified” badge next to the commit hash.
GPG signing, configure
If you prefer GPG, the standard git config --global commit.gpgsign true + user.signingkey <gpg-key-id> flow works unchanged; upload your public key under Settings → SSH and GPG keys → Add GPG key. Forgejo verifies and renders the “Verified” badge.
Require signed commits (org policy)
Enforce signed commits on protected branches via Org admin → Repo defaults → Branch protection → Require signed commits (applies to new repos), or per-repo at Repository → Settings → Branches → <branch> → Require signed commits. Unsigned commits are rejected at push time. Both SSH-key and GPG signatures satisfy the requirement.
SLSA provenance
Build/release provenance is generated server-side by fremforge — there is no workflow file in your repos and no runner minutes are spent. On every Forgejo release.published and package event, the fremforge api hashes each published asset, builds an in-toto Statement wrapped in a DSSE envelope, signs it with the platform builder key, persists the attestation to EU-resident storage scoped to your tenant, and uploads the envelope back alongside the asset as <asset>.intoto.jsonl. It is also surfaced in the admin UI under Security → Code security → Attestations.
The standalone
slsa-provenance.yamlworkflow template that customers were previously expected to call from their build pipelines was retired on 2026-06-16 — a fleet audit found zero callers, and provenance now fires automatically server-side with no customer wiring.
EU-sovereign by design. The signing key, the trust root, and the storage all live inside the fremforge T Cloud Public account (eu-de). The provenance is signed with a platform-held Ed25519 key — not cosign, not Sigstore Fulcio, not Rekor. There is no Fulcio cert chain, no transparency log, and no tuf-repo-cdn.sigstore.dev lookup in the verification path. (Note: this is distinct from fremforge’s commit signing, which does use a self-hosted Fulcio CA — see keyless commit signing. Build provenance and commit signing are separate paths.)
Trust root: https://www.frem.sh/.well-known/slsa-trust-root.json, a small JSON file listing the active builder public key(s). Its shape is fremforge-specific:
{
"trusted_keys": [
{
"kid": "fremforge-slsa-builder-v1",
"alg": "ed25519",
"pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
"builder_id": "https://frem.sh/runner-controller/v1"
}
]
}Pin the file (or a copy of it) in your CI to lock-in trust over the verification window. Key rotations are announced 30 days in advance per the trust page §Sub-processors notification rule; old signatures stop verifying once the key disappears from trusted_keys.
Envelope + statement shape (DSSE envelope wrapping an in-toto Statement):
| Field | Value |
|---|---|
envelope payloadType | application/vnd.in-toto+json |
envelope signatures[0].keyid | fremforge-slsa-builder-v1 (matches a trusted_keys[].kid in the trust root) |
Statement _type | https://in-toto.io/Statement/v1 |
Statement predicateType | https://slsa.dev/provenance/v1 |
predicate.buildDefinition.buildType | https://frem.sh/runner-controller/buildtypes/forgejo-release/v1 (release webhook) |
predicate.buildDefinition.externalParameters.repository | <org>/<repo> |
predicate.buildDefinition.externalParameters.ref | The git ref (e.g. refs/tags/v1.2.3) |
predicate.runDetails.builder.id | https://frem.sh/runner-controller/v1 (server-controlled and force-overridden at sign time — a compromised runner cannot forge this) |
subject[].name / subject[].digest.sha256 | The asset filename and the SHA-256 fremforge computed for it |
SLSA level: Build L2, hosted isolated build platform (single-use ECS VM per job + per-VM ENI; see §Kernel isolation model), signed provenance, server-controlled builder identity. L3 (hermetic builds, reproducible artifacts, full external transparency log) is on the supply-chain roadmap, triggered by a regulated-industry RFP, not at launch.
Verify
Do not use upstream
slsa-verifier verify-artifact --trusted-root. That tool has no--trusted-rootflag onverify-artifactand cannot consume fremforge’s custom trust-root JSON or a bare-Ed25519 DSSE envelope — it expects a Sigstore-style bundle. fremforge’s provenance is a plain Ed25519-signed DSSE envelope, so you verify it directly withcurl/jq/openssl/python3— all distribution-default, no fremforge tooling required.
The verification has three independent checks: the DSSE signature is valid for the published builder key, the asset’s SHA-256 matches what was signed, and the statement names the right repo/builder.
1. Fetch the trust root and extract the builder public key
curl -sSfL https://www.frem.sh/.well-known/slsa-trust-root.json -o trust-root.json
jq -r '.trusted_keys[] | select(.kid=="fremforge-slsa-builder-v1") | .pem' \
trust-root.json > builder.pub2. Verify the DSSE signature, the asset digest, and the statement fields
Run from a directory containing the downloaded asset and its <asset>.intoto.jsonl envelope. Set ASSET to the asset filename:
ASSET=myapp python3 - <<'PY'
import base64, json, hashlib, os, subprocess, sys, pathlib
asset = os.environ["ASSET"]
env = json.loads(pathlib.Path(f"{asset}.intoto.jsonl").read_text().strip())
payload_type = env["payloadType"] # application/vnd.in-toto+json
payload = base64.b64decode(env["payload"])
# Reconstruct the DSSE pre-authentication encoding (DSSEv1):
# "DSSEv1 " + LEN(type) + " " + type + " " + LEN(payload) + " " + payload
# where LEN is the utf-8 byte length and separators are single ASCII spaces.
pae = (
b"DSSEv1 "
+ str(len(payload_type.encode())).encode() + b" "
+ payload_type.encode() + b" "
+ str(len(payload)).encode() + b" "
+ payload
)
pathlib.Path("pae.bin").write_bytes(pae)
pathlib.Path("sig.bin").write_bytes(base64.b64decode(env["signatures"][0]["sig"]))
# Ed25519 ("pure" EdDSA — no pre-hash) verify with openssl. Feed the raw PAE.
r = subprocess.run(
["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", "builder.pub",
"-rawin", "-in", "pae.bin", "-sigfile", "sig.bin"],
capture_output=True, text=True,
)
if r.returncode != 0 or "Signature Verified Successfully" not in r.stdout:
print("DSSE signature FAILED:", r.stdout, r.stderr, file=sys.stderr); sys.exit(1)
print("DSSE signature: OK")
stmt = json.loads(payload)
# The signed subject digest must match the asset bytes on disk.
want = next((s["digest"]["sha256"] for s in stmt["subject"] if s["name"] == asset), None)
got = hashlib.sha256(pathlib.Path(asset).read_bytes()).hexdigest()
if want is None:
print(f"asset {asset} not found among signed subjects", file=sys.stderr); sys.exit(1)
if want != got:
print(f"digest mismatch: signed={want} actual={got}", file=sys.stderr); sys.exit(1)
print(f"asset digest: OK ({got})")
# Sanity-check the builder identity and predicate type.
builder = stmt["predicate"]["runDetails"]["builder"]["id"]
if builder != "https://frem.sh/runner-controller/v1":
print(f"unexpected builder.id: {builder}", file=sys.stderr); sys.exit(1)
if stmt["predicateType"] != "https://slsa.dev/provenance/v1":
print(f"unexpected predicateType: {stmt['predicateType']}", file=sys.stderr); sys.exit(1)
print("builder.id + predicateType: OK")
PYIf all three checks print OK, the asset is the exact bytes fremforge attested, the envelope was signed by the published builder key, and the statement names the fremforge builder. Verification fails (non-zero exit) if the signature doesn’t validate against builder.pub (tampered envelope or wrong/rotated key), the asset’s SHA-256 doesn’t match the signed subject (tampered asset), or the builder.id / predicateType don’t match.
The
keyidin the envelope (fremforge-slsa-builder-v1) must match thekidyou selected from the trust root. If fremforge rotates the signing key, re-fetch the trust root and select the currentkid.
Why not Sigstore at launch
Sigstore’s public Fulcio + Rekor + Trillian stack is operated by the OpenSSF (Linux Foundation), a US-incorporated body. Using it would send every build’s metadata to US-hosted infrastructure, incompatible with fremforge’s EU-only sub-processor commitment in DPA Annex B. fremforge runs its own self-hosted Fulcio + TSA at sign.frem.sh / tsa.frem.sh on T Cloud Public eu-de for commit signing (see keyless commit signing). For build provenance, the platform-held Ed25519 key path above is used instead — no Fulcio, no Rekor. fremforge’s own audit-chain WORM anchor (3-year COMPLIANCE-mode-locked OBS Object Lock) provides equivalent tamper-evidence: every attestation’s SHA-256 is also hash-chained into the platform’s audit log.
Kernel isolation model
Each CI job runs on its own single-use ECS virtual machine on T Cloud Public in eu-de. The isolation properties:
| Property | Detail |
|---|---|
| Kernel sharing | None — each job runs on a dedicated VM; no shared kernel between concurrent jobs, same org or different orgs |
| Persistence | VMs are destroyed after job completion; no filesystem state survives between runs |
| Platform-API access | The VM has no IAM agency attached; the metadata-derived identity has no roles, so the OTC API is unreachable from inside |
| Network | Per-VM Security Group with egress-only allowlist; runners cannot receive inbound connections |
| T Cloud Public metadata | Blocked by SSRF outbound proxy; runner code cannot reach the instance metadata endpoint with privileged scope |
See CI runners for the full isolation specification and BYO runner registration.
Tenant isolation
Every fremforge tenant is a separately-keyed namespace in the platform. The isolation chain runs from the URL through middleware, the database query layer, the storage layer, and the audit chain.
| Layer | Mechanism | What stops cross-tenant access |
|---|---|---|
| URL routing | /<slug>/_admin/* middleware re-loads the tenant from the slug on every request | A user with a session for org A who pastes org B’s URL is rejected at the membership check, not at a stale cookie |
| Membership check | Forgejo Owners-team lookup against the slug, verified on every request | The api never trusts an inbound claim about which orgs the user belongs to |
| Database | Every tenant-keyed table has a tenant_id foreign key; every read AND every write filters on it | SELECT / UPDATE / DELETE without tenant_id is structurally impossible in the audit-flagged paths |
| Signed tokens | Every short-lived signed URL (billing magic link, undo cancellation, OIDC state) carries the tenant id in its payload; the verify-side cross-checks against the URL slug | A token minted for tenant A is rejected if replayed against tenant B’s URL |
| Storage (OBS) | SBOMs, attestations, audit-log objects all keyed <artifact>/<tenant_id>/...; signed-URL minting re-validates tenant.id === row.tenant_id before issue | A direct OBS-presigned URL bound to tenant A cannot be rewritten to read tenant B |
| Audit chain | Tamper-evident hash chain keyed per-tenant; one tenant’s appends never modify another’s chain head | A break in tenant A’s chain (e.g. an attempted retroactive delete) does not affect tenant B’s chain integrity |
| Operator access | Staff (fremverk) viewing customer data is logged to the customer’s audit chain as an operator action; tenant admins can see operator visits in their own audit log | No silent staff access to customer data |
Owners-team revocation lag
The api caches a customer’s Forgejo Owners-team membership for up to 5 minutes to keep page-loads under 50ms (the alternative is a Forgejo API round-trip on every request, which would be 100-200ms slower per page). The trade-off:
- When you add someone to Owners on the Forgejo side, they gain admin access within 5 minutes.
- When you remove someone from Owners on the Forgejo side, they lose admin access within 5 minutes, not instantly.
For most operations this lag is acceptable: a former owner cannot create new Forgejo resources (Forgejo’s own check is immediate), and any privileged action via the fremforge admin UI is recorded in the per-tenant audit log with the actor’s username, so post-revocation activity remains attributable.
If you need immediate revocation (e.g. as part of an incident response after a credential compromise), the operator on-call can pin the cache to zero TTL for your tenant on request, open a ticket at support@frem.sh with subject prefix [urgent-revocation] and the username + tenant slug. Standard SLA: under 15 minutes during business hours, under 60 minutes outside. The same effect is achieved by revoking the user’s Forgejo session (Forgejo Owner → User profile → Sign out), which invalidates the session cookie the api parses, no fremforge-side action needed.
Platform security commitments
| CVE severity | Patch SLA (from upstream fixed release) |
|---|---|
| Critical (CVSS ≥ 9.0) | 48 hours |
| High (7.0-8.9) | 72 hours |
| Medium (4.0-6.9) | 7 days |
| Low | Next scheduled maintenance window |
The patch SLA is published contractually and cited verbatim in the DPA security annex.
Audit-log integrity: tamper-evident hash chain anchored to T Cloud Public OBS WORM storage every 2 minutes; hourly FunctionGraph integrity check; chain breaks page on-call.
Troubleshooting
Push blocked but I cannot find the secret in the diff.
The secret may be in a file that was modified but whose full content is not visible in the standard diff. Run Gitleaks locally to identify the exact location:
pip install gitleaks # or brew install gitleaks
gitleaks detect --source . --verboseRenovate is not raising PRs for a known CVE.
Check the org CVE policy threshold first. The CVE severity may be below the configured floor. If the threshold is correct, check the repository has a supported manifest file and that Renovate has access (it requires read+write on the repo, granted automatically at enrolment). Trigger an out-of-cycle run at Org admin → Dependency updates → Run now.
SBOM download returns 404.
SBOMs are generated only for images pushed after SBOM generation was enabled. Images pushed before enabling do not have an SBOM. Push a new tag to generate one.
SSH-signing fails with ssh-keygen: gpg failed to sign the data.
Make sure git config gpg.format is set to ssh (not the default openpgp) and user.signingkey points at a public-key file. On macOS, also confirm ssh-keygen is on $PATH (Apple’s bundled ssh-keygen works; if you’ve installed Homebrew OpenSSH, ensure it shadows correctly).
Cross-references
- Push protection override in org admin, managing the active override list
- Dependency updates, Renovate configuration reference
- CI runners, runner isolation, ephemeral per-job ECS VMs, BYO runner registration
- OIDC token federation, keyless cloud auth from CI jobs