Detecting a Compromised CI/CD Pipeline Before It Ships Malicious Code

How to instrument your build pipeline for detection, not just prevention, using open source tooling and pseudo-code patterns for SIEM ingestion.

Your CI/CD pipeline has more access than almost anything else in your environment — cloud credentials, source code, signing keys, production deploy rights — and most teams monitor it less than they monitor a laptop.

Why this matters now

The last two years produced a steady drumbeat of build-pipeline compromises: poisoned GitHub Actions, hijacked npm publish steps, malicious commits injected through compromised maintainer accounts, self-hosted runners repurposed as footholds. The pattern is consistent — attackers don’t break into your production environment directly, they break into the thing that builds and deploys your production environment, because that thing already has the keys.

Most security programs treat this as a prevention problem: pin action versions, require signed commits, scan dependencies. All correct, all necessary, and all insufficient on their own. Prevention controls get bypassed — a maintainer account gets phished, a transitive dependency turns malicious after you’ve already pinned it, a self-hosted runner gets popped through an unrelated vulnerability. When that happens, detection is the only thing standing between a single compromised build and a supply-chain incident that reaches every downstream consumer of your software.

This guide covers how to turn your build pipeline into a source of detection telemetry, not just a thing you harden and hope.

The short version

DecisionWhy it matters
Treat pipeline runs as a first-class log source, not just build outputThe interesting signal is behavior during the build, which build logs alone don’t capture
Baseline what a normal build does, per pipelineA build script that suddenly makes outbound network calls or touches secrets it’s never touched before is a stronger signal than any single static rule
Verify artifact provenance at deploy time, not just at build timeA detection that fires after the artifact already shipped is a postmortem, not a control

What actually gets attacked

Three stages of the pipeline are worth instrumenting separately, because they fail differently:

  • The workflow definition itself. An attacker who can modify a workflow file (.yml/.yaml pipeline definitions, build scripts) can make the pipeline do anything the pipeline is trusted to do — exfiltrate secrets, inject code into the build output, or add a step that nobody reviews because it runs after the visible diff.
  • The runner. Self-hosted runners are frequently under-patched, over-permissioned, and network-reachable in ways engineers don’t expect. A compromised runner can read every secret injected into every job that lands on it, not just the job that compromised it.
  • The dependency graph. Build-time dependency resolution (package installs, action/plugin pulls) is an unmonitored code-execution path in almost every pipeline. Most teams scan dependencies before merge; almost none monitor what a dependency’s install script or build step actually does at build time.

Step 1: Get pipeline execution telemetry into your SIEM

Most teams only ship the pass/fail result and the console log of a build into a searchable store, if that. You want structured, per-job telemetry — comparable to process and network telemetry from an endpoint, because a build runner behaves exactly like an endpoint from a detection standpoint.

pipeline_event = {
    run_id, job_id, pipeline_name, triggered_by, trigger_type,   # push / pr / schedule / manual
    workflow_file_sha,          # hash of the workflow definition used for this run
    runner_id, runner_type,     # hosted vs self-hosted
    secrets_accessed:  [ list of secret names referenced, not their values ],
    network_egress:    [ list of destination hosts contacted during the job ],
    processes_spawned: [ list of binaries executed during the job ],
    artifacts_produced:[ list of artifact names + hashes ],
    duration_seconds,
}
emit(pipeline_event) -> siem_ingest_pipeline

Most CI platforms expose enough of this natively (audit logs, run metadata) to populate the first half. The runtime fields — network egress, processes spawned — usually require a lightweight agent or eBPF-based sensor on the runner itself, since the CI platform’s own logs won’t tell you what the build script actually did on the host.

Step 2: Baseline each pipeline against itself

Just like a service account, a pipeline’s “normal” is narrow and repetitive — it’s automation, not a person. That makes deviation detection unusually high-signal here.

for each pipeline_name in all_pipelines:
    baseline = {
        workflow_file_sha:   the known-good hash(es) of the workflow definition
        secrets_accessed:    the fixed set of secrets this pipeline legitimately uses
        egress_destinations: the fixed set of hosts this pipeline legitimately talks to
                              (package registries, artifact stores, deploy targets)
        typical_duration:    p50 / p95 job runtime
    }
    persist baseline, keyed by pipeline_name, updated only through a reviewed change process

The critical difference from a general behavioral baseline: don’t auto-refresh this one on a rolling window the way you would for a service account. A pipeline baseline should only change when a human approves a change to the workflow file — otherwise an attacker who slowly expands what a pipeline does trains your baseline to accept the compromise.

Step 3: Alert on deviation, not just failure

# pseudo-code, evaluated per pipeline_event at ingest

baseline = load_pipeline_baseline(event.pipeline_name)

if event.workflow_file_sha not in baseline.workflow_file_sha:
    emit_alert(
        severity = "high",
        reason   = "pipeline definition changed outside reviewed baseline",
        mitre    = "T1195.002"  # Supply Chain Compromise: Compromise Software Supply Chain
    )

for secret in event.secrets_accessed:
    if secret not in baseline.secrets_accessed:
        emit_alert(
            severity = "critical",
            reason   = f"pipeline accessed secret '{secret}' outside its normal set"
        )

for host in event.network_egress:
    if host not in baseline.egress_destinations and not is_known_registry(host):
        emit_alert(
            severity = "high",
            reason   = f"unexpected outbound connection to {host} during build",
            mitre    = "T1567"  # Exfiltration Over Web Service
        )

if event.runner_type == "self_hosted" and event.trigger_type == "pull_request" \
        and event.triggered_by not in TRUSTED_CONTRIBUTORS:
    emit_alert(
        severity = "critical",
        reason   = "external pull request triggered a job on a self-hosted runner"
    )

That last rule deserves special attention: letting an untrusted pull request execute code on a self-hosted runner is one of the most common ways these compromises actually start, because the runner’s network position and cached credentials are far more valuable than anything in the PR itself.

Step 4: Verify provenance before an artifact gets deployed

Detection during the build is necessary but not sufficient — you also want a control at the deploy boundary that fails closed if provenance doesn’t check out. This is where signing and attestation earn their keep, and where an unmonitored gap between “build finished” and “artifact deployed” is a place an attacker can substitute their own output.

on deploy_request(artifact):
    attestation = fetch_attestation(artifact.digest)

    if attestation is missing:
        block_deploy("no provenance attestation for artifact")

    if attestation.builder_id not in TRUSTED_BUILDERS:
        block_deploy("artifact built by untrusted builder identity")

    if attestation.source_repo != expected_repo or attestation.workflow_sha not in approved_workflow_shas:
        block_deploy("artifact provenance does not match expected source or workflow")

    emit_pipeline_event(type="deploy_gate_pass", artifact=artifact.digest)

The point isn’t just to block a bad deploy — it’s that a blocked deploy is a detection. Feed deploy_gate_pass and deploy_gate_block events into the same pipeline telemetry stream so a pattern of blocked deploys from one pipeline shows up as an incident, not a series of unrelated build failures someone eventually notices.

Where an AI-assisted triage step helps

Workflow file diffs and dependency manifest changes are exactly the kind of noisy, high-volume, low-context artifact a small language model is well suited to pre-triage before a human looks at it. A practical pattern:

on workflow_file_changed(diff):
    summary = small_model.summarize(
        prompt = "Summarize what permissions, secrets, or network "
                 "access this CI workflow diff adds or removes. "
                 "Flag anything that grants broader access than before.",
        input  = diff
    )
    attach_to_pr_review(summary)

    if summary.flags_broadened_access:
        require_security_review = true

This doesn’t replace a human reviewer approving pipeline changes — it makes sure the reviewer sees a plain-language callout of “this diff adds a new secret and a new outbound network call” instead of having to reconstruct that from a 40-line YAML diff themselves. The same pattern works for triaging the deviation alerts from Step 3: have a model draft a one-paragraph summary of what changed and why it’s likely benign or not, and let a human make the actual call.

Open source building blocks

ToolRole
Sigstore / cosignSign build artifacts and verify provenance attestations at deploy time
in-toto / SLSA provenanceStandardized attestation format for “what built this, from what source, with what inputs”
OSSF ScorecardAutomated scoring of a repository’s supply-chain hygiene — pinned dependencies, branch protection, review requirements
FalcoRuntime detection on the runner host itself — unexpected process execution, network connections, file access during a build
OpenTelemetryInstrument pipeline jobs to emit structured execution telemetry into whatever log store you already run

None of this depends on a specific CI platform or log backend — the pattern is baseline the pipeline, alert on deviation, gate on provenance, and it ports to whatever tooling you’re already running.

Final thought

A build pipeline compromise doesn’t look like an intrusion. It looks like a build that passed, an artifact that shipped, and a deploy that went out on schedule — right up until someone notices the software is doing something it was never written to do. Treating pipeline execution as telemetry worth baselining, the same way you’d treat a privileged service account, closes a gap that pure prevention controls can’t close on their own.

If you want help wiring pipeline telemetry into your detection coverage, contact us.