Skip to content
Open
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
14 changes: 14 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,3 +346,17 @@ These variables are used by `docker-compose.yml`, `devtools/docker-compose.dev.y
### MCP Servers

MCP server configuration lives in `mcp-servers.json`, not `.env`. See the [MCP servers section](https://github.com/forge-sdlc/forge/blob/main/mcp-servers.json) of the repository.
# Secret scanning boundary

Forge composes secret detection into the common repository-change gate before a
workflow branch is pushed. Workflow nodes also explicitly scan text they know came
from an agent, such as pull-request descriptions, Q&A responses, review objections,
and post-merge summaries. Trusted Jira and GitHub clients remain transport components;
they do not globally classify every system message as agent output. Detection,
timeout, or scanner failure blocks the affected publication, and diagnostics contain
only the detector name, filename, and line number.

Repositories may commit a `detect-secrets` `.secrets.baseline`. Forge reads the
baseline exclusively from the trusted origin base revision, so an agent cannot alter
the allowlist during a run. Keep baselines narrowly scoped and review baseline changes
as security-sensitive policy changes.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies = [
"opentelemetry-exporter-otlp>=1.27.0",
"md-to-adf>=1.1.0",
"pyyaml>=6.0.0",
"detect-secrets>=1.5.0,<2",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions src/forge/security/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Trusted host-side security boundaries."""
234 changes: 234 additions & 0 deletions src/forge/security/secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
"""Fail-closed secret detection for repository output and outbound text."""

from __future__ import annotations

import json
import subprocess
import tempfile
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FutureTimeoutError
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from detect_secrets import SecretsCollection
from detect_secrets.settings import default_settings

if TYPE_CHECKING:
from forge.workspace.output_validation import OutputValidationContext


class SecretScanError(RuntimeError):
"""A scan could not establish that output is safe to externalize."""


@dataclass(frozen=True)
class SecretFinding:
"""Redacted scanner result. The matched value is deliberately unavailable."""

rule_id: str
location: str
line: int


@dataclass(frozen=True)
class AgentOutputContext:
"""Provenance supplied by a workflow node for generated text."""

source: str
ticket_key: str = ""
repository: str = ""
workflow_stage: str = ""


@dataclass(frozen=True)
class _ScannedFinding:
public: SecretFinding
secret_hash: str


class SecretDetectedError(SecretScanError):
"""One or more secrets were found."""

def __init__(self, findings: list[SecretFinding]):
self.findings = findings
locations = ", ".join(f"{f.location}:{f.line} ({f.rule_id})" for f in findings[:10])
suffix = "" if len(findings) <= 10 else f" and {len(findings) - 10} more"
super().__init__(f"Secret scan blocked externalization: {locations}{suffix}")


def _scan_files(files: list[tuple[Path, str]]) -> list[_ScannedFinding]:
findings: list[_ScannedFinding] = []
with default_settings():
for path, display_name in files:
secrets = SecretsCollection()
secrets.scan_file(str(path))
for detected in secrets.data.get(str(path), set()):
findings.append(
_ScannedFinding(
public=SecretFinding(
rule_id=detected.type,
location=display_name,
line=detected.line_number,
),
secret_hash=detected.secret_hash,
)
)
return findings


def _run_bounded(files: list[tuple[Path, str]], timeout_seconds: float) -> list[_ScannedFinding]:
pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="secret-scan")
future = pool.submit(_scan_files, files)
try:
return future.result(timeout=timeout_seconds)
except FutureTimeoutError as exc:
future.cancel()
raise SecretScanError(f"Secret scan timed out after {timeout_seconds:g}s") from exc
except SecretScanError:
raise
except Exception as exc:
raise SecretScanError(f"Secret scanner failed ({type(exc).__name__})") from exc
finally:
pool.shutdown(wait=False, cancel_futures=True)


def scan_text(text: str, *, location: str, timeout_seconds: float = 10) -> None:
"""Scan outbound text before it is posted. Raises with redacted details."""
if not text:
return
try:
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as output:
output.write(text)
output.flush()
findings = _run_bounded([(Path(output.name), location)], timeout_seconds)
except SecretScanError:
raise
except Exception as exc:
raise SecretScanError(f"Secret scanner failed ({type(exc).__name__})") from exc
if findings:
raise SecretDetectedError([finding.public for finding in findings])


def scan_agent_output(
text: str, *, context: AgentOutputContext, timeout_seconds: float = 10
) -> None:
"""Scan text explicitly identified by its workflow node as agent-derived."""

location = context.source
if context.workflow_stage:
location = f"{location} ({context.workflow_stage})"
scan_text(text, location=location, timeout_seconds=timeout_seconds)


def _git(repo: Path, *args: str) -> str:
try:
result = subprocess.run(
["git", *args], cwd=repo, capture_output=True, text=True, timeout=15, check=True
)
except (OSError, subprocess.SubprocessError) as exc:
raise SecretScanError(f"Unable to prepare secret scan ({type(exc).__name__})") from exc
return result.stdout


def _trusted_base(repo: Path) -> str:
for candidate in ("origin/HEAD", "origin/main", "origin/master"):
try:
return _git(repo, "merge-base", "HEAD", candidate).strip()
except SecretScanError:
continue
raise SecretScanError("Unable to resolve a trusted origin base for secret scanning")


def _baseline_hashes(repo: Path, base: str) -> set[str]:
result = subprocess.run(
["git", "show", f"{base}:.secrets.baseline"],
cwd=repo,
capture_output=True,
text=True,
timeout=15,
check=False,
)
if result.returncode != 0:
return set()
try:
baseline = json.loads(result.stdout)
return {
item["hashed_secret"]
for entries in baseline.get("results", {}).values()
for item in entries
if isinstance(item.get("hashed_secret"), str)
}
except (TypeError, json.JSONDecodeError, KeyError) as exc:
raise SecretScanError("Trusted .secrets.baseline is invalid") from exc


def scan_repository(repo: Path, *, timeout_seconds: float = 30) -> None:
"""Scan all changed and untracked output relative to the trusted origin base."""
repo = repo.resolve()
base = _trusted_base(repo)
changed = set(_git(repo, "diff", "--name-only", "--diff-filter=ACMRT", "-z", base).split("\0"))
changed.update(_git(repo, "ls-files", "--others", "--exclude-standard", "-z").split("\0"))
changed.discard("")

files: list[tuple[Path, str]] = []
for name in sorted(changed):
path = repo / name
try:
resolved = path.resolve(strict=True)
except (OSError, RuntimeError) as exc:
raise SecretScanError(f"Unable to inspect output path {name!r}") from exc
if not resolved.is_relative_to(repo) or path.is_symlink():
raise SecretScanError(f"Unsafe output path cannot be scanned: {name!r}")
if resolved.is_file():
files.append((resolved, name))

allowed = _baseline_hashes(repo, base)
findings = [
finding
for finding in _run_bounded(files, timeout_seconds)
if finding.secret_hash not in allowed
]
if findings:
raise SecretDetectedError([finding.public for finding in findings])


class RepositorySecretValidator:
"""Compose secret detection into Forge's common pre-push gate."""

name = "repository_secret_scan"

def __init__(self, *, timeout_seconds: float = 30):
self.timeout_seconds = timeout_seconds

def validate(self, context: OutputValidationContext) -> None:
repo = context.repo_path.resolve()
with tempfile.TemporaryDirectory(prefix="forge-secret-scan-") as directory:
files: list[tuple[Path, str]] = []
for index, entry in enumerate(context.changed_entries):
if entry.status == "D":
continue
try:
result = subprocess.run(
["git", "show", f"{context.head_ref}:{entry.path}"],
cwd=repo,
capture_output=True,
timeout=15,
check=True,
)
except (OSError, subprocess.SubprocessError) as exc:
raise SecretScanError(
f"Unable to inspect output blob {entry.path!r} ({type(exc).__name__})"
) from exc
scan_path = Path(directory) / str(index)
scan_path.write_bytes(result.stdout)
files.append((scan_path, entry.path))

allowed = _baseline_hashes(repo, context.base_ref)
findings = [
finding
for finding in _run_bounded(files, self.timeout_seconds)
if finding.secret_hash not in allowed
]
if findings:
raise SecretDetectedError([finding.public for finding in findings])
10 changes: 10 additions & 0 deletions src/forge/workflow/nodes/implement_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from forge.integrations.jira.client import JiraClient
from forge.prompts import load_prompt
from forge.sandbox import ContainerRunner
from forge.security.secrets import AgentOutputContext, scan_agent_output
from forge.workflow.feature.state import FeatureState as WorkflowState
from forge.workflow.nodes.code_review import run_post_change_review, sync_pr_description
from forge.workflow.nodes.workspace_setup import prepare_workspace
Expand Down Expand Up @@ -458,6 +459,15 @@ async def _post_review_objection(
f"{objections}\n\n"
f"*Please confirm whether to proceed as requested or withdraw.*"
)
scan_agent_output(
comment,
context=AgentOutputContext(
source="review objection",
ticket_key=ticket_key,
repository=f"{owner}/{repo}",
workflow_stage="implement_review",
),
)
if pr_number:
await github.create_issue_comment(owner, repo, pr_number, comment)
await post_status_comment(
Expand Down
10 changes: 10 additions & 0 deletions src/forge/workflow/nodes/post_merge_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from forge.config import get_settings
from forge.integrations.jira.client import JiraClient
from forge.security.secrets import AgentOutputContext, scan_agent_output
from forge.workflow.bug.state import BugState

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -39,6 +40,15 @@ async def post_merge_summary(state: BugState) -> BugState:
current_repo=current_repo,
pr_urls=pr_urls,
)
scan_agent_output(
comment,
context=AgentOutputContext(
source="post-merge summary",
ticket_key=ticket_key,
repository=current_repo,
workflow_stage="post_merge_summary",
),
)
await jira.add_comment(ticket_key, comment)
logger.info(f"Posted post-merge summary to {ticket_key}")
except Exception as e:
Expand Down
9 changes: 9 additions & 0 deletions src/forge/workflow/nodes/pr_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from forge.models.workflow import ForgeLabel, TicketType
from forge.orchestrator.checkpointer import set_pr_ticket_index
from forge.prompts import load_prompt
from forge.security.secrets import AgentOutputContext, scan_agent_output
from forge.workflow.nodes.code_review import sync_pr_description
from forge.workflow.nodes.post_merge_summary import _extract_impact
from forge.workflow.pr_state import save_active_pull_request
Expand Down Expand Up @@ -76,6 +77,14 @@ async def open_pull_request_from_fork(
draft: bool = False,
) -> dict:
"""Open a pull request from the prepared fork branch to upstream."""
scan_agent_output(
f"{title}\n{body}",
context=AgentOutputContext(
source="pull request title and body",
repository=f"{target.owner}/{target.repo}",
workflow_stage="pr_creation",
),
)
return await github.create_pull_request(
owner=target.owner,
repo=target.repo,
Expand Down
9 changes: 9 additions & 0 deletions src/forge/workflow/nodes/qa_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from forge.integrations.agents import ForgeAgent
from forge.integrations.github.client import GitHubClient
from forge.integrations.jira.client import JiraClient
from forge.security.secrets import AgentOutputContext, scan_agent_output
from forge.workflow.feature.state import FeatureState as WorkflowState
from forge.workflow.utils import update_state_timestamp

Expand All @@ -29,6 +30,14 @@ async def _post_qa_response(
artifact_type: str,
body: str,
) -> None:
scan_agent_output(
body,
context=AgentOutputContext(
source=f"{artifact_type} Q&A response",
ticket_key=ticket_key,
workflow_stage="qa_handler",
),
)
pr_target = _artifact_pr_target(state, artifact_type)
if pr_target:
repo_full, pr_number = pr_target
Expand Down
3 changes: 2 additions & 1 deletion src/forge/workspace/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

from forge.config import get_settings
from forge.security.secrets import RepositorySecretValidator
from forge.utils.redaction import redact_secrets
from forge.workspace.manager import Workspace
from forge.workspace.output_validation import (
Expand Down Expand Up @@ -36,7 +37,7 @@ def __init__(
"""
self.workspace = workspace
self.settings = get_settings()
self.output_validators = tuple(output_validators)
self.output_validators = (RepositorySecretValidator(), *tuple(output_validators))
self.output_base_ref = output_base_ref
# Set by workspace recovery when this instance represents a replacement
# clone rather than the workspace recorded in workflow state. The path
Expand Down
Loading