Engineering
Updated
7 min read

Devcontainers, Little Snitch, macOS TCC - protecting developer laptops

How we implement different layers to secure our developer laptops & environments: Devcontainers, outbound firewall, macOS Transparency Consent and Control framework, and SSH agent for Git keys.

Devcontainers, Little Snitch, macOS TCC - protecting developer laptops

A single compromised npm package on a developer's laptop is all it takes - a quiet threat that executes with the familiar npm install command.

The potential for damage is significant - compromised commit rights to source repositories, stolen session tokens, exposed secrets from environment variables, and even direct access to production networks. Once you gain a foothold on a developer laptop, there are many opportunities to reach sensitive production systems.

At Arcjet, developer laptops consistently come top in our assessments of the "most likely" threats. By the very nature of the job, developers are regularly expected to install dependencies, execute code on their local systems, use third-party editor extensions, and connect to sensitive environments. This inherent risk likely explains the recent surge in developer-focused exploits, such as malware bundled within Python, Go, and Node packages; VS Code extension exploits; and Chrome extension hijacking.

Arcjet is a devtools startup. Our security as code SDK helps developers implement features like bot detection and signup form spam detection. We’re thinking about security all day every day, not just in our product, but also in how we run the company. In this blog post, I’ll talk through some of the work we’ve done to improve our own developer security.

Devcontainers

The first line of defense is containing the development environment itself. Originally developed by Microsoft, Devcontainers is an open specification that defines the development environment for a project:

A development container (or dev container for short) allows you to use a container as a full-featured development environment.

Using a .devcontainer/devcontainer.json file, you can define a container environment by specifying a base image from a public or private registry. Various optional features can be added to install common tools, such as the GitHub or AWS CLIs, linters, formatters, and other language runtimes. Include recommended VS Code extensions and scripts to run after installation, and within a few seconds of launching the container you have a fully configured development environment.

When you have a team of developers, getting them all running the same version of the same tools can be a big challenge. Using Devcontainers solves this by defining a consistent environment as configuration, rather than manually setting things up. The Devcontainers config in our public JS SDK and docs repos has helped make it easy for external contributions.

// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
  "name": "arcjet-docs",
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm",
  // Features to add to the dev container. More info: https://containers.dev/features.
  "features": {
    "ghcr.io/devcontainers/features/common-utils:2.5.2": {},
    "ghcr.io/trunk-io/devcontainer-feature/trunk:1.1.0": {}
  },
  // Use 'forwardPorts' to make a list of ports inside the container available locally.
  // "forwardPorts": [],
  // Install trunk tools inside the container
  // Uses array syntax to skip the shell: https://containers.dev/implementors/json_reference/#formatting-string-vs-array-properties
  "updateContentCommand": ["trunk", "install"],
  // Install npm dependencies within the container
  // Uses array syntax to skip the shell: https://containers.dev/implementors/json_reference/#formatting-string-vs-array-properties
  "postCreateCommand": ["npm", "ci"],
  "customizations": {
    "vscode": {
      "extensions": [
        "astro-build.astro-vscode",
        "unifiedjs.vscode-mdx",
        "trunk.io"
      ]
    }
  }
  // Configure tool-specific properties.
  // "customizations": {},
  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
  // "remoteUser": "root",
}

Devcontainer config for the Arcjet docs repo.

Using a Devcontainer isolates the dev environment from the host system (the developer laptop). Code is executed inside the container rather than directly on the host. This isolation mitigates most attack vectors such as malware executed via post-install scripts or from backdoored dependencies.

The downside is a minor performance overhead, particularly with I/O-intensive operations on macOS due to the underlying virtualization layer. However, for most development workflows, the security benefits far outweigh the negligible performance impact. Cloning a repository directly into a container volume rather than binding to the host filesystem can mitigate most of the performance issues. 

While credentials and source code within the devcontainer could still be exfiltrated, the damage is constrained by the container's boundaries, making it easier to quarantine. This prevents code from unrestricted host access - keychain, password manager vaults, browser history databases, etc.

Containers are not designed for security or 100% isolation - they’re more of a convenient packaging and deployment format - so there is always the potential for container breakout. However, most attackers will assume that code is executed on the host system directly. All the code knows is that it’s running on a (pretty sparse) Linux machine. Devcontainers can therefore be a very effective layer of security for development.

Outbound firewall

The next layer is controlling what the isolated environment can access. macOS has a good built-in firewall, but it is primarily designed to protect against inbound connections. Tracking outbound connections is just as important.

Using an outbound firewall such as LuLu firewall (free, open source) or Little Snitch (paid, or its free variant Little Snitch Mini) will alert you the first time any application attempts to make outbound connections. This is initially quite noisy, but you get a good baseline of common applications pretty quickly.

Why is this important? A compromised dependency might try to "phone home" by sending exfiltrated secrets (like your AWS_ACCESS_KEY_ID) to a remote server over a standard port like DNS (53) or HTTPS (443). A default-deny firewall forces you to explicitly allow connections, making this anomalous traffic immediately obvious.

Little Snitch rules configuration.

Built-in macOS protections

This layer focuses on using mechanisms built into macOS. Introduced in macOS Mojave (10.14), the Transparency Consent and Control (TCC) framework restricts application access to sensitive user data and system resources. 

You’ll have seen this in action with the consent boxes appearing whenever applications try to access your microphone, camera, location, photos, contacts, and other areas of your system that macOS considers sensitive. 

This protection also extends to the ~/Downloads, ~/Documents, and ~/Desktop folders so that any process which tries to read or write files to these locations will be blocked until you approve access. macOS 10.15 introduced additional access controls for anything located in the ~/Desktop and ~/Documents directories which locks down kernel access even further.

macOS Privacy & Security controls.

These restrictions apply to each application, which includes any scripts or processes that might attempt to exfiltrate the contents of source code directories on disk. If you place all your code into one of these three directories, they will also benefit from the TCC protections (although the responsible process might show up as your editor or terminal).

For example, if you check out your Git repository to ~/Documents/repo then any malware that attempts to scrape the contents of ~/Documents will trigger the consent popup.

The role of TCC becomes more nuanced when using Devcontainers. This is because the container runtime itself (e.g., Docker Desktop or OrbStack) is the application that receives TCC authorization to access directories on the host. Consequently, malware executing within the container (e.g., via a post-install script) that accesses these mounted files will not trigger a new TCC prompt. The I/O request is proxied through the trusted runtime, effectively bypassing a direct TCC check on the malicious process.

While this means TCC's file-access prompts offer less direct protection from threats inside the container, the container itself still provides a layer of isolation. TCC remains a useful defense against a potential container escape, where malware might try to break out and execute directly on the macOS host.

SSH agent for Git keys

The final layer is ensuring the developer's identity and access are secure. Any keys stored directly on disk are easily accessible. This is one reason why AWS recommends using IAM Identity Center with the SSO CLI flow for logging into AWS accounts - so static credentials aren’t stored on disk.

The same problems arise with SSH keys, often used for GitHub authentication. Generated keys are stored in ~/.ssh by default, which makes them easy to exfiltrate. 

Balancing UX with security is always a challenge. One option is to set a passphrase for the key and then store it in Keychain. This happens automatically if you access a passphrase protected file stored at .ssh/id_rsa or .ssh/identity - macOS will manage access for you. If you have multiple keys or the key is stored somewhere else, you can manually add it to ssh-agent and ask for the passphrase to be stored in Keychain

An alternative to using the macOS Keychain is the SSH key support in 1Password. This avoids any files on disk and in contrast to using your login password for Keychain, 1Password triggers a prompt whenever a new application wishes to access the key. We have 1Password reporting to our logging infrastructure which means we also get audit logs for all credential access.

At Arcjet, we mandate signed commits, which requires developers to manage a signing key. Enforcing signed commits is a foundational practice for securing the software supply chain. It provides verifiable attestations that code originates from a trusted developer (that has also authenticated recently), protecting the repository from unauthorized code injection, even if a developer's GitHub access is compromised.

1Password Developer tools.

MDM

It’s only a matter of time before an incident happens. Ideally, one of the above layers will catch attacks or mistakes, but when something does happen we need to be able to detect it, understand how it happened, apply effective quarantine measures, and quickly remediate the situation.

The focus is often on the fancy detection and response part of this, but logging is just as important because it helps you answer questions like: What happened? What data (if any) was extracted? How long has this been compromised? Were any other systems impacted?

All Arcjet devices are provisioned with MDM tooling to help detect potential problems quickly, alert the right people, and protect our team & customers. We’ve partnered with Latacora for 24/7 monitoring & response and their team acts like our internal security experts. Various detection rules notify us of any suspicious activity, we have escalation channels to trigger rapid response investigations, and regularly run practice tabletop exercises to test our processes.

Conclusions

Security is all about layers. Securing a developer workstation is not about achieving an impenetrable state; it's about creating layers of defense that systematically reduce the attack surface. By isolating development work in containers, controlling network egress, making use of built-in features on the host OS, and securing developer identity, we’re able to build a robust security posture without getting in the way of development.

Related articles

Subscribe by email

Get the full posts by email every week.