ProductCLISecurityDocsPricing
Sign in

Documentation

How Basalt reads your code and cloud, what it finds, and how to act on it.

Overview

Basalt builds a graph of your platform from two sources: your repositories and your AWS account. It then searches that graph for attack paths, chains of real permissions and connections that lead from something an attacker can reach to data or systems that matter.

Some paths go through AI agents. A public function hosts an agent, the agent has a tool that runs shell commands, and the function's IAM role can read a customer bucket. Others do not involve agents at all, such as an IAM role any AWS account can assume. Basalt reports both.

Every finding comes with the path, the impact, a suggested fix and the evidence behind it: file and line, or the resource that defines each step.

  • Code is parsed, never imported or executed.
  • AWS access is read-only and limited to an explicit list of configuration calls. See AWS actions.
  • Secret values are never stored. The graph keeps names, locations and kinds only.

For the full trust model, see Security.

Quickstart

The basalt CLI scans a repository on your machine. Install it with curl -fsSL <your Basalt URL>/api/v1/cli/install.sh | sh (macOS, Linux) or from the download page (Windows too). Every command needs a linked account; a free one is enough.

1. Link your terminal

basalt login links the CLI to your Basalt account. It prints a URL and a code, opens the browser, and waits until you confirm the code there. Use --no-browser on a remote machine and open the URL yourself.

terminal
$ basalt login
$ basalt whoami

The token is stored in ~/.config/basalt/credentials.json with mode 0600. basalt logout revokes the session on the server and deletes the file.

Every command needs a linked account. After that, basalt scan, basalt scan-aws and basalt check run entirely on your machine and do not send results to Basalt; only the interactive assistant (basalt with no arguments) talks to the Basalt API.

2. Scan a repository

terminal
$ basalt scan path/to/repo

The scanner walks the repository, parses Python, TypeScript and JavaScript, Java, Rust, C++, Terraform and MCP configuration files, builds the graph and runs the path engine. Build output and dependency folders such as node_modules, .venv, dist, build, .next and target are skipped, and so are files over 1 MB.

3. Read the output

Text output has three parts: the fixes that close the most important paths, the findings sorted by severity, and an inventory of what was found.

example output
11 findings, 12 fixes

== Fix first ==
- Scope s3:GetObject on role support-bot  (closes 3)
- Remove shell execution from run_diagnostics  (closes 1)
- Restrict who can assume ci-deployer  (closes 1)

== Findings ==
[CRITICAL] Any AWS account can assume ci-deployer (administrator)
    Any AWS account -> ci-deployer -> acme-customer-exports
[CRITICAL] SupportBot can be prompted to read acme-customer-exports through run_diagnostics
    support-bot function URL -> support-bot -> SupportBot (prompt injection) -> run_diagnostics (runs commands) -> support-bot -> acme-customer-exports
[CRITICAL, medium confidence] SupportBot can be prompted to read acme-customer-exports through read_file
    ...
[HIGH] INTERNAL_API_TOKEN is stored in plain text
    run_diagnostics -> INTERNAL_API_TOKEN

Scanned 30 files: 9 Agent, 27 Tool, 10 McpServer, 13 Credential, 3 EntryPoint, 3 ComputeResource, 4 IamRole, 4 DataStore
...

Each finding line shows its severity, and its confidence when it is not high. The line under it is the path, step by step. Start with Fix first: one fix often closes several paths. Use --format json for the full findings with stories, impact and evidence (see JSON output).

Connect AWS

Basalt reads your AWS account through an IAM role you create. The role trusts Basalt's AWS account and requires an ExternalId unique to your organization, which protects against the confused-deputy problem. Basalt never stores permanent keys.

Create the role

  1. Open Sources in your workspace and start the AWS setup. It shows Basalt's account ID, your ExternalId and both policies below with the values filled in.
  2. Create a role named basalt-readonly with the trust policy.
  3. Attach the permissions policy to it.
  4. Paste the role ARN back into the setup screen.

Permissions policy

The first statement allows only the configuration reads Basalt uses. The second is an explicit Deny on data reads, so the role stays safe even if it gains more permissions by mistake.

permissions-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BasaltReadConfiguration",
      "Effect": "Allow",
      "Action": [
        "iam:GetAccountAuthorizationDetails",
        "iam:GetPolicy",
        "iam:GetPolicyVersion",
        "s3:ListAllMyBuckets",
        "s3:GetBucketPolicy",
        "s3:GetBucketPublicAccessBlock",
        "s3:GetBucketTagging",
        "dynamodb:ListTables",
        "secretsmanager:ListSecrets",
        "lambda:ListFunctions",
        "lambda:GetFunctionUrlConfig",
        "ecs:ListTaskDefinitionFamilies",
        "ecs:DescribeTaskDefinition",
        "ec2:DescribeInstances"
      ],
      "Resource": "*"
    },
    {
      "Sid": "BasaltNeverReadsData",
      "Effect": "Deny",
      "Action": [
        "s3:GetObject*",
        "secretsmanager:GetSecretValue",
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
        "dynamodb:Scan",
        "ssm:GetParameter*",
        "kms:Decrypt",
        "rds-data:ExecuteStatement"
      ],
      "Resource": "*"
    }
  ]
}

Trust policy

Replace the two placeholders with the values from your setup screen.

trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": "arn:aws:iam::<BASALT_ACCOUNT_ID>:root" },
    "Action": "sts:AssumeRole",
    "Condition": { "StringEquals": { "sts:ExternalId": "<CUSTOMER_EXTERNAL_ID>" } }
  }]
}

What happens on each scan

  1. Basalt calls sts:AssumeRole with your ExternalId and receives credentials that expire after one hour.
  2. Every API call passes a client-side guard. Any operation outside the allowlist is blocked before the request leaves the scanner, including data reads such as s3:GetObject and secretsmanager:GetSecretValue.
  3. Each call is recorded in the scan's audit log: service, operation, region and time.
  4. lambda:ListFunctions and ecs:DescribeTaskDefinition return environment variable values; AWS offers no variant without them. Basalt classifies each value in memory (does it look like a plain-text secret?) and discards it. The value is never written, logged or sent.

If you prefer, remove those two actions. The scan still runs without the compute analysis, and the report lists the missing permission.

Local mode

basalt scan-aws runs the same read-only scan from your machine, with the same guard. Either assume the customer role:

terminal
$ basalt scan-aws --role-arn arn:aws:iam::123456789012:role/basalt-readonly --external-id <EXTERNAL_ID>

or use the credentials already configured in your shell or a named profile:

terminal
$ basalt scan-aws --use-current-credentials --profile my-profile --region us-east-1 --region eu-west-1

One of --role-arn or --use-current-credentials is required; the CLI never picks a target on its own. --role-arn requires --external-id. Without --region, only us-east-1 is scanned. AWS support needs boto3; the CLI tells you if it is missing.

Connect GitHub

The GitHub App for repository scanning is rolling out and is not generally available yet. It will read only the repositories you select.

Until it reaches your workspace, scan repositories with the CLI (basalt scan), or we set up the connection with you during early access. You can also analyze Terraform in a repository without connecting AWS at all: the scanner reads .tf files directly.

Pre-deploy check

basalt check answers one question: does this branch add attack paths compared to its base? It scans the working tree, including uncommitted changes, and the merge-base with the base branch, then compares the findings.

terminal
$ basalt check
$ basalt check --base origin/main --fail-on critical

The base tree is extracted with git archive into a temporary directory and only parsed. Nothing in your repository changes. Without --base, the check uses the remote's default branch, then tries origin/main, origin/master, main and master.

The output groups paths into three lists: new paths introduced by the branch, paths the branch closes, and paths already open on the base. The verdict is one of:

  • block: at least one new path at the --fail-on severity or above (default high; never disables blocking).
  • warn: new lower-severity paths, uncommitted changes, unpushed commits, a branch with no upstream, or a branch behind its base.
  • pass: none of the above.

Exit codes

CodeMeaning
0Pass or warn.
1Block: a new path at --fail-on or above.
2The check could not run, for example not a git repository or base branch not found.

In CI

Pipelines can’t sign in through a browser. On the CLI page of the app, under Signed-in terminals, use Create CI token: give it a name (for example github-actions) and a lifetime (30, 90 or 365 days). The token (bsl_ci_…) is shown once: store it as the repository secret BASALT_TOKEN. It only unlocks the local scanner (basalt scan and basalt check) and is validated against the API on every run; it can’t read your findings or use the assistant. Revoke it on the same page. The check needs git history back to the merge-base, so fetch the full history.

.github/workflows/basalt.yml
name: basalt
on: pull_request
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Install the Basalt CLI
        run: |
          curl -fsSL https://basalt.dev/api/v1/cli/install.sh | sh
          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
      - name: Pre-deploy check
        run: basalt check --base origin/${{ github.base_ref }} --fail-on high
        env:
          BASALT_TOKEN: ${{ secrets.BASALT_TOKEN }}

Use --format json to consume the result in another tool.

The graph

Everything Basalt knows about your platform is a graph of nodes and edges. Node IDs are stable, so the same component keeps the same ID across scans, and a Terraform definition and the live AWS resource converge on the same node when the name is a literal.

Nodes

KindWhat it represents
EntryPointWhere outside input arrives: a public Lambda function URL, an HTTP route whose handler calls a model.
ApiAn API surface. Reserved in the model; not produced by the current detectors.
AgentAn LLM agent or a model call that is given tools.
ToolA function the model can call, with its capabilities: shell, filesystem, http, database, cloud, source control.
McpServerAn MCP server declared in a client configuration or implemented in code.
CredentialA credential's name, location and storage kind. Never its value.
IamRoleAn IAM role, from Terraform or the live account.
PolicyAn IAM policy. Reserved in the model; today policies are evaluated into can_read and can_write edges instead.
DataStoreAn S3 bucket, DynamoDB table or Secrets Manager secret.
ComputeResourceA Lambda function, ECS task definition or EC2 instance.

Edges

KindMeaning
exposesAn entry point exposes a compute resource.
routes_toAn HTTP route sends its input to an agent.
invokesOne component calls another. Reserved in the model; not produced yet.
uses_toolAn agent can call a tool.
provides_toolAn MCP server provides a tool.
uses_credentialA component reads a credential.
assumesA compute resource runs with an IAM role.
can_read, can_writeA role can read or modify a data store, as computed by the IAM evaluator.
runs_onAn agent defined in code runs on a compute resource, for example the Lambda whose handler points to its file.

Every edge carries a confidence level and the evidence that produced it.

Attack paths

A single risky property is not a finding. A tool that runs shell commands only matters if untrusted input can reach it. Detectors attach risk signals to nodes, and the path engine turns signals into findings only when a path connects them to something an attacker can reach and something worth reaching.

The engine runs five rules, described in Path types. Each finding has a stable key, a story in plain language, the path, the impact, the fixes that close it and the evidence. Findings are sorted by severity, then confidence. Fixes are ranked by the most severe finding they close, then by how many findings they close, so one IAM scope change that closes three paths comes first.

The engine never invents what it cannot see: no record counts, no data contents, no exploitability beyond the signals it found.

Severity

Severity is one of critical, high, medium or low. It combines four factors:

FactorQuestionHow the engine uses it today
ExposureIs the starting point reachable from the internet or by untrusted users?Agent paths start only from public entry points. Open trust starts from any AWS account. Public buckets start from the internet.
ExploitabilityCan each step actually be taken?A tool must pass model input to a dangerous call without restriction. Low-confidence signals, such as a tool that validates its input or asks for human approval, do not create paths.
PrivilegeHow broad are the permissions gained?Administrator access on an openly trusted role makes it critical. Wildcard resources produce a separate scoping fix.
Data sensitivityWhat does the path reach?A data store is treated as sensitive when its name or tags suggest it. Sensitive targets raise agent and open-trust paths from high to critical.

A data store looks sensitive when its name contains customer, user, pii, export, billing, payment, patient, backup, prod or secret, or when a tag key or value contains pii, confidential, sensitive, restricted or customer. The finding names the tag it relied on.

Confidence

Every edge and every finding has a confidence level: high, medium or low. A path is only as confident as its weakest step.

  • High: every step was read directly from code, configuration or cloud metadata.
  • Medium: part of the path was inferred. Examples: the role grants access only to some S3 prefixes; a Lambda handler points at build output and Basalt matched the TypeScript source; a tool reads credentials from the environment or a metadata endpoint, which depends on the runtime.
  • Low: a key step could not be confirmed. Examples: a policy has a Condition block or a Terraform reference Basalt cannot resolve; several files in a monorepo could match the same handler.

When the IAM evaluator cannot decide whether a role can reach a data store, it does not guess. The edge is kept with "decision": "uncertain" and low confidence, and the text output marks it uncertain. A path with low confidence is reported one severity level lower and labeled, so a critical becomes a high. Treat low-confidence findings as leads to verify.

Excluding files

Add a .basaltignore file at the repository root to exclude generated code, vendored code or test fixtures. It supports a subset of .gitignore syntax:

  • A plain name matches at any depth: fixtures.
  • A pattern containing /, or starting with /, is anchored to the root: /tests.
  • A trailing / matches directories only: fixtures/.
  • *, ? and [] work as wildcards. Lines starting with # are comments.
  • Negation (!) is not supported; those lines are skipped.
.basaltignore
# synthetic data, not deployed
fixtures/
/tests
*.generated.json

Excluded paths are listed in the scan's audit (audit.ignored), and the text output reports how many were skipped.

Code size

Plan limits count lines of code analyzed: non-blank lines in the files the scanner parses (code in the supported languages, Terraform and MCP configuration), summed over the latest scan of each repository. Files the scanner does not parse, such as Markdown, images or excluded paths, do not count. If a scan exceeds your plan's limit, it fails with an explanation; excluding generated or vendored code with .basaltignore is usually enough. Each scan reports the number in audit.lines_of_code.

Path types

Agent to data

A public entry point exposes a compute resource that hosts an agent. Text sent to the entry point can instruct the agent (prompt injection) to call one of its tools. If the tool passes model input to a dangerous call without restriction, it acts with the IAM role of the compute resource, and the engine follows that role to every data store it can read or modify. How the tool gains the role depends on what it does: a shell tool runs commands with the role's credentials; a cloud tool calls AWS with model-chosen input, and only reaches stores of the services it calls; a filesystem tool on Lambda can read the role's temporary credentials from the process environment; an HTTP tool on ECS or EC2 can fetch them from the credentials endpoint. The last two are capped at medium confidence. The finding is critical if the data store looks sensitive, high otherwise. Stores that are already public are skipped, since the path adds no new access. Two variants exist: a shell tool that reaches no data store is still reported as critical command execution, and a database tool that executes model-written SQL is reported as high. Today, public entry points for this rule are Lambda function URLs with authentication set to none, read from Terraform or the live account.

Open trust

The trust policy of an IAM role allows any AWS principal ("AWS": "*") to assume it, so anyone with an AWS account can obtain its permissions. The finding is critical if the role has administrator access or can reach a sensitive data store, and high otherwise. Confidence is high because the trust policy is read directly. The fix names the exact accounts or roles that need the role and adds conditions such as an ExternalId.

Plain-text credential

A credential value is written in plain text where it should be a reference: in the env or headers of an MCP server configuration, in a connection string passed to an MCP server, or in the environment variables of a Lambda function or ECS task definition (from Terraform or the live account). Anyone who can read that file or configuration, through repository access, CI logs or backups, can use it. The finding is high with high confidence. The value itself is classified and discarded; Basalt keeps only the name and location. The fix is to rotate the credential and load it from a secret store.

Coding-assistant MCP servers

MCP servers in a repository's configuration are loaded by every developer who opens it in Claude Code, Cursor, VS Code or Windsurf. Content the assistant reads, such as issues, files or web pages, can instruct it to use those servers. The finding is medium when a server grants shell access, filesystem access on a broad path (the root, a home directory or a drive), or talks to a remote server over plain HTTP. It is low when the only issue is a package run without a pinned version, so any new release executes on the next start. The impact is the developer's machine, not production.

Public sensitive data store

An S3 bucket's policy or ACL allows anonymous reads, and its name or tags suggest sensitive data. The finding is high with high confidence. Buckets that are public but do not look sensitive, such as static website assets, are not reported. The fix removes the public statement and enables Block Public Access.

What we detect

All code is parsed: Python with its AST, the other languages with tree-sitter grammars. Nothing is imported, compiled or executed. Files in non-Python languages that mention no AI framework are skipped before parsing.

SourceFilesWhat Basalt finds
Python.pyLangChain and LangGraph, LlamaIndex, CrewAI, AutoGen, OpenAI SDK and Agents SDK, Anthropic SDK, PydanticAI, smolagents, MCP SDK. Decorated tools and raw JSON tool schemas, what each tool can do and whether a tool parameter reaches that call, agents and their tools, raw LLM calls with tools=, FastAPI and Flask routes whose handler calls the model (and whether they require authentication), secret-like environment variables and pydantic settings fields (names only).
TypeScript, JavaScript.ts .tsx .mts .cts .js .jsx .mjs .cjsVercel AI SDK, OpenAI Agents SDK, LangChain.js and LangGraph.js, Mastra, Anthropic and OpenAI SDKs with raw tool loops, MCP servers built with the TypeScript MCP SDK.
Java.javaLangChain4j and Spring AI @Tool methods, Spring AI MCP @McpTool, agents built with AiServices, ChatClient or Google ADK.
Rust.rsrmcp #[tool] servers, rig tools and agents, #[tool] and #[rig_tool] functions.
C++.cpp .cc .cxx .hpp .hh .hxxMCP servers written with cpp-mcp style APIs. Raw LLM loops are not detected as agents.
MCP client configurationmcp.json, .mcp.json, claude_desktop_config.json, mcp_config.json, including under .cursor/, .vscode/ and .windsurf/Claude Desktop, Claude Code, Cursor, VS Code and Windsurf servers: transport, package and pinning, known capabilities of well-known servers, allowed filesystem paths, credentials passed in env or headers. Unknown servers get no guessed capabilities.
Terraform.tfIAM roles and policies (inline, managed, attachments, policy documents, jsonencode, file(), heredoc JSON), permission boundaries, trust policies, Lambda, ECS task definitions, EC2 instances, S3, DynamoDB, Secrets Manager, public Lambda function URLs, public bucket policies and ACLs. Values built from variables, locals or modules are reported as unresolved, never guessed.
AWS accountLive, read-onlyRoles, inline and managed policies, trust relationships, S3 buckets with public policies and tags, DynamoDB tables, Secrets Manager secrets (names and tags), Lambda functions and function URLs, ECS task roles, EC2 instance profiles. See AWS actions.

Code and cloud are linked through Lambda handlers: an agent defined in the file a function's handler points to runs on that function, for Python, Node.js and Java runtimes.

AWS actions

This is the complete list of AWS calls the scanner can make. The guard enforces it in code, and a test checks that the scanner calls nothing else.

IAM actionUsed for
iam:GetAccountAuthorizationDetailsRoles, inline and managed policies, trust relationships (one paginated call)
iam:GetPolicy, iam:GetPolicyVersionContents of attached AWS managed policies
s3:ListAllMyBucketsList of buckets
s3:GetBucketPolicy, s3:GetBucketPublicAccessBlockDetect public buckets
s3:GetBucketTaggingSensitivity from tags
dynamodb:ListTablesList of tables, never items
secretsmanager:ListSecretsSecret names and tags, never values
lambda:ListFunctions, lambda:GetFunctionUrlConfigFunctions, their roles, public URLs
ecs:ListTaskDefinitionFamilies, ecs:DescribeTaskDefinitionTask roles, names of environment variables and secrets
ec2:DescribeInstancesInstance profiles (roles) of instances
sts:AssumeRoleTemporary credentials for your role, with your ExternalId
sts:GetCallerIdentityConfirm the account (needs no permission)

CLI commands

CommandOptionsDescription
basalt, basalt chat [prompt]Interactive session with the Basalt assistant. Needs a linked account and a terminal. Every change it proposes (file edit, branch, commit, push, pull request) is shown and needs your yes. It never commits on the default branch.
basalt login--no-browserLink this terminal to your account with a code confirmed in the browser.
basalt logoutRevoke this terminal's session and delete the local token.
basalt whoamiShow the linked organization, plan, user and role (or the CI token in BASALT_TOKEN). Exits 1 if not signed in.
basalt update--check, -y, --yesInstall the latest CLI from the Basalt it was downloaded from (sha256 checked). Without uv, prints the installer one-liner.
basalt mcpMCP server over stdio with read-only tools for Claude Code, Cursor and VS Code. See MCP.
basalt --versionPrint the installed version.
basalt scan <path>--format text|json, -o, --output FILEScan a local repository, read-only.
basalt check [path]--base REF, --fail-on critical|high|medium|low|never (default high), --format text|jsonAttack paths this branch adds or closes compared to its base. Path defaults to the current directory. Exit codes 0, 1, 2.
basalt scan-aws--role-arn ARN --external-id ID or --use-current-credentials; --profile NAME, --region REGION (repeatable, default us-east-1), --format text|json, -o, --output FILEScan a live AWS account, read-only. A target is always required.

Run basalt --help or basalt <command> --help for the same list in your terminal.

In CI, set BASALT_TOKEN (a CI token) instead of running basalt login; see In CI.

MCP (Claude Code, Cursor, VS Code)

basalt mcp exposes Basalt to your coding agent over stdio, with read-only tools: list_findings (attack paths), get_finding (path, impact, evidence and fix), list_fixes, basalt_overview and scan_path (local scan of a directory, parse only). It uses this machine’s basalt login session. Account tools are part of the plans with the MCP integration (Starter and up); scan_path works on any account. Nothing in it edits files, git or the account.

Claude Code
claude mcp add basalt -- basalt mcp
.cursor/mcp.json
{
  "mcpServers": {
    "basalt": { "command": "basalt", "args": ["mcp"] }
  }
}
.vscode/mcp.json
{
  "servers": {
    "basalt": { "type": "stdio", "command": "basalt", "args": ["mcp"] }
  }
}

If the editor can’t find basalt, use the full path (which basalt, usually ~/.local/bin/basalt).

JSON output

basalt scan --format json and basalt scan-aws --format json return one object. Abridged:

scan.json
{
  "scanner_version": "0.0.1",
  "source": { "type": "local_repository", "root": "/path/to/repo" },
  "audit": {
    "files_read": [".cursor/mcp.json", "support_bot/agent.py", "infra/iam.tf"],
    "lines_of_code": 611,
    "ignored": ["fixtures/"],
    "started_at": 1760140800.1,
    "finished_at": 1760140800.9
  },
  "errors": [],
  "graph": {
    "nodes": [
      {
        "id": "Tool:support_bot/agent.py:run_diagnostics",
        "kind": "Tool",
        "name": "run_diagnostics",
        "properties": { "capabilities": ["shell"], "sinks": [{ "call": "subprocess.run", "line": 32 }] },
        "evidence": [{ "source": "support_bot/agent.py", "line": 29, "detail": "tool 'run_diagnostics' (decorator)" }],
        "signals": [{ "id": "tool.shell_true", "summary": "subprocess called with shell=True", "confidence": "high",
                      "evidence": [{ "source": "support_bot/agent.py", "line": 32, "detail": "subprocess.run" }] }]
      }
    ],
    "edges": [
      {
        "source": "IamRole:support-bot",
        "target": "DataStore:s3:acme-customer-exports",
        "kind": "can_read",
        "confidence": "high",
        "evidence": [{ "source": "infra/iam.tf", "line": 13, "detail": "aws_iam_role_policy.support_bot_s3" }],
        "properties": { "actions": ["s3:GetObject"], "decision": "allow", "scope": "full",
                        "prefixes": [], "via_wildcard_resource": true }
      }
    ]
  },
  "analysis": {
    "findings": [
      {
        "key": "agent-data:EntryPoint:lambda_url:support-bot|Tool:...|DataStore:s3:acme-customer-exports",
        "severity": "critical",
        "confidence": "high",
        "title": "SupportBot can be prompted to read acme-customer-exports through run_diagnostics",
        "story": "Anyone on the internet can call support-bot function URL, ...",
        "path": [
          { "node_id": "EntryPoint:lambda_url:support-bot", "kind": "EntryPoint",
            "name": "support-bot function URL", "relation": "exposes", "note": null }
        ],
        "impact": "Read access to s3://acme-customer-exports (tagged data_classification=confidential).",
        "fix_ids": ["fix:iam-scope:IamRole:support-bot"],
        "evidence": [{ "source": "infra/compute.tf", "line": 16, "detail": "aws_lambda_function_url.support_bot" }]
      }
    ],
    "fixes": [
      {
        "id": "fix:iam-scope:IamRole:support-bot",
        "title": "Scope s3:GetObject on role support-bot",
        "summary": "The policy grants s3:GetObject on \"*\", so support-bot can reach every s3 resource ...",
        "kind": "iam_policy",
        "evidence": [{ "source": "infra/iam.tf", "line": 13, "detail": "aws_iam_role_policy.support_bot_s3" }],
        "finding_keys": ["agent-data:..."]
      }
    ]
  }
}
  • source.type is local_repository for scan and aws_account for scan-aws, which also reports account_id and regions. Its audit lists every API call (service, operation, region, at) instead of files.
  • Evidence source is a repository-relative path or an ARN.
  • Finding key values are stable across scans. basalt check compares them to decide what is new.
  • Fix kind is one of iam_policy, tool_code, credential, mcp_config, trust_policy, data_exposure.

Known limits

We publish what the analysis does not cover yet, so you know what a clean result does and does not mean.

  • Organization SCPs, session policies and resource policies on the target (other than public bucket policies) are not part of the IAM evaluation yet. When they could change the result, the edge is marked uncertain.
  • S3 Block Public Access is checked at the bucket level only, not the account level.
  • Lambda function URLs are checked on $LATEST only.
  • Public entry points come from Lambda function URLs. API Gateway, load balancers and other front doors are not read yet.
  • Data stores are S3, DynamoDB and Secrets Manager. RDS and EKS are not covered.
  • Infrastructure as code is Terraform only. CloudFormation and CDK are not parsed yet.
  • Terraform values built from variables, locals or modules stay unresolved.
  • Data sensitivity comes from names and tags. Basalt never reads data, so it cannot see what a store actually contains or how many records it holds.
  • AWS is the only cloud. GCP and Azure are not supported.
>_

Questions about how Basalt handles your data and access? Read Security. Ready to scan your platform? Get started.