Skip to content

Code Scanning

Cloud Security scans the source repositories behind your cloud estate and files what it finds into the same risk-ranked worklist as everything else. Vulnerable dependencies, credentials committed to git, misconfigured infrastructure-as-code, container images, code weaknesses and end-of-life runtimes all arrive as ordinary findings — same shape, same triage verbs, same automation events.

The point is not to add a second security product next to the first one. It is that a dependency advisory means something different when the graph can show the image it is baked into and the workload running that image, and an infrastructure-as-code check means something different when the bucket it declares is a bucket you actually own.

In the console it's the Code page

Repositories, their scan status and their findings live under Cloud Security → Code. The same findings also appear on Risks, where the Repository filter narrows to one repository.

What it scans

Lane What it reads Finding class
Dependencies (SCA) lockfiles and manifests, resolved to a software bill of materials, matched against the vulnerability database vulnerability
Malicious packages the same dependency set, matched against a malicious-package feed malware
Secrets credentials in the working tree, and — as a separate switch — in the full commit history secret
Infrastructure as code Terraform, CloudFormation, Kubernetes manifests, Helm charts, Dockerfiles misconfig
Container images images the repositories reference, and optionally images your workloads run vulnerability on the image
Static analysis (SAST) source files, against a curated rule pack mapped to CWE code_weakness
Licenses dependency licenses that carry obligation or compatibility risk license_risk
End-of-life runtimes language and base-image runtimes past their published support date eol_runtime

Dependency findings carry the package, the installed version and the fixed version where one exists, plus EPSS and KEV so the queue is ordered by exploitability rather than by CVSS alone. Secrets found only in history carry a different remediation — rotate, because deleting the file does not un-leak the credential.

How scanning works, and what leaves your repository

Scanning code means reading code. The invariant is therefore not "we never read it", it is we never keep it:

  • Each scan runs in an ephemeral, sandboxed container in your data region. It shallow-clones the repository into scratch storage, runs the engines, writes one normalized report, and is destroyed.
  • The container runs with no cloud identity attached, a read-only root filesystem, restricted egress and a hard 30-minute wall clock. It reaches your source-control host, our object storage and our vulnerability-database mirror, and nothing else.
  • On GitHub, the access token it is handed is scoped to the single repository being scanned and expires in an hour. GitLab and Bitbucket cannot narrow a token that way, so the job clones with the connection's own token — which is why those connectors ask for a token holding only the read scopes, and flag a broader one at connection time.
  • Only the report leaves. Findings, the bill of materials and hashes — never file contents, never a diff, never a secret's value.
  • A discovered secret is stored as a salted hash. There is no field on a finding capable of holding the credential, which is deliberate: the plaintext never reaches storage, a log, or an event.
  • Nothing is written unless you grant it. A GitHub App granted read access scans and never writes. Pull-request checks and fix pull requests use the write permissions you choose to grant that App (see Pull-request checks), and each write mints a token for only the permissions that one action needs. GitLab and Bitbucket connections never write.

Turning it on

Two things are required: the connection needs to be able to read repository contents, and you need a code_scanning policy that says which repositories to scan.

Code scanning works with GitHub, GitLab (GitLab.com and self-managed) and Bitbucket Cloud connections. For GitLab and Bitbucket, read access is part of connecting — the token's required scopes already include cloning — so skip step 1 and see GitLab setup or Bitbucket Cloud setup. A GitLab project in a subgroup is named by its full path, e.g. acme/platform/api.

Before either, you need:

  • the organization subscribed to ext-cloud-security — this is a separate gate from permissions, and without it every code route answers 403 whatever the API key can do;
  • an API key with cloudsec.get, plus cloudsec.set to write a policy or push a document. See API Keys;
  • the current LimaCharlie CLI; and
  • Docker, for local scans only. Pushing an existing document does not need it.

Authenticate interactively with limacharlie auth login, or set LC_OID and LC_API_KEY in CI.

The policy can also be edited in the console under Cloud Security → Policies → Code scanning: turn on Enable code scanning, add the repository's <owner>/<repository> under Include, and select at least one engine.

GitHub: let LimaCharlie create the App

The quickest way to connect GitHub is to have the console create the GitHub App for you. It sets up everything on this page that would otherwise be manual: repository read access, the webhook that drives push rescans and pull-request checks, and its D&R rules. Nobody adds a webhook to a repository.

The choice is only available when your LimaCharlie user or API key holds cloudsec.get, cloudsec.set, secret.get, secret.set, cloudsensor.get, cloudsensor.set, ikey.list and ikey.set: it reads and writes the App's private key and webhook secret, the connection's webhook adapter, its installation key and the connection itself. Without all of them the wizard opens on I already have a GitHub App and names the permissions that are missing. Installing the webhook rules also needs dr.list and dr.set, but the connection completes without them.

  1. Cloud Security → Settings → Providers → Add provider → GitHub, and choose Create a GitHub App for me (recommended). The other choice, I already have a GitHub App, connects an App you made yourself, as described in GitHub provider setup.
  2. Name the connection, then enter the GitHub organization slug and a name for the App. The App name defaults to LimaCharlie <organization> and must be unique on GitHub.
  3. Choose the options:
    • Allow AutoFix to open fix pull requests raises Contents from read-only to Read and write. It is off by default. Leave it off unless you want AutoFix pull requests.
    • Turn on code scanning with pull-request checks is offered, and ticked by default, only when the organization has no code_scanning policy yet. See the default policy below.
  4. Continue on GitHub. GitHub shows the App it is about to create in your organization. An owner of the GitHub organization reviews it and creates it. GitHub then asks where to install it: choose All repositories.
  5. GitHub sends you back to LimaCharlie, which checks the installation and saves the connection.

The App belongs to your GitHub organization, not to LimaCharlie. It is private (installable only on your organization), you can edit or delete it like any other App, and its private key and webhook secret go straight into your organization's secrets. They are never stored in the browser.

What the App is created with:

Setting Value Why
Repository: Actions, Administration, Code scanning alerts, Dependabot alerts, Metadata, Secret scanning alerts, Secrets, Webhooks Read-only Collection: the inventory and posture described in GitHub provider setup, and GitHub's own alerts
Organization: Administration, Members, Secrets, Webhooks Read-only Collection, as above
Repository: Contents Read-only, or Read and write with AutoFix Read to clone for scanning; write only to push AutoFix branches
Repository: Checks, Pull requests Read and write Pull-request checks and comments. Nothing is published until the code_scanning policy turns them on
Webhook Active, subscribed to Push and Pull request Push rescans and pull-request checks
Webhook URL and secret this connection's own LimaCharlie webhook, with a secret GitHub generates Deliveries are signed, and LimaCharlie refuses any delivery whose signature does not match

LimaCharlie also creates the connection's webhook adapter and, when your API permissions include dr.list and dr.set, installs the three webhook rules if they are not already installed.

When it does not finish in one go

  • An organization member who is not an owner can only request the installation. An owner has to approve it on GitHub, and then you add the connection with I already have a GitHub App. The page shows the App ID and credentials to enter.
  • GitHub gives you an hour to create the App after you leave LimaCharlie. After that, start again from Settings.
  • Do not close or reload the page until the App's private key is saved. GitHub hands the key over once, and until it is saved it exists only in that page. If saving it fails, keep the page open and choose Retry. If the page is closed or reloaded before the key is saved, or while GitHub is still handing the App over, the setup cannot be finished there: the App may already exist on GitHub, so either generate a new private key for it and connect it with I already have a GitHub App, or delete it and start again.
  • Once the key is saved, the setup can be resumed for up to 7 days in the same browser session: if a later step fails, the page says which one, and Retry, or coming back to the page, continues from there.
  • If the webhook or the rules could not be set, the connection is still saved: use the GitHub webhooks section or Install the webhook rules on the Code page afterwards.
  • If LimaCharlie could not confirm that the App signs deliveries with the secret LimaCharlie verifies, the setup ends by asking you to use Sync webhook secret on the Code page. See Secret not synced.

The default policy

If you tick Turn on code scanning with pull-request checks, LimaCharlie creates one code_scanning policy only if the organization still has none when the connection is saved. An existing policy is never changed. The policy it creates:

  • scans every repository the App can see (no include list), daily;
  • runs the dependency, secrets, infrastructure-as-code, static-analysis and license engines;
  • has pr_checks: true and pr_comments: false. Comments notify everyone subscribed to a pull request, so they are left for you to turn on.

Edit it under Cloud Security → Policies → Code scanning like any other policy.

The App sees every repository; the policy decides what is scanned

Installing the App on all repositories means GitHub sends LimaCharlie a delivery for every push and pull request in the organization. That is safe: the code_scanning policy's repos.include and repos.exclude, pr_checks and pr_comments still decide which repositories are scanned and which pull requests get a check or a comment. A delivery for a repository outside the policy is declined, and nothing is written to it.

1. GitHub: grant Contents: Read-only

An App created by LimaCharlie already has this. This step is for an App you created yourself.

The connector's baseline permissions inventory repositories but cannot read them. Add the Contents → Read-only Repository permission to your GitHub App and approve the permission change on the organization's installation page — GitHub requires an owner to accept a permission increase on an existing installation.

See GitHub provider setup for the full permission table, including the two optional alert permissions that let LimaCharlie ingest GitHub's own Dependabot, code-scanning and secret-scanning alerts and deduplicate them against its own findings.

Without it, scans fail with a clear error, not silently

A repository selected for scanning while the App lacks Contents reports github_app_missing_contents_permission on the code scan status, with the remediation attached. Nothing is scanned and no findings are invented.

2. Create the policy

Code scanning is opt-in. With no code_scanning policy the lane never runs — it does not default to scanning everything you connected.

# code-policy.yaml
policy_type: code_scanning
enabled: true
repos:
  include: ["acme/api-*", "acme/payments"]
  exclude: ["acme/api-archive"]
scanners:
  sca: true
  secrets: true
  secrets_history: true
  iac: true
  sast: true
  images: true
  licenses: true
severity_floor: LOW          # LOW means no floor: record everything
schedule: daily
image_sources: ["dockerfile"]
autofix_registry_access: true
limacharlie hive set --hive-name cloudsec_policy --key code-scanning \
    --input-file code-policy.yaml --enabled
Field Meaning
enabled Required. false parks the policy — kept and editable, but nothing is scanned.
repos.include Globs matched case-insensitively against owner/name and the bare name. Empty means every repository the provider can see — write it explicitly unless that is genuinely what you want.
repos.exclude Always beats include.
scanners Each engine is an explicit boolean; at least one must be true. There is no implicit "all on", and no engine implies another. sast and secrets_history are off unless you turn them on.
severity_floor CRITICALHIGHMEDIUM. Findings below the floor are not recorded at all — see the note below. LOW, INFO and an empty value all mean no floor.
schedule daily (the default), weekly, or manual.
sast_ruleset Which static-analysis pack runs. Leave it empty for the curated default.
image_sources A list. Where the image lane looks: dockerfile (the default) or workloads. registries is accepted but does nothing — see Not yet available.
autofix_registry_access Whether dependency AutoFix may make read-only package-registry metadata requests. Omitted means true; set it to false to keep AutoFix on the ordinary restricted scan lane. See Dependency AutoFix.
pr_checks, pr_comments, gating.fail_on The pull-request lane — see below.

severity_floor drops, it does not hide

A finding under the floor is never synthesized, so it is absent rather than filtered — it has no row, no history and no triage state. Lower the floor later and it comes back as a new finding, with first_seen set to the scan that recreated it, not to when the code was first vulnerable. Raising the floor closes what is already open with closed_reason: below_severity_floor.

The ladder starts at MEDIUM: LOW, INFO and an empty value are all "no floor" and drop nothing. The floor composes across records as the lowest of the records that select a repository, so an added record can widen coverage but never narrow it.

If you want everything recorded and only your view narrowed, leave the floor empty and filter the worklist by severity instead.

An org may hold several code_scanning records and they compose, so you can scan a small set of sensitive repositories daily with every engine on, and the rest weekly with a narrower set. Composition takes the union of the enabled engines, the lowest severity_floor and the most frequent schedule, so a record can generally add coverage. autofix_registry_access is deliberately stricter: an explicit false in any matching record wins over true or an omitted field. This prevents a broader policy from silently widening registry access for a repository whose narrower policy disabled it.

The glob dialect is the shared Cloud Security one

*, ?, […], {a,b}, and a leading ! for negation within a list. Write negations in include; a ! in exclude reads as "exclude everything that is not this", which cancels your include list.

secrets_history is its own switch rather than a modifier on secrets because it is the only thing that forces a full clone — on a large monorepo that is the difference between a fast scan and one that hits the clone cap.

image_sources: ["registries"] is accepted and not enumerated: a policy asking for it gets the referenced images only. Registry enumeration is not implemented.

Force a rescan

Bump the provider record's sync_now field to a new value, or ask for one repository directly:

limacharlie cloudsec code rescan acme/payments

The next pass picks up the repositories the policy selects, respecting the per-repository debounce.

Reading the results

CLI version

The cloudsec code commands were added after CLI 5.6.2 and require the first limacharlie release newer than it. An older CLI has no code command group at all and answers No such command, so upgrading is not enough on its own until that release is out. Check with:

limacharlie --version
limacharlie cloudsec code scan --help
# Repositories, their scan state and open-finding rollups.
limacharlie cloudsec code repos
limacharlie cloudsec code repos --with-findings --all
limacharlie cloudsec code repos -q payments

# Has the lane run, and what happened last time?
limacharlie cloudsec code status

# The software bill of materials for one repository.
limacharlie cloudsec code sbom --repo acme/payments -o payments-sbom.json.gz

code repos returns a repo key (<owner>/<name>) that the other commands and the --repo findings filter take. code status is the authoritative answer to "did it run" — an empty code list means the lane has never run in this org, which is not the same as the lane being off.

The findings themselves are ordinary findings:

limacharlie cloudsec finding list --repo acme/payments --class vulnerability
limacharlie cloudsec finding list --class secret --severity CRITICAL

# Which producer found it: the scan LimaCharlie ran, or a document you pushed.
limacharlie cloudsec finding list --repo acme/payments --source hosted
limacharlie cloudsec finding list --repo acme/payments --source ingest

Each finding also carries the producing scanner on its code evidence as detected_via (lc-code-scanner for the hosted scan, lc-code-scanner-byo for a pushed local scan, sarif-ingest/cyclonedx-ingest for a converted document). Filter with --source rather than folding detected_via yourself: --source is applied inside the query, so the counts describe the estate, while folding a page at a time makes every count a count of one page. It takes hosted, ingest, other (the source control's own detectors), none (no code provenance at all) or both.

A repository row reports its own provenance the same way, as hosted, ingest or both.

See the CLI reference and the API reference for the full surface.

Reading scan_status

Each repository carries a scan_status of scanned, partial or unknown with a machine-readable scan_status_reason. unknown with repo_not_scanned means nothing has scanned it yet — check that it is inside an enabled policy's include list, then let the next pass reach it. partial means a cap or an unavailable engine truncated the scan, so the finding list is a lower bound and must never be read as clean.

code status remains the authoritative answer about the run itself; follow it when the two disagree.

An older repo_scan_props_not_projected reason was a bug, since fixed. If you still see that spelling you are looking at a stale record.

The bill of materials

Every scanned repository gets a CycloneDX SBOM, produced during the scan and retrievable on request through a short-lived signed link. It is not stored as inventory rows — a 2,000-package repository must not add 2,000 rows to your estate — so the graph carries the vulnerability matches while the SBOM stays a downloadable artifact.

A repository that has not been scanned yet reports sbom_not_generated_yet rather than an empty document.

Rescanning on every push

A daily schedule means a fix merged at 09:00 is still an open finding at 17:00. A push webhook closes that gap: one repository, rescanned within minutes, without waiting for or restarting the estate-wide pass.

GitHub App webhook ──push──▶ LimaCharlie webhook adapter ──▶ D&R rule ──▶ rescan

Pushes are coalesced: the first push arms a 10-minute window, everything inside it collapses into one scan of the head of the burst. Every gate the schedule applies still applies — the repository must be in your collected inventory, not archived, and selected by an enabled policy — and only the default branch is scanned, so a push to a feature branch is declined with a reason rather than scanned silently.

A push-triggered scan does not update the estate-wide code status row. "Did my push get scanned" is answered by that repository's row in limacharlie cloudsec code repos.

How the webhook is set up

A GitHub App has one webhook, configured on the App itself, and it receives the events it is subscribed to from every repository the App is installed on. So the code lane needs one webhook per GitHub connection, never one per repository. Each connection has its own:

  • webhook adapter, the cloud_sensor record github-code-webhook-<connection>, where <connection> is the connection's name, lowercased (shortened with a hash suffix when the name is long or has characters a URL cannot carry). Every one of these adapters reports the hostname github-code-webhook, which is what the three D&R rules match on, so the same rules serve every connection;
  • signing secret, the secret github-code-webhook-<connection>, which holds the App's webhook secret. Each App signs with its own secret, which is why the adapters are not shared.

Because the adapter name is lowercased, two connections whose names differ only in letter case (for example Acme and acme) would share one adapter and one signing secret. The Add provider wizard refuses such a name for a new GitHub connection. Connections that already exist with such names are marked Name conflict on the Code page (see below).

The adapters use an installation key with the description cloudsec-github-code-webhook, which is created if it does not exist.

Nothing needs doing by hand for a connection created with Create a GitHub App for me: the App is created with an active webhook, subscribed to Push and Pull request, already pointing at its adapter. For a connection whose App you created yourself, the Code page shows what is missing and walks you through it, below.

The code_scanning policy stays the only scope control. The webhook delivers events for every repository the App is installed on, and a repository the policy does not select is not scanned or checked.

Webhook status on the Code page

The Code page's GitHub webhooks section lists every GitHub connection and whether its App delivers push and pull-request events to this organization. LimaCharlie reads this from GitHub with the App's own credentials.

  • A status that is not a working webhook is re-checked within about 30 seconds, so a fix made on GitHub shows within about 30 seconds (the page says up to a minute). Choose Check again to refresh it.
  • A working webhook (Receiving events) is re-checked every 5 minutes, so a webhook that breaks on GitHub can take up to 5 minutes to show.
  • Fix webhook, Sync webhook secret and Re-sync webhook secret show the new status as soon as they succeed, because a successful change clears the cached one.

GitHub's API can change an App's existing webhook, but it cannot create a webhook, activate one, or change which events an App is subscribed to. Those steps are done by an owner of the GitHub organization in the App's settings on GitHub, and the page tells you when they are needed.

Status What it means What to do
Receiving events The App's webhook points at this organization's LimaCharlie webhook, and the App is subscribed to push and pull request events. Nothing.
Not connected: the App has no active webhook The App has no webhook URL, or its webhook is not Active. Set up webhook, then add the webhook on GitHub (see below).
Not connected: the webhook points to another address The webhook is active, but its URL is not this organization's LimaCharlie webhook for code scanning. Also shown when the URL is right but the webhook does not send JSON or does not verify TLS, which Fix webhook corrects. Fix webhook. It asks for confirmation first (see below).
Missing events The URL is right, but the App is not subscribed to push and/or pull request events. An owner ticks Push and Pull request under the App's Permissions & events on GitHub.
Not verified GitHub could not be read, for example because it timed out, rate-limited the App, or rejected its credentials. Nothing to fix here. It is checked again automatically; if it persists, check the App's private key.
Secret not synced The App may sign deliveries with a different secret than LimaCharlie verifies, so they would be rejected. Shown in place of the status above, even Receiving events, because GitHub never reveals an App's secret, so the status check cannot see a mismatch. Sync webhook secret (see below).
Name conflict This connection's name differs from another connection's only by letter case, so both would share one webhook adapter and signing secret. No webhook actions are offered for these connections. Delete one of the connections and add it again under a distinct name. A connection cannot be renamed in place.
Needs attention GitHub reported a webhook problem this page does not recognise. The row shows the detail LimaCharlie received. Follow the detail shown.

The section shows a warning when any connection is Not connected, Missing events, Secret not synced or Name conflict: for those connections, push rescans and pull-request checks may not run.

Set up webhook

For an App with no active webhook, Set up webhook prepares everything on the LimaCharlie side: it creates the connection's webhook adapter and signing secret if they are missing (reusing them if they exist) and installs any of the three D&R rules that are missing when you hold dr.list and dr.set. It then shows the Payload URL and Secret to paste into GitHub. It needs secret.get, secret.set, cloudsensor.get, cloudsensor.set, ikey.list and ikey.set.

An owner of the GitHub organization then opens Organization → Settings → Developer settings → GitHub Apps → the App and:

  1. on General, under Webhook, ticks Active;
  2. pastes the Payload URL into Webhook URL;
  3. sets Content type to application/json;
  4. pastes the Secret into Webhook secret;
  5. keeps SSL verification enabled;
  6. on Permissions & events, under Subscribe to events, ticks Push and Pull request. GitHub only offers Push once the App has the Contents permission, and Pull request once it has Pull requests;
  7. saves the changes, then chooses Check again on the Code page.

The Secret is shown only in that panel, and only to someone who holds the permissions above.

Fix webhook

For an App whose active webhook points somewhere else, Fix webhook prepares the adapter, secret and rules the same way, then updates the App's webhook through GitHub's API to point at the adapter, with content type JSON, TLS verification on and the signing secret. It needs cloudsec.set in addition to the permissions of Set up webhook; without them the page shows the status and names what is missing.

  • Confirmation. A GitHub App has only one webhook. Before replacing it, the page asks you to confirm with Replace webhook: whatever receives those deliveries today stops receiving them, and the previous secret cannot be restored.
  • The App is used by another LimaCharlie organization. If the App's webhook already delivers to a different LimaCharlie organization, for example one GitHub App reused across several organizations, Fix webhook refuses and changes nothing. Overwriting it would silently stop that organization's push rescans and pull-request checks. Connect this organization with its own GitHub App.
  • The webhook was deactivated in the meantime. If GitHub reports that the App no longer has an active webhook, nothing is changed and the page switches to the Set up webhook steps above.

Sync webhook secret

Secret not synced is shown when LimaCharlie holds a signing secret that the App may not be using: for example, when a setup with Create a GitHub App for me could not set LimaCharlie's secret on the App. The flag is kept only in the browser that ran that setup; another browser shows the detected status instead. It clears when a sync succeeds.

Sync webhook secret sets the webhook URL and signing secret LimaCharlie already holds on the GitHub App, through GitHub's API, so the App signs deliveries with the secret LimaCharlie verifies. Nothing is changed on the LimaCharlie side. It asks for confirmation, Re-sync the webhook secret of …?, unless the connection is already Receiving events, because the App's current secret is replaced and cannot be restored.

Re-sync webhook secret does the same for a connection that is Receiving events, after the same confirmation. Use it when GitHub's Recent Deliveries show deliveries rejected with 401, which means the App and LimaCharlie hold different secrets.

Both need cloudsec.set, secret.get and cloudsensor.get; without them the page names what is missing. Both need the connection's webhook adapter and secret to exist already, and an App with an active webhook: they are not offered while the status is Not connected. For the API route they use, see The API.

An App created with Create a GitHub App for me has an active webhook subscribed to both events from the start.

The API

The console's Fix webhook calls a public route you can call yourself, for example from automation. Like Fix webhook, it can only update an App's active webhook: it cannot create or activate one.

POST https://api.limacharlie.io/v1/cloudsec/{oid}/code/webhook

It requires cloudsec.set. It points an existing GitHub connection's App webhook at a webhook adapter you have already created (the adapter and its secret are not created by this route):

{
  "connection": "<the cloudsec_provider record name of a GitHub connection>",
  "url": "https://<hooks domain>/<oid>/github-code-webhook-<connection>/<url secret>",
  "secret": "<the adapter's signing secret>"
}
  • url must be exactly this organization's hook URL for a per-connection github-code-webhook-<name> adapter: https, this organization's hooks domain (the url.hooks value of GET /v1/orgs/{oid}/url), this organization's ID as the first path segment, three path segments, and no credentials, port, query, fragment or percent-encoding. Anything else is refused, so the route cannot point your App at an address outside LimaCharlie. The URL of an adapter named just github-code-webhook, from an older version of the manual recipe, is refused with invalid_url: create a per-connection adapter first (see Manual setup), or set that App's webhook by hand on GitHub.
  • secret is 20 to 256 characters with no whitespace, and must be the value the adapter's signature_secret verifies.

The App's webhook is updated with the App's own credentials, and the response is the connection's re-checked webhook status:

{"state": "available", "reason": "", "missing_events": [], "detail": ""}

state is available, unavailable or unknown. reason is empty when available, otherwise webhook_not_configured, webhook_points_elsewhere, missing_events or verification_unavailable (the statuses in the table above). missing_events lists the events still to tick. A successful call can therefore still answer missing_events: the URL and secret were set, and the event subscription is the manual step above. Neither the URL nor either secret is ever returned.

A refusal carries a machine-readable reason at the top level of the error body:

HTTP reason Meaning
400 invalid_connection, invalid_url, invalid_secret The body is malformed, or the URL is not this organization's hook URL.
400 connection_not_found, provider_not_github The connection does not exist in this organization, or is not a GitHub connection.
400 credential_unavailable The connection's App credentials are missing or cannot be read.
400 webhook_not_active The App has no active webhook, and GitHub's API cannot create one. Nothing was changed. An owner must enable it in the App's settings (the Set up webhook steps); retrying before that will not help.
400 webhook_in_use_by_other_org The App's webhook delivers to another LimaCharlie organization. Nothing was changed; retrying will not help. Use a separate GitHub App.
502 github_unavailable GitHub could not be reached or rate-limited the request. Retry later.
502 github_credential_rejected GitHub rejected the App's credentials. Check the App's private key.
502 github_rejected GitHub refused the update, with GitHub's message.
503 host_unavailable A temporary failure inside LimaCharlie. Safe to retry.
504 timeout The request took too long. The change may already be applied: re-read the status before retrying.
500 hooks_domain_unavailable This organization's hooks domain could not be determined. Nothing was changed.

The current status of every connection is also on GET /v1/cloudsec/{oid}/code/capabilities, as the webhook object of each GitHub connection.

Manual setup (advanced)

Use this only when the console path does not suit you: an organization managed entirely from the CLI or infrastructure-as-code, or a forked rule. It produces the same result as Set up webhook or Fix webhook.

The Code page can install the D&R rules for you

The Code page's Overview tab has a Webhook automation panel whose Install the webhook rules action writes all three D&R rules below in one step (the push rescan and both pull-request rules), with a per-rule status badge and a Remove action to take them back out. The limacharlie hive set steps below remain the way to install one rule at a time, read exactly what you are installing before you do, or fork a rule with a change of your own.

  1. Create a webhook adapter for the connection, a cloud_sensor record with sensor_type: webhook, named github-code-webhook-<connection>. It carries two secrets, both long and random: secret goes in the hook URL, and signature_secret is the key GitHub signs every delivery with (the webhook's Secret in GitHub). With signature_scheme: hmac-sha256, LimaCharlie refuses any delivery whose X-Hub-Signature-256 signature does not match, and drops a delivery it has already accepted, so knowing the URL is not enough to trigger a scan. See the webhook adapter tutorial for the record shape. The hostname must stay exactly github-code-webhook, since the rules match on it.
OID=<your organization id>
CONNECTION=<the connection name, lowercased>
# An installation key for the org: `limacharlie --oid "$OID" installation-key list`,
# or Sensors -> Installation Keys in the web app.
INSTALLATION_KEY=<an installation key for that org>
SECRET=$(python3 -c "import secrets;print(secrets.token_urlsafe(32))")
SIGNATURE_SECRET=$(python3 -c "import secrets;print(secrets.token_urlsafe(32))")
cat > hook.json <<JSON
{
  "sensor_type": "webhook",
  "webhook": {
    "secret": "$SECRET",
    "signature_scheme": "hmac-sha256",
    "signature_header": "X-Hub-Signature-256",
    "signature_secret": "$SIGNATURE_SECRET",
    "client_options": {
      "hostname": "github-code-webhook",
      "identity": {"oid": "$OID", "installation_key": "$INSTALLATION_KEY"},
      "platform": "json",
      "sensor_seed_key": "github-code-webhook-$CONNECTION"
    }
  }
}
JSON
limacharlie hive set --hive-name cloud_sensor --key "github-code-webhook-$CONNECTION" \
    --input-file hook.json --enabled

The hook URL is https://<your org's hook domain>/<oid>/github-code-webhook-<connection>/<secret>; the domain is the url.hooks value of GET /v1/orgs/{oid}/url.

signature_secret can also reference a stored secret instead of holding the value ("signature_secret": "hive://secret/github-code-webhook-<connection>").

An adapter from an older version of this recipe, named just github-code-webhook, keeps working for a single connection, and its URL is reported as Receiving events. Set up webhook, Fix webhook and the API only work with the per-connection name: the API refuses a legacy adapter's URL with invalid_url. To use them, create the per-connection adapter first, or keep maintaining the legacy App webhook by hand on GitHub.

  1. Point the GitHub App's webhook at that URL. On GitHub, edit the App: tick Active, set the webhook URL, content type application/json and Secret $SIGNATURE_SECRET, keep SSL verification on, and subscribe to Push and Pull request. If the App already has an active webhook, you can instead call the API with the URL and $SIGNATURE_SECRET; the API cannot create or activate a webhook, or subscribe events. A delivery without a valid signature is refused with 401, and the rules only fire on deliveries whose signature was verified. GitHub's Redeliver of a delivery that was already accepted within the last 24 hours is acknowledged but not processed again.

This is the only hook the code lane needs, for every repository the App is installed on. Do not add webhooks to individual repositories.

  1. Install the D&R rule. It ships as a recipe rather than being installed for you, so you can read what it does and fork it. Save the YAML below as push-rescan.yaml and write it into the dr-general hive under the name cloudsec-code-push-rescan:
limacharlie hive set --hive-name dr-general \
    --key cloudsec-code-push-rescan --input-file push-rescan.yaml --enabled

event/__lc_signature_verified is set only on a delivery whose signature LimaCharlie verified; any other adapter, or a body that includes the field itself, never carries it.

detect:
  event: json
  op: and
  rules:
    - op: is
      path: routing/hostname
      value: github-code-webhook
    - op: is
      path: event/__lc_signature_verified
      value: true
    - op: exists
      path: event/head_commit/id
    - op: exists
      path: event/repository/full_name
    - op: starts with
      path: event/ref
      value: refs/heads/
    - op: is
      path: event/deleted
      value: false
respond:
  - action: extension request
    extension name: ext-cloud-security
    extension action: code_scan_now
    extension request:
      repo: '{{ .event.repository.full_name }}'
      ref: '{{ .event.ref }}'

Pull-request checks and merge gating

A daily scan says what a repository contains. A pull-request check says what a change introduces, on the pull request, before the merge.

It has three parts, and all three are needed: permissions the App grants, the policy switch that turns the feature on, and the webhook plus relay rule that tells LimaCharlie a pull request happened. The last one is the step most easily missed — with pr_checks: true and no pull_request webhook, nothing ever fires and no error is raised anywhere.

For an App created with Create a GitHub App for me, the permissions and the webhook are already in place, so the policy switch is the only part left. If you also let it create the default policy, checks are already on. For any other App, the GitHub webhooks status on the Code page tells you whether the webhook part is done (see Webhook status on the Code page).

Pull-request checks, pull-request comments and AutoFix are GitHub-only. They are the one part of the lane that writes to your organization, and they use write permissions granted to the connection's own GitHub App — nothing is written until you grant them. What each feature needs:

Feature App permissions Policy switch Trigger
Pull-request checks Checks: Read and write, Pull requests: Read and write pr_checks: true the pull_request webhook + cloudsec-code-pr-check
Pull-request comments Checks: Read and write, Pull requests: Read and write pr_comments: true the same rule
Dependency AutoFix pull requests Contents: Read and write, Pull requests: Read and write an enabled code_scanning policy each fix is requested per finding

Granting a permission only makes a feature available; the policy switch is what turns it on. The App's permissions are also not what any single call holds: the check writer asks for checks + pull_requests + metadata and deliberately not contents, so publishing a status can never carry the ability to rewrite your source, and the AutoFix writer asks for contents + pull_requests + metadata and not checks.

The Code page reports, per connection, which of these the App can actually do — read from the installation's granted permissions — and names the permission to add when one is missing. A write the App cannot perform is refused with write_app_not_configured, naming the missing permission.

Grant the permissions

An App created by LimaCharlie already has Checks and Pull requests: Read and write, and Contents: Read and write if you allowed AutoFix when creating it. To add them to any other App, or to allow AutoFix later: edit the App under Organization → Settings → Developer settings → GitHub Apps → your App → Permissions & events, set the permissions above, and approve the permission request on the organization's installation page — GitHub requires an owner to accept a permission increase on an existing installation.

A separate write App, if you prefer

Keeping the write permissions on a second App is still supported: create an App with the permissions above, install it on the repositories you want checked, and name it on the provider record. When a record names one, it is used for every write instead of the connection App.

limacharlie secret set --key github-code-actions-key \
    --value "$(python3 -c 'import json;print(json.dumps({"private_key":open("code-actions.private-key.pem").read()}))')" \
    --enabled
provider_type: github
github_org: "acme"
github_app_id: "1234567"
github_installation_id: "89012345"
credentials: hive://secret/github-app-key
github_actions_app_id: "7654321"
github_actions_installation_id: "54321098"
actions_credentials: hive://secret/github-code-actions-key

The three fields are set together or not at all, and the record is refused if the write App is the same App, or points at the same secret, as the read connection.

The webhook stays on the connection's App, not the write App: that is the App Fix webhook updates and the App whose webhook status the Code page shows.

Turn it on

# In the code_scanning policy.
pr_checks: true
pr_comments: true
gating:
  fail_on: HIGH

Fire the check from the webhook

The policy switch and the permissions make checks possible. What makes one appear on a pull request is the second recipe rule — without it nothing reacts to a pull request being opened, and the feature is silently inert.

When the connection's webhook status is Receiving events and the rules are installed, this is already done. An App created for you is subscribed to pull requests from the start, and automatic setup, Set up webhook and Fix webhook install all three rules when you hold dr.list and dr.set. The steps below are the manual equivalent, and the reference for what the rules do.

A pull-request check needs the webhook, not just pr_checks: true

Turning pr_checks on does not make checks appear by itself. The webhook must send pull_request events, and cloudsec-code-pr-check must be installed. The Code page reports the first as Missing events when it is not true, and Is it firing? is how you confirm the rest.

  1. Make sure the App is subscribed to Pull request, on the same webhook as push rescans. It is the same hook, the same URL and the same signing secret — a second adapter would only mean a second secret to get wrong. GitHub has no API for event subscriptions, so this is ticked on the App's Permissions & events page.

  2. Install cloudsec-code-pr-check into dr-general, the same way as the push rule:

Save the YAML below as pr-check.yaml, then:

limacharlie hive set --hive-name dr-general \
    --key cloudsec-code-pr-check --input-file pr-check.yaml --enabled

As with the push rule, event/__lc_signature_verified is only set on a delivery whose signature LimaCharlie verified.

detect:
  event: json
  op: and
  rules:
    - op: is
      path: routing/hostname
      value: github-code-webhook
    - op: is
      path: event/__lc_signature_verified
      value: true
    - op: exists
      path: event/pull_request/number
    - op: exists
      path: event/repository/full_name
    - op: exists
      path: event/pull_request/head/sha
    - op: exists
      path: event/pull_request/base/sha
    - op: or
      rules:
        - op: is
          path: event/action
          value: opened
        - op: is
          path: event/action
          value: synchronize
        - op: is
          path: event/action
          value: reopened
respond:
  - action: extension request
    extension name: ext-cloud-security
    extension action: code_pr_check
    extension request:
      repo: '{{ .event.repository.full_name }}'
      pr: event.pull_request.number
      base_sha: '{{ .event.pull_request.base.sha }}'
      head_sha: '{{ .event.pull_request.head.sha }}'
      base_ref: '{{ .event.pull_request.base.ref }}'
      head_ref: '{{ .event.pull_request.head.ref }}'
      action: '{{ .event.action }}'

pr is a path, and the other fields are templates

This is not a typo in the rule. A D&R extension request value written as {{ ... }} is rendered to text, while a value written as a bare path keeps the payload's own type. The pull-request number has to arrive as a number, so it is a path; every other field is text anyway, so every other field is a template.

Writing pr: '{{ .event.pull_request.number }}' looks right and fails silently: the request is rejected before it reaches Cloud Security, with invalid value for pr: not an integer, a string, and no check appears on the pull request. If checks stop appearing after you edit the rule, this is the first thing to look at.

Everything else that happens on a pull request — labels, assignments, reviews, closing — leaves the code under review untouched, so the rule ignores it and a busy repository's chatter never becomes scan traffic. Those actions are also the only ones the service accepts, together with the retargeting edited covered below, so widening the rule alone changes nothing.

Nothing in the rule is trusted. LimaCharlie re-reads the pull request from GitHub and uses GitHub's commits, refuses one that is not open or whose head does not match, and refuses a base equal to its head. Several pushes in quick succession collapse into one check, on the newest head commit.

Draft pull requests are checked

A draft gets a check like any other pull request. This is deliberate: the service reacts to opened, synchronize, reopened and a retargeting edited, and not to ready_for_review, so a rule that skipped drafts would leave a draft that is marked ready — with no further push — with no check at all. If that check is a required one, the pull request could never be merged.

To exclude drafts anyway, fork the rule and add one condition:

   - op: is
     path: event/pull_request/draft
     value: false

Then be aware of the trade-off above, and push a commit after marking a pull request ready for review so its check runs.

Keeping the check honest when the base branch changes

Pointing a pull request at a different base changes what it introduces without pushing anything. GitHub reports that as an edited action, not a synchronize — so without this rule the check already sitting on the head commit stays exactly as it was, green or red, measured against a base that is no longer the base. If you made the check required, that is a merge gate satisfied by a scan of a diff that no longer exists.

Automatic setup, Set up webhook and Fix webhook install this rule too (with dr.list and dr.set). To install it by hand, add cloudsec-code-pr-retarget alongside the rule above — same webhook, same adapter, same signing secret. Save the YAML below as pr-retarget.yaml, then:

limacharlie hive set --hive-name dr-general \
    --key cloudsec-code-pr-retarget --input-file pr-retarget.yaml --enabled

The base actually having moved is what makes matching on event/changes/base/sha/from (not just forwarding it) free for a title or body edit: only a base change carries that field. pr is a path for the same reason as the check rule above, and prev_base_sha — the base the pull request moved away from — is a template, safely, because this rule only fires when the event has that field at all.

detect:
  event: json
  op: and
  rules:
    - op: is
      path: routing/hostname
      value: github-code-webhook
    - op: is
      path: event/__lc_signature_verified
      value: true
    - op: is
      path: event/action
      value: edited
    - op: exists
      path: event/changes/base/sha/from
    - op: exists
      path: event/pull_request/number
    - op: exists
      path: event/repository/full_name
    - op: exists
      path: event/pull_request/head/sha
    - op: exists
      path: event/pull_request/base/sha
respond:
  - action: extension request
    extension name: ext-cloud-security
    extension action: code_pr_check
    extension request:
      repo: '{{ .event.repository.full_name }}'
      pr: event.pull_request.number
      base_sha: '{{ .event.pull_request.base.sha }}'
      head_sha: '{{ .event.pull_request.head.sha }}'
      base_ref: '{{ .event.pull_request.base.ref }}'
      head_ref: '{{ .event.pull_request.head.ref }}'
      action: '{{ .event.action }}'
      prev_base_sha: '{{ .event.changes.base.sha.from }}'

A new check then runs against the new base, and that newer check run on the same head commit supersedes the old one. Nothing else changes: the pull request is still re-read from GitHub, and the base that is scanned is the one GitHub reports, not the one in the rule.

Why the retarget is its own rule, and why prev_base_sha belongs only on it

A rule has one response. A retarget has to send prev_base_sha, and the opened, synchronize and reopened events do not have that field, so the two cannot share a response — see the last paragraph of this warning.

GitHub also reports a base change, a title change and a description change with the same edited action. changes.base.sha.from is the field that tells them apart, which is why the retarget rule both matches on it and forwards it. Matching matters: without that condition the rule still fires on every title edit, and each of those requests is refused before it reaches Cloud Security.

Do not copy prev_base_sha onto the cloudsec-code-pr-check rule. A {{ ... }} template over a field the event does not have renders to the literal text <no value>, which is rejected — so that one line would stop every check in your organization, not just retargets. It is safe on the retarget rule precisely because that rule only fires when the field is there.

Is it firing?

In order, stopping at the first thing that is wrong:

  1. The webhook is connected. On the Code page, the connection's row under GitHub webhooks should read Receiving events, and the Webhook automation panel should show all three rules installed. Not connected is fixed with Set up webhook (no active webhook) or Fix webhook (it points elsewhere); Missing events needs an owner to tick Push and Pull request on the App (see Webhook status on the Code page). A change made on GitHub shows within about 30 seconds; choose Check again to refresh.
  2. GitHub delivered it. On GitHub, the App's settings have an Advanced tab with Recent Deliveries. A pull_request delivery should be there with a 200. A 401 means the signature did not verify — the App's webhook Secret and the adapter's signature_secret are not the same value. Use Re-sync webhook secret on the Code page (see Sync webhook secret). Otherwise paste the secret again on GitHub, or call the API with the adapter's URL and signing secret (per-connection adapters only). No delivery at all, for a repository you expected, usually means the App is not installed on that repository.
  3. The rule matched. Replay it over a short window around the delivery. --start and --end are Unix seconds, so keep the window to a few minutes: replay reads Insight and is billed on the volume it processes.
limacharlie replay run --name cloudsec-code-pr-check \
    --start 1750000000 --end 1750000600

For a base-branch change, replay cloudsec-code-pr-retarget instead. No match there most often means the edit was not a base change: a title or description edit carries no changes.base.

Replay does not send the extension request, it shows you what the rule would have sent — which is the fastest way to see whether pr came out as a number or as text. No match at all means a condition did not hold, most often the action, or a delivery whose signature was not verified (a rule that also matches nothing for a push points at the signature rather than at this rule). 4. The request was accepted. A rule that matched but sent a malformed field is reported against the extension rather than on the pull request — see the warning about pr above, whose symptom is invalid value for pr: not an integer, a string. 5. The check ran. The repository's row in limacharlie cloudsec code repos is about scheduled scans, not pull requests; the pull request's Checks tab is where a pull-request check appears. Expect one named LimaCharlie Code Security, in_progress within seconds and completed within a few minutes. 6. It was refused. A pull-request check that is declined before it starts leaves nothing behind — no check run, and no message on the pull request. If everything above looks right and no check appears, work through the causes, which are the only ones:

  • the policy switch is off for that repository (pr_checks, and the repository has to be selected by an enabled code_scanning record);
  • the App cannot publish — grant Checks: Read and write and Pull requests: Read and write, and accept them on the organization's installation page;
  • the repository is not in the collected inventory, or is archived;
  • the connection has spent its daily source-control write budget;
  • for a base-branch change: the new base is the same commit as the old one, so what the pull request introduces did not change and there is nothing to re-check.

What the check says

Both commits are scanned and the two results are diffed by identity, never by line number, so moving a finding within a file does not report it as introduced.

Outcome Conclusion
nothing introduced, both scans complete success — the only green
findings introduced, none at or above the gate neutral — it reports without blocking, and does not claim the pull request is clean
something at or above gating.fail_on failure
either scan hit a limit never success — a truncated scan has not looked everywhere
the scan could not run neutral, titled "could not complete" — our outage does not block your merge

gating.fail_on is CRITICAL, HIGH, MEDIUM, LOW or NONE, and absent means NONE: turning pr_checks on never silently starts failing merges. INFO is deliberately not a value.

The check carries a summary with the full counts and up to 50 annotations (GitHub's own per-request cap). The summary always states the full count, so a pull request introducing two hundred findings is never described by the fifty that fit, and only a finding that would block is annotated as a failure.

With pr_comments: true you also get one comment, edited in place on every later push, never a second one.

Dependency AutoFix pull requests

For a dependency finding with a published fixed version, the lane can open the pull request that raises it. It needs the same write App as above (with contents: write) and no extra policy switch: an enabled code_scanning record selecting the repository is the scope, and the write App being present is the opt-in.

The edit happens inside the sandbox, from the finding LimaCharlie's own scan produced — you cannot ask for an arbitrary package to be raised to an arbitrary string. Three resolvable findings are still refused, each with its reason: a package flagged malicious (the remediation is removal and credential rotation, not an upgrade), a vulnerability with no published fixed version, and an ecosystem with no editor.

Be aware of the lockfile behavior, because it decides whether the pull request is complete:

Ecosystem What is edited Is that the whole fix?
maven pom.xml <version>, or the property it references Yes — there is no lockfile
pip the pin in requirements.txt Yes — there is no lockfile. A --hash-pinned requirement, or a lock file from another Python tool, is refused with the reason
npm package.json, range operator preserved; with registry metadata access, the corresponding package-lock.json entry Yes by default. AutoFix uses the registry's published version, resolved tarball URL and integrity metadata to rewrite the lockfile entry alongside the manifest. With autofix_registry_access: false, it edits the manifest only and marks the lockfile stale
go the require line in go.mod Yes when there is no go.sum. When one exists, it carries a hash of the module zip that cannot be computed without downloading it, so the pull request marks that lockfile stale

The sandbox carries no package manager, deliberately: running one would execute resolution code — and, for npm, lifecycle scripts — from the dependency graph under suspicion. Default-enabled npm AutoFix instead makes one read-only registry metadata request and rewrites the existing lockfile entry directly; it does not install or execute the package. The registry-enabled AutoFix job uses a separate, tightly scoped egress lane from ordinary scans.

Set autofix_registry_access: false when even that metadata request is not acceptable. The npm pull request then raises package.json only and carries an unmissable stale-lockfile warning with npm install --package-lock-only --ignore-scripts. Go AutoFix remains manifest-only by design; when the repository has a go.sum, its pull request carries the corresponding go mod tidy warning. Merging either stale-lockfile pull request believing the lock moved would fix nothing, which is why the warning appears on the pull request rather than in a footnote.

Nothing is written to your findings when the branch is pushed. A branch is a proposal: the finding closes when the merge lands and the next scan of the default branch no longer sees the package.

There is one open AutoFix pull request per (repository, package) — the branch name is deterministic, so the open pull request is the open fix.

Ask for one by finding id:

limacharlie cloudsec code autofix fnd_2290bab86c1b4d0374d1e2666f64aeca

The call accepts and returns; the clone, the edit and the pull request happen minutes later in the sandbox, so accepted does not mean a pull request exists — the pull request is the result. Every refusal above is a quiet no-op on this call for the same reason: it has already answered by the time the work runs. If no pull request appears, the reason is on the cloudsec.code_autofix_refused operational event.

Bring your own scanner

Not everything worth knowing about a repository comes from the hosted scan. You may already run a scanner in CI, use an analyzer for a language the hosted lane does not cover, or want results for a repository before you connect the organization. So the lane takes results you produced:

limacharlie cloudsec code ingest --repo acme/payments --source sarif -f results.sarif

--source is sarif (SARIF 2.1.0, which nearly every scanner can emit), cyclonedx (a bill of materials, with or without its vulnerabilities section), or report — the LimaCharlie scanner's own document, which is what code scan below produces.

It is the same finding, not a copy of it

A pushed finding is deduplicated against the hosted scan by identity. A dependency vulnerability is identified by its advisory, the package and the manifest that declares it — never by a line number — so when both lanes see CVE-2021-23337 in lodash in package-lock.json, there is one finding, whose age and triage state survive. Pushing the same document twice writes nothing at all.

Three rules follow from that, and they are worth knowing before you wire up a pipeline:

  • A pushed document can only close findings it previously reported. Fix a dependency, push again, and that finding closes. It can never close something the hosted scanner found — your dependency scan says nothing about the secrets and misconfigurations the sandbox looked for. The reverse is also true: a hosted scan will not close what you pushed.
  • What the format cannot carry is reported, not guessed. The response's notes names it. iac_resource_ref_absent, for example, means the document identified an infrastructure finding by file alone, because SARIF has no field for the resource address — so those findings do not dedupe against the hosted scan's, which identify the resource.
  • Credential findings in a third-party document are refused (secrets_not_ingestable). A secret is identified here by a keyed digest of the matched value, which no foreign format carries; what such a document does routinely carry is the credential itself, in a snippet, and that is not something to accept. Use the hosted lane for secrets.

The repository does not have to come from a connected source-control organization. A push for a repository nothing collects creates the repository entry from the facts the document supplies — which is what makes this the path for code you scan but do not connect. In that case also pass --default-branch, because no provider connection can state it:

limacharlie --output yaml cloudsec code ingest \
  --repo acme/private-api \
  --source sarif \
  --file results.sarif \
  --default-branch main \
  --commit "$COMMIT_SHA"

It must still match an enabled code_scanning policy — the same switch and the same globs the hosted lane uses — and it counts against the same repository quota as a connected one.

A document can be up to 20 MiB; files ending in .gz stay compressed in transit. Narrow the scan or split independent results into separate documents if you exceed it.

Scanning locally

code scan runs the LimaCharlie scanner over a checkout on your own machine — or in your CI — and, with --ingest, pushes the result:

# Look at the report without sending anything.
limacharlie cloudsec code scan ~/src/payments -o report.json.gz

# Scan and push.
limacharlie cloudsec code scan ~/src/payments --repo acme/payments --ingest

Nothing about the checkout leaves the machine except the report. The scanner runs in a container by default (which carries the pinned engines and their databases); --binary runs an already-installed agent instead, which is what a CI image that ships one should do. --scanners defaults to sca,iac,licenses; sast and images also run locally.

A scan must use --ingest, -o, or both, so a completed scan never discards its only copy of the report. The -o output is a gzipped LimaCharlie report/v1 document, which is also what code ingest --source report takes.

Secret scanning cannot run locally, and asking for it is an error rather than a quietly narrower scan: --scanners sca,iac,secrets fails. A secret's identity here is a digest keyed by a deployment-side value this command does not have, so locally-found credentials could not deduplicate against the hosted scan's — and the ingest refuses them for the same reason. Use the hosted lane for secrets.

A GitHub Actions recipe

This runs on every push to the default branch, scans the checkout, and pushes the report. It uses no LimaCharlie CI minutes: the work happens in your runner.

name: Cloud Security code scan

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          # The scanner reads the working tree. Full history is only needed if you
          # scan git history for secrets, which the local scan does not do.
          fetch-depth: 1

      - name: Install the LimaCharlie CLI
        run: pipx install limacharlie

      - name: Scan and push
        env:
          # A LimaCharlie API key with `cloudsec.set`, stored as a repository secret.
          LC_OID: ${{ secrets.LC_OID }}
          LC_API_KEY: ${{ secrets.LC_API_KEY }}
        run: |
          limacharlie auth login --oid "$LC_OID" --api-key "$LC_API_KEY"
          limacharlie cloudsec code scan . \
            --repo "$GITHUB_REPOSITORY" \
            --commit "$GITHUB_SHA" \
            --ingest

To push results from a scanner you already run, replace the last step with an ingest of its output — most tools have a SARIF formatter:

      - name: Push existing results
        run: |
          limacharlie cloudsec code ingest \
            --repo "$GITHUB_REPOSITORY" \
            --source sarif \
            --commit "$GITHUB_SHA" \
            -f results.sarif

$GITHUB_REPOSITORY is already <owner>/<name>, which is exactly the repository key the lane uses.

In your IDE

The code lane is exposed to AI assistants over the Model Context Protocol, so a session in your editor can read repository findings and scan the working copy before anything is pushed. See Cloud Security in your IDE.

The joins that make it worth doing

Code findings are not a separate island. The repository is a node in the security graph, container images are nodes keyed by digest, and two edges connect them to the running estate: built-from (image → the repository that produced it) and runs-image (workload → the image it runs).

That gives you questions no repository scanner can answer on its own, shipped in the query pack:

Query Question
vulnerable_packages_on_exposed_workloads which advisories are in images that internet-facing workloads actually run
images_with_kev_on_exposed_workloads the same, narrowed to known-exploited vulnerabilities
secrets_in_repos_with_cloud_oidc which federated pipeline identities can assume a cloud identity — the blast radius of a leaked repository credential
eol_runtimes_in_production_images which end-of-life runtimes reach a running workload

Read an empty result carefully

Three of the four need the runs-image link, and that link only covers the image sources your policy's image_sources enables — dockerfile (the default) links images a scanned repository declares, workloads links the digest-pinned images your cloud inventory reports a workload running. So an empty result can mean "that link was not collected here" rather than "nothing is affected". Each query's description says exactly which.

secrets_in_repos_with_cloud_oidc anchors on every federated principal, not only CI ones, so a directory federation into your cloud appears alongside pipeline trusts — read the principal's subject to tell them apart.

Compliance

Two frameworks are graded off the code lane, alongside the cloud benchmarks:

  • owasp-top10 — OWASP Top 10:2021, mapped by CWE. All ten categories are assessable once static analysis has run over at least one repository; with sast off, the five categories that only static analysis can evidence report NOT_ASSESSED with their mapping already written down.
  • cis-supply-chain — the CIS Software Supply Chain Security Guide's Source Code and Dependencies sections in full (60 controls), 10 of which have a detector. Every other control says in its own words why it is not assessed.
limacharlie cloudsec compliance report --framework owasp-top10
limacharlie cloudsec compliance report --framework cis-supply-chain

"NOT_ASSESSED — reason" is a statement, not a gap in the report. It means this build has no detector for that control, or the signal a detector needs was never produced, and the alternative — reporting PASS because no finding exists — would be a false clean bill. Every such control carries its own reason, so the list doubles as a map of what the framework asks for and what is measured.

Both frameworks apply only when a source-code provider is connected; on an estate without one they report NOT_APPLICABLE rather than a vacuous pass. And every control that grades an outcome ("are there secrets in the source?") additionally waits for the lane to have completed a scan pass: a connected provider is not the same fact as a scanned repository. The six controls that depend on static analysis additionally wait for a static-analysis pass to have actually run — sast is off by default, and an empty code_weakness list from an engine that never started is not evidence of anything.

Because most of cis-supply-chain is not auto-assessable, its report carries the low-coverage qualifier — read the coverage figure next to the score, never the score alone. See Compliance for how scoring and coverage work.

These controls grade the outcome, not the configuration

Several controls in both frameworks ask whether a scanner is in place. What the finding store can answer is whether there are findings. The two differ exactly where it matters: a repository your policy excludes produces no findings and therefore cannot fail those controls. Read the score next to the Code page's scan coverage. Each affected control says so in its description.

Limits

Free-tier organizations are capped on how much of an estate the lane covers:

Free-tier limit Value
Repositories scanned the first 10 by name, per connected source-control organization
Container images scanned the 5 most referenced, per organization

Held-back repositories and images are reported with a machine-readable reason (free_tier_code_repos_cap, free_tier_code_images_cap) rather than silently omitted, and the covered set is stable — it does not rotate, so you do not watch the same findings open and close.

Everything else is a scale guard rather than a tier:

Limit Value
Scan wall clock 30 minutes per repository
Concurrent scans per organization 4
Repositories scanned per day, per connection 500
Container images per pass 50
Per-file size read by static analysis 1 MiB (larger files are counted, not opened)
Clone size 2 GB
Report size 20 MiB compressed
Pull-request writes per day, per connection 500
AutoFix pull requests per day, per connection 20

When a cap truncates a scan, the code scan status carries what was hit. A partial scan never closes findings it did not have the chance to re-observe.

Not yet available

Named here so their absence is not mistaken for a clean result:

  • Custom static-analysis rule packs. sast_ruleset: custom:<ref> is accepted by the policy validator and refused by the scanner. The refusal is scoped to static analysis alone: the repository reports scan_status: partial with sast_ruleset_unresolved, and its dependency, secret, infrastructure and licence findings are complete and still close normally. Clear the value to use the curated pack.
  • Container-registry enumeration (image_sources: ["registries"]). The value is accepted by the policy validator, but nothing enumerates a registry — this is the expensive, unbounded half of the image lane and it is not built.
  • Pull-request checks, comments, AutoFix and push-triggered rescans on GitLab and Bitbucket. Those connections are scanned on the policy's schedule and write nothing.
  • Bitbucket Data Center (self-hosted). Bitbucket Cloud is supported.

Troubleshooting

Problem What to check
A cloudsec code command is missing Upgrade to a CLI release newer than 5.6.2.
The CLI cannot identify the repository Pass --repo <owner>/<repository> explicitly.
Docker is not found Install and start Docker, or upload a document produced by an existing scanner with code ingest.
The repository is not recorded Confirm that the repository matches an enabled code-scanning policy and is within the organization's repository quota.
The document is rejected as too large Keep it below 20 MiB by narrowing the scan or splitting independent results into separate documents.
Secret results do not appear Use hosted secret scanning; local and third-party secret findings are intentionally not ingested.
Hosted scanning reports github_app_missing_contents_permission The connected GitHub App cannot read repository contents, so nothing could be cloned. Add Repository → Contents: Read-only to the App, then approve the permission request on the organization's installation page: GitHub requires an owner to accept a permission increase on an existing installation, so editing the App alone is not enough. Nothing was cloned or code-scanned, and no existing finding changed — the provider's own sweep is unaffected.
A repository reports scan_status: partial with sast_ruleset_unresolved The code-scanning policy names a static-analysis rule pack that is not available, so static analysis did not run on that repository. Clear the custom sast_ruleset value to use the built-in pack. Nothing else about the repository is affected: its dependency, infrastructure, license and secret findings are complete and still close normally.
A repository reports scan_status: unknown with repo_not_scanned Nothing has scanned it yet. Confirm it is inside an enabled code-scanning policy's include list, then allow the next scheduled pass to reach it. limacharlie cloudsec code status is the authoritative answer about the scan run itself; follow it when the two disagree.
A repository or image reports free_tier_code_repos_cap or free_tier_code_images_cap The organization is on the free tier, which covers the first 10 repositories per connected source-control organization and the 5 most-referenced container images per organization. The covered set is stable rather than rotating, so findings do not appear and disappear between passes. Narrow the policy to the repositories you care about, or move off the free tier. A _report suffix means the limit is not being applied: everything was scanned, and the message reports what the limit would have done.
A pull-request check or AutoFix does nothing, reporting write_app_not_configured or write_app_lacks_contents Only the write was refused. Scanning and existing findings are unaffected. The App that writes — the connection's App, or a separate Code Actions App if the record names one — lacks the permission the message names: Checks and Pull requests: Read and write for checks and comments, Contents: Read and write for AutoFix. The Code page shows which is missing per connection. Grant it and approve the permission request on the installation page.
An AutoFix pull request reports lockfile_stale The pull request is real and correct; the lockfile still has to be regenerated. npm: npm install --package-lock-only --ignore-scripts. Go: go mod tidy. This is expected for Go whenever a go.sum exists, and for npm only under autofix_registry_access: false, a yarn.lock/pnpm-lock.yaml, or an entry that could not be rewritten safely.
Pushes are not rescanned and pull requests get no check Open the Code page's GitHub webhooks section. Not connected with no active webhook: use Set up webhook and have an owner add the Payload URL and Secret on GitHub, then Check again. Not connected pointing elsewhere: use Fix webhook. Missing events means an owner of the GitHub organization must tick Push and Pull request under the App's Permissions & events; GitHub offers no API for this. Receiving events means the webhook is fine: check that the three rules are installed under Webhook automation, then work through Is it firing?. Never add webhooks to individual repositories: the App's one webhook covers every repository it is installed on.
A connection's webhook shows Not verified GitHub could not be read with the App's credentials: a timeout, a rate limit, or a rejected private key. Nothing is wrong with the webhook as far as LimaCharlie knows, and it is checked again automatically. If it stays that way, check that the App and its private key still exist on GitHub.
GitHub's Recent Deliveries show deliveries rejected with 401 The App signs with a different secret than LimaCharlie verifies. On the Code page, use Re-sync webhook secret (or Sync webhook secret if the row says Secret not synced), which sets LimaCharlie's secret on the App. Without the console, call the API with the per-connection adapter's URL and signing secret, or paste the secret on GitHub.
A connection shows Secret not synced The setup could not confirm that the App uses the secret LimaCharlie verifies, so deliveries may be rejected. Use Sync webhook secret. This flag exists only in the browser that ran the setup.
A connection shows Name conflict Its name differs from another connection's only by letter case, so both would share one webhook adapter and signing secret, and no webhook actions are offered. Delete one of the connections and add it again under a distinct name; a connection cannot be renamed in place. The Add provider wizard refuses such names for new connections.
Fix webhook asks to Replace webhook The App's webhook currently delivers to another address. A GitHub App has only one webhook, so replacing it stops those deliveries, and the previous secret cannot be restored. If another tool relies on that webhook, connect LimaCharlie with its own GitHub App instead.
Fix webhook fails with webhook_in_use_by_other_org The App's webhook already delivers to a different LimaCharlie organization, so nothing was changed. One App can only deliver to one organization. Create a separate GitHub App for this organization, for example with Create a GitHub App for me.
Fix webhook fails with github_credential_rejected or credential_unavailable The connection's App credentials are missing or GitHub rejected them. Generate a new private key for the App on GitHub and update the connection's credentials secret.
Fix webhook fails with webhook_not_active, or switches to the Set up webhook steps The App's webhook was deactivated after its status was read. GitHub's API cannot create or activate a webhook, so an owner must follow the Set up webhook steps on GitHub. Nothing was changed.
The API refuses a webhook URL with invalid_url The route only accepts this organization's per-connection adapter URL, https://<hooks domain>/<oid>/github-code-webhook-<connection>/<secret>. A legacy adapter named just github-code-webhook is refused: create a per-connection adapter first.
Create a GitHub App for me says "GitHub App … was created, but this setup ended before its private key was saved", or that setup was interrupted while GitHub was creating the App The page was closed or reloaded before the App's private key was saved, and GitHub hands the key over only once. The App may exist on GitHub. Generate a new private key for it on GitHub and connect it with I already have a GitHub App, or delete the App and start again.
Fix webhook reports a timeout The change may already be applied. The status is re-checked automatically; look at it again before retrying.
Create a GitHub App for me ends with "The installation was requested" The person who installed the App is not an owner of the GitHub organization, so GitHub only requested the installation. Once an owner approves it on GitHub, add the connection with I already have a GitHub App, using the App ID and credentials the page showed.
Create a GitHub App for me says the setup expired GitHub requires the App to be created within an hour of leaving LimaCharlie. After the App's key is saved, the setup stays resumable for 7 days in the same browser session. If the App was created on GitHub anyway, delete it or connect it manually with a newly generated private key, then start again from Settings.
A finding you expected is absent entirely Check the policy's severity_floor. A finding under the floor is never recorded, so it has no row to filter for. LOW, INFO and an empty value mean no floor.

See also