Skip to content
M&L AI

AttackMap

Changelog

What changed in each release, from mlaify/AttackMap's CHANGELOG.md at v0.6.2.

All notable changes to AttackMap will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.6.2 - 2026-11-04

Fixed — packaging

  • attackmap[all] couldn’t be installed from the package index: its analyzers were git URL dependencies, which uv and pip refuse for a package installed from an index (“URL dependencies must be expressed as direct requirements”). The extra now pins each analyzer’s release on the same index (attackmap-analyzer-python==0.2.0, node-service 0.3.0, …); plugins_lock.py keeps the commits for -m auto-install. scripts/update_plugin_lock.py writes both. Install with uv tool install "attackmap[all]" --index mlaify=https://pkg.mlaify.io/pypi/simple/ --index-strategy first-index (README and the external-analyzer guide no longer give the git-URL form, which now needs the index for the analyzers).

0.6.1 - 2026-11-04

Changed — packaging

  • plugins_lock.py and the [all] extra pin the analyzers’ 0.2.0 release commits (node-service 0.3.0), the commits their signed v* tags point at. Those releases are on the mlaify package index, and they need attackmap>=0.6.0.

0.6.0 - 2026-11-04

Changed — packaging

  • Released to the mlaify package index (https://pkg.mlaify.io/pypi/simple/). A v* tag now builds the wheel and sdist and attaches them to a GitHub release (.github/workflows/release.yml); the index picks them up within six hours.
  • plugins_lock.py and the [all] extra still pin the analyzers’ 0.1.0 commits (node-service 0.2.0), as in 0.5.x. Their 0.2.0 releases (node-service 0.3.0) need this core release (attackmap>=0.6.0), so they follow it onto the index, and the pins move to them in a later release.

Added

  • Detection rules for cloud and Kubernetes misconfigurations (#351). Findings from the cloud / IaC rule ids (#334) now come with Sigma rules that watch the control plane for the risky setting being applied. One rule per template per repository; each rule’s selection needs the risky attribute itself, never a bare event name.

    • Kubernetes audit log (product: kubernetes, service: audit):
      • privileged workloads; privilege escalation or dangerous capabilities; hostPath or host namespaces (pods, workload templates, CronJob job templates; containers, init and ephemeral containers);
      • LoadBalancer/NodePort services; RBAC grants with * or escalate/bind/impersonate;
      • service accounts using pods/exec or pods/attach (create, or get for WebSocket exec); cluster-admin bindings; secret list/watch outside the control plane.
    • CloudTrail: security groups opened to 0.0.0.0/0 or ::/0; RDS PubliclyAccessible; EC2 instances launched with public IPs (aws_instance; launch templates aren’t mapped); S3 Block Public Access deleted or set with a protection off, or public ACLs; S3 bucket policies with a * principal; EKS endpoints made public, or created without endpointPublicAccess: false (public by default).
    • AWS rules apply only to records on the aws platform with a matching Terraform resource type. Kubernetes rules apply only to Kubernetes/Helm records (and RBAC from Terraform). Kinds with no control-plane event (unencrypted storage, unpinned images, Dockerfile issues, missing security contexts) show as uncovered.
    • Ids fold in the repository name. Rules pass attackmap.detections.sigma and pySigma.
  • CloudMisconfig contract record for IaC plugins (terraform#4, iac#1). A new ScanResult.cloud_misconfigs list of CloudMisconfig(kind, file, line, resource, platform, context, escalation_path, evidence_text, severity, confidence), exported from attackmap.sdk with the CLOUD_MISCONFIG_KINDS and CLOUD_MISCONFIG_PLATFORMS vocabularies. Until now the Terraform and IaC plugins could only report misconfigurations as auth/entrypoint hints, which never became findings.

    • Core emits one finding per (kind, severity), so each instance keeps its own severity in SARIF (level, security-severity) and in --fail-on-new-high: adding a low /var/log mount to a repo with a high docker.sock mount doesn’t trip the gate. The kind’s usual severity keeps the plain title; other tiers add “(low-severity instances)”. Confidence is the max over the tier’s records.
    • Records merge on (kind, file, line, resource, evidence), and a true duplicate keeps the more severe report. Every record is a location, so two resources at one (file, line) stay two instances.
    • SARIF rule metadata (name, description, default level, security-severity) for these rules comes from the rule’s spec and usual severity, not from whichever severity tier is listed first.
    • The tiering is shared, not cloud-specific, so other families whose records carry a severity can reuse it:
      • threat_model.tiered_findings / tier_title build the per-tier findings and titles.
      • _TIERED_SPEC_TABLES + tiered_rule_defaults drive the SARIF rule metadata for any tiered rule.
      • merge.keep_more_severe is the shared upgrade-on-duplicate hook (the same one AppSurfaceIssue uses).
      • threat_model.instance_locs gives each record its own location with an instance key (resource + evidence; not serialized) that is mixed into its fingerprint. So a record’s fingerprint doesn’t move when another instance is added on the same line, or when a confidence or tier changes. Locations without an instance (every other rule) keep their fingerprints.
      • check_tiered_tables rejects a kind registered in two tiered spec tables or a missing default tier, at import.
      • analyzers.KIND_VOCABULARIES / drop_unknown_kinds drop unknown str kinds one record at a time, with a note.
    • Changed (all rules): SARIF and fingerprints. SARIF results are now deduplicated on (file, line, instance fingerprint) instead of (file, line), so two fingerprinted locations at a repeated (file, line) are two results. Existing rules never had two locations at one (file, line), so their SARIF is unchanged. Instance fingerprints now count repeated lines per (rule, file, line content) across all findings, not per finding, so two findings of one rule on the same line (e.g. a high and a low mount in one compose flow list) no longer share a fingerprint that Code Scanning would merge. A location that was the only one of its (rule, file, content) keeps its fingerprint.
    • kind is typed str: a record with a kind this core doesn’t know is dropped on its own (with an UnknownCloudMisconfigKind note under analyzer_errors; --strict-analyzers fails), and the rest of the plugin’s result is kept. platform is validated. Free-text fields are redacted, folded to one line and capped.
    • 24 new rule ids, each with CWE/OWASP/ATT&CK ids:
      • Cloud (Terraform): iac-module-unpinned, public-network-access, public-cluster-endpoint, plaintext-listener, api-no-authorizer, unencrypted-storage, public-data-access, public-invoker.
      • Workloads (Kubernetes, Helm, compose, Dockerfile): privileged-container, privilege-escalation-allowed, container-runs-as-root, missing-security-context, host-path-mount, host-namespace-shared, dangerous-capability, security-profile-disabled, exposed-service, unpinned-container-image, dockerfile-remote-add, dockerfile-curl-pipe-shell.
      • Kubernetes RBAC: rbac-wildcard, rbac-escalation, rbac-secret-access, cluster-admin-binding.

    The taxonomy registry gains CWE-269, 311, 668, 732 and 1188 and ATT&CK T1133, T1530, T1548.001, T1552.007, T1609 and T1611 (and CWE_NAMES is sorted again). Core emits no records itself, so scans without an IaC plugin are unchanged.

  • App attack-surface contract for platform plugins (#188, #190). A new AppSurfaceIssue signal (exported from attackmap.sdk, merged by (kind, file, line) into ScanResult.app_surface_issues) lets the Apple analyzers (Swift and Objective-C, in attackmap-analyzer-swift) report app-side issues; core turns each kind into one aggregated finding at its worst instance’s severity, tagged app-surface. Nine rules, each with CWE/OWASP taxonomy: apple-ats-insecure-loads (CWE-319), apple-custom-url-scheme (CWE-939), apple-webview-js-injection (CWE-94/79), apple-insecure-secret-storage (CWE-312/922), apple-pasteboard-secret (CWE-200), apple-hardened-runtime-exception (CWE-693/114), apple-debuggable-entitlement (CWE-489), apple-xpc-unvalidated-peer (CWE-306/284) and apple-dynamic-dispatch (CWE-470/114). Native format-string and command-exec constructs in Objective-C use the CodeWeakness kinds shared with the C/C++ plugins (#189). kind is typed str: a record with a kind this core doesn’t know is dropped and noted under analyzer_errors (the rest of the plugin’s result is kept). Records merge by (kind, file, line, evidence); on a duplicate the more severe, then more confident, report wins (merge.keep_more_severe). Core itself emits none of them, so scans without the plugin are unchanged.

  • carthage dependency ecosystem (#188). Cartfile.resolved pins are git repositories, so --cve looks them up under OSV’s SwiftURL name like SwiftPM packages (the repository URL in evidence_text).

  • Hummingbird role checks (#187). The function-level authorization check (BFLA) counts Hummingbird’s RolePolicy(...) and PermissionPolicy(...) (in a route’s guards or handler) as role checks, so admin routes gated by AuthorizationPolicyMiddleware(RolePolicy(.admin)) aren’t reported.

  • One package, several analyzers (#190). -m <name> and the install hint now resolve an analyzer entry point to the official package that registers it (-m objc installs attackmap-analyzer-swift), and a package that provides several requested analyzers is installed once.

  • Sigma rules for CI workflows, leaked AWS keys and code weaknesses, written to attackmap-detections/ (#251, part 2).

    • CI workflows: one GitHub audit-log rule (product: github, service: audit, workflows.created_workflow_run) per risky trigger: pull_request_target (pr-target-checkout, cache-poisoning-pr-target), issue_comment (issue-comment-pr-checkout), workflow_run (workflow-run-artifact-poisoning) and pull-request triggers on self-hosted runners (self-hosted-pr). These are hunting rules (level low) for runs started by outside actors. oidc-on-untrusted-trigger gets a CloudTrail rule for AssumeRoleWithWebIdentity with a GitHub OIDC subject ending in :pull_request. Hygiene kinds (unpinned actions, token permissions, curl | sh, script injection) have no run-level audit-log signal and show as uncovered.
    • Leaked AWS access keys (hardcoded-secret, secret-in-git-history): a CloudTrail rule on userIdentity.accessKeyId. AttackMap keeps no secret values, so the rule carries a REPLACE_WITH_LEAKED_ACCESS_KEY_ID placeholder and explains how to keep the rotated key as a permission-less tripwire.
    • Code weaknesses without a route: app-wide query-string marker rules for prototype-pollution (__proto__, constructor[prototype]; JavaScript/TypeScript files only) and graphql-exposure (introspection: __schema{, __type(; never __typename).
    • Route scoping also reads Express :name after - or . (/flights/:from-:to, /plantae/:genus.:species, and optional /report.:fmt?) as parameters, and gives no rule to routes whose parameters carry an inline regex (:id(\d+), {id:[0-9]{2}}).
    • Repository-wide rules (workflows, keys, weaknesses) fold the scanned directory’s name into their id, so two repositories with the same workflow file names don’t share a rule id.
    • With --format json or all, every rule is written to <output>/attackmap-detections/<slug>-<id8>.yml (a correlation rule goes in the same file as a second YAML document) with an index.json. The folder is used only if AttackMap created it (it holds .attackmap-detections.json); an existing folder of that name that isn’t ours is left alone, with a message, and nothing is written. Rule files from an earlier run that are no longer produced are removed only if that marker recorded them. The folder carries the output marker, so -o . never scans the rules back in.
  • Deployable Sigma rules for detection opportunities (#251, part 1). Taint, SSRF, BOLA and webhook findings now produce detection opportunities that carry a Sigma rule, not only prose. Each rule has a deterministic UUID (stable across reruns), status experimental, ATT&CK tags from the taxonomy registry, CWE and ATT&CK references, a log source, the fields it needs, false-positive notes and sample positive/negative events. Rules come from YAML templates in attackmap/detections/ keyed by rule id and filled with the route, method, parameter and file.

    • Injection routes (sql-injection, subprocess-shell, ssti, eval/exec/code-injection, expression-injection, jndi-injection, unsafe-deserialization, path-traversal, nosql-injection, ldap-injection, xpath-injection, header-injection, open-redirect, ssrf): web server access log, the route plus that class’s payload markers. When the traced source names the query parameter, the markers are anchored on it with a regex; a path-parameter source is matched in the path, and an untraced input on a parameterized route in both. Body sources note that access logs don’t record bodies.
    • SSRF also gets a network_connection rule for connections to the instance metadata service and a CloudTrail rule (level medium) for EC2 instance-role credentials used from outside AWS, AWS Internal and private ranges.
    • BOLA (bola-read/bola-modify): a base rule for successful object accesses on the route plus a Sigma value_count correlation (one principal, 50 distinct objects in 10 minutes).
    • The webhook opportunity (#243) gains a rule for POSTs that carry no provider signature header.
    • Routes are matched exactly, or with a |re anchored at both ends where each parameter matches within one path segment and only a final * spans segments; /users/:id? also matches /users, and :name is a parameter only at the start of a segment (/v1/things:batchGet is literal). The regexes are linear: adjacent parameters merge, and a parameter excludes the character that follows it. Literals are escaped outside [A-Za-z0-9_/-]. No rule for routes without a literal word of two letters or digits (/, *, /:id, /:a.:b), with more than 8 parameters or 2 optional segments, non-ASCII, or longer than 160 characters. One rule per rule id and route, at most 5 routes per rule id and 60 per-route rules per report, shared round-robin across rule ids. A rule only covers locations its finding still covers after suppression. A rule whose sample events don’t behave is dropped.
    • The defensive review lists the rules and adds a detection-coverage table (finding class, rule present yes/no, log sources). The review JSON (schema 1.5.0) adds detection_coverage and the new optional opportunity fields sigma, sigma_correlation, logsource, fields_required, false_positive_notes and test_events. The LLM prompt pack leaves the rule bodies out.
    • attackmap.detections.sigma checks rules structurally and matches events without pySigma. Tests also validate every rule with pySigma (new dev dependency, not a runtime one). CWEs are in references, not tags, because Sigma has no cwe tag namespace.
  • Spring Security csrf_disabled in Java and Kotlin config (#341). csrf-disabled now also flags the Kotlin DSL csrf { disable() }. A new whole-repo pass (webhardening.find_jvm_csrf_disabled) runs the Spring patterns over .java and .kt files. Before this, they were never checked, because core’s per-file scan reads only Python/JS/TS/Go/PHP, so csrf().disable(), csrf(c -> c.disable()) and csrf(AbstractHttpConfigurer::disable) went unreported too. Test/vendored files and comment lines are skipped. A config that also sets SessionCreationPolicy.STATELESS (the usual bearer-token API) is reported at low severity and says so.

  • Native C/C++ weakness records (#189). Analyzer plugins can now emit function-local C/C++ weaknesses as CodeWeakness records with five new kinds, each with a rule id, CWE/OWASP/ASVS mapping and remediation:

    • unbounded-copy (CWE-120/121/242): strcpy/strcat/sprintf/gets/scanf("%s") without a size bound.
    • format-string (CWE-134): a non-constant printf-family format.
    • native-command-exec (CWE-78): system/popen/exec* with a non-constant command.
    • tainted-copy-length (CWE-805/787): memcpy/memmove with an input-controlled length.
    • alloc-size-overflow (CWE-680/190): an allocation size multiplied from input with no overflow check.

    CodeWeakness gains optional confidence, source_kind and source_line fields. For the native kinds the emitter sets severity and confidence per instance, from whether an input reaches the sink in the same function. Each evidence line names the input it traced. CodeWeakness, CODE_WEAKNESS_KINDS and SupplyChainIssue are now exported from attackmap.sdk. The emitters are the C and C++ analyzer plugins.

    • The native kinds use the shared severity tiering (tiered_findings): one finding per (kind, severity), the high tier with the plain title and the others suffixed, so each tier has its own finding id and fingerprints differ per instance. SARIF rule metadata comes from the default tier.
    • Native records merge on (kind, file, line, evidence) with merge.keep_more_severe, which now also carries source_kind / source_line from the winning report (set or not).
    • source_kind outside the known inputs is kept as other; source_line needs a source_kind.
    • Core-aggregated kinds keep their fixed severity and medium confidence and their (kind, file, line) merge identity.
  • Changed: CodeWeakness.kind and DependencyHint.ecosystem are typed str (#189, #191). Records with a value this core doesn’t know are dropped one by one and noted under analyzer_errors (UnknownCodeWeaknessKind, UnknownDependencyEcosystem; --strict-analyzers fails), instead of one unknown value discarding the plugin’s whole result. analyzers.KIND_VOCABULARIES entries may name the field they check (default kind).

  • cmake dependency ecosystem (#191). DependencyHint.ecosystem accepts cmake for CMake FetchContent / find_package dependencies. It is inventory-only: there is no OSV database for it, so --cve reports these dependencies as not CVE-checked.

  • Webhook signature verification (#243, part B). A new webhook_verification.py reads each webhook route’s handler, the helpers it calls (one hop, including imported modules) and JS .use(...) middleware in the same file, and sets webhook_verification on the attack surface: verified, weak_compare, no_replay_guard, absent or unknown, with the provider and the line it rests on.

    • Recognized verifiers: Stripe constructEvent/construct_event/ConstructEvent, GitHub (@octokit/webhooks verify/verifyAndReceive), Slack SignatureVerifier/NewSecretsVerifier, Twilio validateRequest/RequestValidator/twilio.webhook(), Shopify webhooks.validate, Svix/Standard Webhooks verify and sns-validator. Hand-rolled HMACs (createHmac, hmac.new, hmac.New, hash_hmac) count as verified only with a constant-time compare (timingSafeEqual, compare_digest, hmac.Equal, hash_equals…).
    • New rule webhook-weak-verification (medium): an HMAC compared with ==/===/!==/bytes.Equal, or a scheme that signs a timestamp (Slack, Stripe, Svix) verified by hand without checking it.
    • Behavior change: public-webhook (high) no longer fires on a webhook that verifies its signature, and says why when it does fire. A verified webhook no longer anchors the “External event spoofing” attack path, and the path’s weak point now cites the verification state and line.
    • New detection opportunity for absent or weak verification: alert on webhook requests with a missing or invalid signature, a stale timestamp, or a repeated event id.
    • A signature computed over JSON.stringify(req.body) is noted on the surface: it breaks verification of genuine events.

    Weak verification scores as the webhook_exposure benchmark class. New case: express-webhooks-demo.

  • OAuth client and session checks (#243, part 2). auth_protocol.py gains six rules:

    • oauth-missing-state (medium): a hand-built authorization request (response_type in a URL, object, dict, PHP array or Go url.Values, with a client_id nearby) that sends no state, and passport OAuth2 strategies without state: true or a state store.
    • oauth-implicit-flow (medium): response_type=token (including id_token token).
    • oauth-redirect-uri-loose (high): prefix, suffix or substring checks on a redirect URI (startsWith, in, strings.HasPrefix, str_starts_with…) and wildcard redirect_uris. Exact membership in an allow-list (ALLOWED_REDIRECT_URIS.includes(uri)) is not flagged.
    • oauth-missing-pkce (medium): a code-flow request from a public client (client-side code with no client secret) without code_challenge, plus usePKCE: false, pkce: false and the plain method.
    • session-fixation (medium): a user identity stored in the session without req.session.regenerate() (Express, only when the repo uses a server-side store such as express-session) or session_regenerate_id() (PHP).
    • session-weak-secret (high): a session secret under 32 characters hard-coded in express-session, cookie-session or @fastify/session options, a Flask secret_key/SECRET_KEY (including an env fallback like environ.get("SECRET_KEY", "dev")), or a gorilla/sessions store key.

    OAuth rules join the token_auth benchmark class. Session rules are a new session_auth class. New case: express-oauth-session-demo.

    • Fix: the #243 line-comment check treated // inside a URL (https://…) as a comment and skipped the rest of the line.
  • JWT verification checks (#243, part 1). A new per-file pass (auth_protocol.py) judges JWT calls with the semantics of the library the name is bound to (jsonwebtoken, jose, PyJWT, python-jose, authlib, golang-jwt/jwt-go). Five new rule ids:

    • jwt-alg-confusion (high): verification with no pinned algorithm list. Covers jsonwebtoken verify without algorithms (key not an HMAC secret), PyJWT decode without algorithms=, and a golang-jwt keyfunc that never checks token.Method (no WithValidMethods).
    • jwt-unverified-decode (high): identity claims (sub, role, user…) read from jsonwebtoken decode, jose decodeJwt, python-jose get_unverified_claims or golang-jwt ParseUnverified, in a file that never verifies a token.
    • jwt-claims-unchecked (medium): ignoreExpiration: true, verify_exp: False, WithoutClaimsValidation(), or authlib claims never validate()d.
    • jwt-header-trust (high): the unverified header’s kid in a file path or an interpolated SQL string, or its jku/x5u URL fetched.
    • jwt-weak-secret (high): a hard-coded HMAC secret shorter than 32 characters at the sign/verify call (jose TextEncoder().encode('…'), Go []byte("…")).

    These and jwt-weakness carry a new token-auth tag, scored by the benchmark as the token_auth class (new case express-jwt-demo).

    • Behavior change: the “JWT verification” control is now credited only by a verifying call with a pinned algorithm (or jose jwtVerify), reported as a jwt_verified auth hint. Before, a jsonwebtoken mention, jwt.decode/jwt.sign, or a /jwt/... route path was enough, so a repo that only decoded tokens looked protected.
  • More dependency ecosystems (#255). DependencyHint.ecosystem and Vulnerability.ecosystem now accept maven, nuget, conan, vcpkg, gem, cocoapods, hex and pub. The set is exported from attackmap.sdk as DependencyEcosystem / DEPENDENCY_ECOSYSTEMS.

    • --cve maps these ecosystems to OSV’s Maven, NuGet, ConanCenter, vcpkg, RubyGems, Hex and Pub. swiftpm now maps to OSV SwiftURL, keyed by the normalized repository URL, so Swift deps are CVE-checked for the first time.
    • CocoaPods has no OSV database. Those deps are inventory-only, and --cve says so instead of implying “no vulns”.
    • New lockfiles: composer.lock (supersedes the same-directory composer.json), NuGet packages.lock.json and gradle.lockfile.
  • Supply-chain risk beyond CVEs (#247). A new offline pass (supply_chain.py) runs after the SBOM and emits SupplyChainIssues, which become findings with six new rule ids:

    • dependency-confusion: internal-looking names (internal_prefixes in .attackmap.yaml, the repo’s own npm scope as an unscoped prefix, internal-/-internal) installed via --extra-index-url or a Poetry supplemental source, or unscoped on npm. --extra-index-url alone is reported low.
    • typosquat-candidate: Damerau-Levenshtein ≤ 1 from a bundled, versioned list of popular PyPI/npm names (popular_packages.py).
    • mutable-vcs-dependency: git refs that aren’t commit SHAs (pip/PEP 508, Poetry, uv, npm github:, Cargo git =), unhashed archive URLs, and Go replace to forks or paths outside the repo.
    • install-script: direct npm deps with hasInstallScript, and the repo’s own install hooks piping curl into a shell.
    • unlocked-manifest: npm/Composer/Pipenv without a lockfile, and lockfile entries without integrity.
    • insecure-registry: http:// registries/indexes, strict-ssl=false, --trusted-host.

    A risky dependency imported on a taint path adds an exploitability factor. The pass makes no network calls.

    • Fix: a bare git+https://… line in requirements.txt no longer produces a dependency named git.
  • Deeper secret detection (#252).

    • New credential families: AWS secret access keys (next to their key name), Azure storage AccountKey= / SharedAccessKey=, Azure AD client secrets, Datadog, Heroku, DigitalOcean and Vault tokens, .npmrc _authToken / _auth, Docker registry auth, GCP service-account key files, and credentials in URLs (https://user:pass@host).
    • A keyword-proximity rule catches short literal assignments such as password = "…" at confidence 0.5. It is skipped in tests and vendored code, with a placeholder/env-lookup deny-list. A finding backed only by this rule has medium confidence.
    • Config now catches DB_PASS=-style keys. The credential dotfiles .npmrc, .pypirc, .netrc, .dockercfg, .git-credentials and id_rsa/id_ed25519 are scanned, still honoring the symlink policy. Provider patterns now run on config files too.
    • --secrets-history N (opt-in) scans the patches of the last N commits (all refs) with the same detectors. It reports secret-in-git-history with the introducing commit and whether each secret is still_in_head. It is bounded (5000 commits, 64 MB, timeout), and git runs hardened (no hooks, pager, fsmonitor, external diff, textconv, lazy fetch, or system/global config).
  • Security. gitref (--suppress-from-ref) uses the same hardened git invocation, so a scanned repo’s .git/config can no longer run commands through diff.external/core.fsmonitor.

  • Fix. Evidence redaction now also masks an unterminated quoted value (a snippet cut at its length cap). Config key/value evidence masks the value explicitly, so a long JSON-escaped private key can’t leak through a truncated snippet.

  • CI workflow scanner gaps (#246). Eleven new workflow rules: workflow_run_artifact_poisoning (high, CWE-829, when the triggering run’s artifacts or head are executed), issue_comment_pr_checkout, github_script_injection, github_env_injection ($GITHUB_ENV/$GITHUB_PATH/$GITHUB_OUTPUT, directly or via an env: binding), default_token_permissions, oidc_on_untrusted_trigger, secrets_inherit, checkout_persist_credentials, cache_poisoning_pr_target, docker_action_unpinned and curl_pipe_shell.

    • Trigger trust: on pull_request_target, issue_comment and workflow_run, secret_in_run and broad_permissions are raised to high. git fetch … pull/N/head and gh pr checkout in run: count as PR checkouts.
    • Free-text inputs.* of workflow_call (medium) and workflow_dispatch (low) workflows count as script injection. The new workflow_run.head_branch/head_commit.*/display_title contexts do too.
    • Reusable-workflow uses: jobs are now pin-checked. Docker action.yml images are checked as well as composite steps.
    • Workflow findings now carry an external/cwe/cwe-N tag.
    • Behavior change: uses: docker://img:tag without @sha256: was previously skipped and is now reported. A workflow with no permissions: block gets a low finding (medium on an untrusted trigger).
  • Intra-procedural taint propagation (#239). The taint pass now follows a request value through local assignments to the sink, so the two-line u = request.args["u"] → requests.get(u) shape is caught in Python (via ast), JS/TS, Go, PHP and Java (brace-balanced, strings masked).

    • Per-language source catalog in taint_sources.py: query, body, path params, headers, cookies, uploaded filenames and queue/event payloads, plus framework-bound handler parameters (Flask/FastAPI route params, Spring @RequestParam, NestJS @Query()). Env/argv are low-weight and never satisfy the request gate.
    • Only the dangerous argument counts (the URL, not the JSON body; the query, not its bound params), and a URL whose literal prefix pins scheme and host is not SSRF.
    • TaintChain gains source_kind, source_line and flow (source → propagation → sanitizer/guard → sink steps); taint findings cite each traced route and emit SARIF codeFlows on the sink result.
    • New flow-confirmed findings sql-injection and path-traversal, raised only when a source is traced into the query / path. Path-traversal and SSRF sinks for JS (fs.readFile*), Go (os.Open/ReadFile, http.Get/NewRequest), PHP (fopen/file_get_contents/readfile, cURL) and Java (Paths.get, new File, Files.read*, openStream, HttpRequest.newBuilder), and Java SQL / Runtime.exec / ProcessBuilder sinks (same-file only — Java imports are not resolved).
    • attackmap bench: injection recall 0% → 100% at 100% precision; new labelled case express-docs-demo. Before/after in evals/benchmark/HISTORY.md.
  • Taint sink registry and catalog expansion (#240). Sinks now live in a declarative, schema-validated registry, src/attackmap/taint_sinks.yaml. Each entry has a kind, languages, regex, gate, dangerous argument(s) and a required CWE.

    • Plugins contribute sinks through the attackmap.taint_sinks entry-point group. attackmap.sdk exports TAINT_SINK_PLUGIN_GROUP, TAINT_SINK_KINDS and validate_taint_sink. An invalid plugin entry is skipped with a warning.
    • New sink kinds, each with a finding, a CWE/OWASP taxonomy entry and an exploitability base score: path_traversal (dynamic_open stays as its schema-compatible alias), zip_slip, code_injection, expression_injection, jndi_injection, ldap_injection, xpath_injection, header_injection and regex_injection.
    • New signatures:
      • Path traversal: Flask send_file, Express res.sendFile/download (without root:), Go http.ServeFile, PHP include/require.
      • Zip-slip: tarfile extractall without filter=, adm-zip, unzipper.
      • Command execution: Node destructured exec/execSync, spawn(…, {shell: true}), os.system, .NET Process.Start, Ruby system/backticks/%x/Open3/Kernel#open.
      • Code and expression injection: new Function, vm.run*, string setTimeout, importlib.import_module/__import__/getattr, Ruby send/constantize, SpEL/OGNL/MVEL/ScriptEngine.eval, JNDI lookup.
      • LDAP and XPath search filters and expressions.
      • Response headers.
      • User-controlled regular expressions.
      • Deserialization: jsonpickle, dill, shelve, .NET BinaryFormatter/TypeNameHandling, Jackson default typing, SnakeYAML without SafeConstructor, XStream.
    • C# is indexed with flow analysis. Ruby is indexed with the same-call request-token gate only.
    • Behavior change: PHP exec() is now reported as command execution, not code exec. A bare JS exec( counts only in files that load child_process.
  • Route-level auth in the plugin contract (#256). Route gains auth ("required" / "anonymous" / "unknown"), guards and guard_evidence; the last is redacted like other evidence. When a plugin declares the state:

    • the unauthenticated state-changing-route finding uses it;
    • attack-surface auth_signals use it instead of the ±40-line file window, so a [AllowAnonymous] route no longer inherits its neighbour’s [Authorize];
    • on duplicate route keys, a known state fills in an unknown one.

    "unknown" (the default) keeps the existing resolution, so older plugins are unaffected.

  • CWE, OWASP, ASVS and ATT&CK ids on every finding (#250). A registry in attackmap/taxonomy.py maps each rule id to its CWE ids, OWASP Top 10 2021 / API Security Top 10 2023 categories, ASVS 4.0.3 requirements and ATT&CK techniques, including sub-techniques such as T1552.001, T1552.005, T1195.001/.002 and T1505.003. All 63 weakness rules are mapped, including the #246 workflow rules. A test fails for any rule without a CWE and an OWASP id, so new detectors must register. The ids appear in:

    • attackmap-report.json, as a new taxonomy field on each finding;
    • SARIF: external/cwe/cwe-NNN tags, a CWE helpUri, rule relationships, a run-level CWE taxonomies entry, and cwe/owasp/asvs/attack rule properties;
    • defensive-review.json (schema 1.4.0), as weaknesses[].rule_id and weaknesses[].taxonomy;
    • the PR comment, diff Markdown and defensive-review.md finding lines, e.g. (CWE-918 · A10:2021 · API7:2023).
    • Workflow finding CWE tags now come from the registry and use CodeQL’s zero-padded form, e.g. external/cwe/cwe-078. script-injection is tagged CWE-78 and CWE-94.
  • Deeper web and API hardening checks (#244). Five new rules, all registered in the taxonomy:

    • CORS now looks at the origin policy as well as credentials:
      • a reflected origin with credentials is cors-wildcard-credentials (high). This covers req.headers.origin echoed into Access-Control-Allow-Origin, an origin callback that always calls cb(null, true), FastAPI allow_origins=["*"], and Django CORS_ALLOW_ALL_ORIGINS / CORS_ORIGIN_ALLOW_ALL. Allow-list-guarded reflection is ignored.
      • a wildcard or reflected origin without credentials is the new cors-wildcard-origin (low).
      • the new cors-untrusted-origin catches allow-lists that admit attacker origins: an unanchored or .*example origin regex (taking into account that Starlette full-matches and django-cors-headers anchors only the start), endsWith('example.com')/includes(...) checks, and an allowed null origin. It is high with credentials, otherwise medium. Exact allow-lists and anchored regexes are not flagged.
    • graphql-no-query-limits (low): an Apollo, express-graphql, Yoga, Mercurius, Strawberry, graphene GraphQLView or gqlgen server built in a file with no depth, complexity or cost limit.
    • graphql-batching (medium): allowBatchedHttpRequests: true or Yoga batching.
    • csrf-unprotected-session (medium): cookie-session auth (express-session, cookie-session, Django SessionMiddleware, Flask-Login) with state-changing routes and no CSRF defence anywhere in the repo. A CSRF-token middleware, @csrf_protect, CSRFProtect/FlaskForm, or a SameSite=Strict/Lax session cookie counts as a defence. A commented-out CsrfViewMiddleware does not.
  • Function-level authorization (#242, OWASP API5). New rule bfla-privileged-route (high, CWE-285, API5:2023): “Privileged route checks authentication but not role (BFLA)”. It fires on an admin route that resolves an authentication control but has no role or permission check on the route, its router, or the function it calls (requireRole, @roles_required, IsAdminUser, hasRole, @Secured, isAdmin, …). A plugin-declared guard list counts. Routes with no authentication at all stay with unauth-state-change.

  • BolaCandidate carries authn_evidence and confidence (#242).

  • Field-level mass assignment (#242, OWASP API3). A new whole-repo pass (mass_assignment.py) reports mass-assignment when a bind that looks scoped still lets a client set a privileged model field (is_staff, is_admin, is_superuser, role, permissions, balance, verified, tenant_id, …; camelCase too). The model is resolved across files.

    • Django REST Framework serializers and ModelForms with fields = "__all__" or an exclude denylist, and CreateView/UpdateView with fields = "__all__". read_only_fields, extra_kwargs read_only, read-only declared fields and exclude are subtracted. Django’s User (imported or via get_user_model()) and AbstractUser/PermissionsMixin subclasses carry is_staff/is_superuser/groups/user_permissions. A form wired into a ModelAdmin is skipped (staff-only).
    • A SQLModel table=True model taken as a FastAPI POST/PUT/PATCH body. Depends()/Security() parameters and plain pydantic DTOs aren’t.
    • A Spring @Entity (Java or Kotlin) bound by @ModelAttribute or @RequestBody on a write mapping. @JsonIgnore/READ_ONLY fields (for JSON bodies), an @InitBinder setAllowedFields and setDisallowedFields are honoured.
    • Mongoose: the request body as the update document, as in findByIdAndUpdate(id, req.body), updateOne(filter, { $set: req.body }), findOneAndUpdate, replaceOne.

Changed

  • The omeka-s analyzer repo is now attackmap-analyzer-omeka-s (#380). It was attack-map-analyzer-omeka-s. The plugin lock, the [all] pin, update_plugin_lock.py and the docs use the new name, and the pinned commit is unchanged. GitHub redirects the old URL, so existing installs keep working.
  • ATT&CK mapping for findings comes from the rule id (#250). It is no longer substring-matched against title and evidence, which mapped “monkey” to T1552, cursor.execute to T1059 and “author” to T1078. Findings without a registered rule id (some plugin findings) fall back to whole-word matching on the title only. Detection opportunities link findings by their rule’s ATT&CK techniques and list them in a new related_rule_ids field; the fuzzy title-word match is gone. Insight mappings use T1685 (Disable or Modify Tools) in place of T1562, which ATT&CK v19 revoked. Tactic names follow ATT&CK v19 (for example “Stealth” and “Defense Impairment”).
  • analyze validates every option before scanning (#229).
    • Enum options are checked up front and the error lists the accepted values: --progress-format (now including tty), --format, --llm-provider/-speed/-effort/-backend. A typo in an LLM option used to surface only after the full scan and report writing. In the --llm and --remediate paths it could be swallowed by the broad error handling.
    • Behavior change: these now fail with exit 2 instead of being silently ignored or clamped:
      • --verify, --verify-votes, --hunt-lenses, --hunt-rounds or --hunt-budget without --hunt
      • --diff-output without --baseline
      • out-of-range values: --verify-votes 0, a --hunt-lenses value outside 1–6, --hunt-rounds 0, a negative --hunt-budget
  • Sanitizers are bound to the flow (#239). A sanitizer counts only when the tainted value passes through it (u = secure_filename(u)) or a dominating guard checks a value derived from it (if ip.is_private: abort(400)); it is no longer enough for one to appear anywhere in the file. re.escape / html.escape no longer neutralize SSTI, and ipaddress.ip_address( alone no longer neutralizes SSRF. The file-level table remains only as a fallback for sinks outside any analyzable function.
  • JS bare import specifiers (require("path")) no longer resolve to a same-named sibling file in the taint import graph.
  • BOLA/IDOR separates authentication from authorization and checks the handler, not the file (#242).
    • Authentication (@login_required, router.use(requireAuth), Depends(get_current_user), a req.user reference) no longer suppresses a candidate. It raises it to high confidence instead, since that combination is the classic authenticated IDOR.
    • Object-level checks (an ownership comparison, a caller-scoped query, a policy call, @PreAuthorize with an #id or principal expression) are looked for within the route’s own handler span, plus one hop into a function it delegates to: a same-file helper, an imported service function, or a Django view named in the URLconf. A sibling handler’s check no longer suppresses the route.
    • Role-gated routes (admin tooling) are not reported as BOLA.
    • Unauthenticated writes that unauth-state-change already reports are not repeated as BOLA.
    • BOLA findings skip tests/, fixtures/ and examples/ paths like the other route findings.
    • One BOLA attack path is emitted per affected file (up to 3), not only for the top candidate.
    • pk counts as a resource-id parameter.
    • Bench: bola recall 0.5 → 1.0 at precision 1.0.
  • unauth-state-change covers privileged routes (#242). Admin routes, and routes whose own path is /internal/… or /debug/…, now get the same per-route control-chain resolution as public_api routes. Bench: unauth_state_change recall 0.4 → 1.0 at precision 1.0.
  • handler_span.span_bounds / span_text accept a max_span cap. A span no longer includes the decorators stacked on the next route.

Fixed

  • Concise arrow handlers are taint-walked (#398). A route handler written as an arrow with an expression body, (req, res) => db.query('...' + req.query.id), wasn’t seen as a function, so its request input was never traced and the sink was dropped below the finding threshold; the same code in braces was reported. Concise bodies are now scoped like braced ones (to the expression’s end: the first , / ; / closing bracket at their depth, or the statement end), so they’re found, and a handler’s flow no longer spills into its neighbours’ sinks.

  • -m python keeps the built-in python-web analyzer (#401). -m runs only the named analyzers, and attackmap-analyzer-python is additive over python-web, which extracts FastAPI and Flask routes, so -m python alone found no routes on a FastAPI or Flask app. Selecting an official plugin that is additive over a built-in now selects that built-in too (analyzers.ADDITIVE_OVER_BUILTIN; today only python → python-web).

  • Pyflakes-clean core, with ruff in CI (#231). ruff check (rule set F, configured in pyproject.toml) now passes on src/attackmap.

    • typing.get_type_hints works on the public functions in analyzers.py: Callable and ScanProgress are now real imports. threat_model.py imports DatabaseHint and ExternalCall, which its annotations named.
    • Removed unused imports and locals, and the unused _WEB_DEPENDENCY_TOKENS (JS detect() never checked frameworks).
    • Docstrings now match behaviour: DefaultAnalyzer is the built-in Go/PHP analyzer, not a no-op; merge_analyzer_signals doesn’t dedup routes, calls or secrets; progress documents the NDJSON renderer.
  • unauth-state-change recognises handler-level session checks in JS/TS (#348).

    • A route’s own handler that resolves the caller’s identity and rejects when it’s missing is no longer reported. The handler is either inline, clamped to its closing brace, or the function the registration passes by reference.
    • Identity calls: getSession(, getServerSession(, a zero-argument auth(), currentUser(, supabase.auth.getUser(, req.session.user/userId, or the passport req.user / req.isAuthenticated().
    • Recognition is structural (handler_auth.py):
      • Strings and comments are masked first.
      • The if condition must be exactly !session[.member].
      • The if must be a top-level statement of the handler body: not an else if, not the body of a braceless if or loop. A try block, or an IIFE that is the body’s last statement, still counts as top level. It must also come before any state-changing call (persistence methods, plain createX(/deleteX( calls, $executeRaw).
      • The then-branch must start with return (not return next(), return next(null), return await next() or return void next()) or throw. A bare redirect( doesn’t count, because Remix and SvelteKit 1 return it rather than throw. next(err) / res.status(401|403) count only when followed by return.
      • Any reassignment of the session variable or one of its members (=, ||=, ??=, destructuring, session.user ||= guest) disqualifies it. A destructured data/error is never tracked.
      • A try block counts as top level only if its catch rethrows or returns.
      • A lookup with a fallback (?? {…}, || x, a ternary, .catch(…)) doesn’t count.
      • A handler passed as obj.method is followed only when obj is imported, and only when it is the route’s sole handler (post(path, a, b) isn’t followed).
      • The lookup must be the initializer itself (not !req.user or a query mentioning req.user); destructured aliases bind the alias; whole auth() / .auth.getUser() results need a member test; member tests must name identity (!session.user.isGuest doesn’t count).
      • A rejecting return returns nothing, a literal, a response, a redirect or an error, not a hand-off call. More writes count before a gate (queue.add(, axios.post(, redis.del(, fs.unlink(, fetch().
      • Masking keeps template interpolations visible and skips regex literals.
      • Before the gate, any awaited call other than the lookup or a request read (req.json(), schema.parse…), or a return that hands off (return handleGuest(req, res)), disqualifies it. next('route') is a hand-off, not a rejection. Inline handlers after other middleware (upload.single('f')) aren’t trusted; validators, body parsers, cors and rate limiters before them are.
      • Remix (getSession(request.headers.get('Cookie'))) and h3 (getSession(event, …)) sessions need a member test, as .then(…) and Object.assign(session, …) count as fallbacks.
      • The one-hop definition lookup counts lines on \n only, like route lines.
      • Async lookups must be awaited (an un-awaited getServerSession(...) is a Promise and never falsy). next(...) counts as a rejection only with an error (next(new Error(…)), next(err), next(createError(401))); response returns are a fixed list of response methods. Writes named with an inner verb (findOneAndRemove(, processRefund() count before a gate.
      • An inline handler’s body must open in the registration’s last argument; validators count as read-only middleware only by name (validateBody, zValidator), not validateAndSave.
      • With several registrations on one line (.get(a).post(b), app.get(…); app.post(…)), only the route’s own method is followed, and only if unambiguous.
      • A finally after a catch is checked too; initializers wrapped over several lines are read whole (getCurrentUser(\n req,\n) ?? guest); request-read awaits are exact names (parse, validate, json), not prefixes; a literal return must be the whole value (return 0 || guestCheckout() doesn’t count); a callback passed to a wrapper isn’t an IIFE.
      • Middleware is checked in .route('/x').post(mw, h) chains too. A try whose catch or finally writes isn’t transparent. A bare !session.id doesn’t count (anonymous visitors have a session id too).
      • Synchronous writes and events (.run(, .emit(, invalidate() count before a gate; a boolean about the lookup (req.user == null) isn’t a lookup; a rejection that itself writes (return json(await db.x.deleteMany())) doesn’t count.
      • Cost: each file is masked once, inline handlers are clamped to their registration, the first unsafe call is found once per handler, and one-hop gates are cached per function.
      • Assigning req.user / req.session… before a direct check disqualifies that check.
    • When in doubt the finding stays.
    • Real world: documenso’s POST /api/ai/detect-fields and /detect-recipients are no longer flagged. juice-shop’s /dataerasure stays flagged, because its lookup is app-specific. cal.com, dub, zulip, saleor, netbox and BookStack are unchanged.
  • BFLA recognises role-check guard names from plugins (#349).

    • Guards named restrictTo, permit, checkRole(s), rbac, acl, allowRoles, @Permissions, @CheckPolicies, @MembershipRoles, @Pbac or Roles in Route.guards count as role checks. The match is on the whole guard name, so generic words never count when they appear in handler code.
    • Arguments are judged by an allow-list. A role-list guard counts only when every listed role is a string literal, an enum-like member (Role.ADMIN, UserRole.Admin: a capitalised owner named for roles or permissions) or an ALL_CAPS constant naming admin, administrator, owner, superuser, superadmin, systemadmin, sysadmin, staff, root, moderator or manager (case-insensitive, ROLE_ stripped, _/- ignored, so SUPER_ADMIN and super-admin count). Names without arguments, other identifiers, members of lower-case receivers (doc.owner, req.query.admin), spreads and Object.values(...) don’t count. authorizeRoles(...) / authorize_roles(...) are judged by their arguments too. Role calls in strings or comments don’t count.
    • router.use(restrictTo('admin')) keeps its inner call, and counts only for later routes on the same router (registrations chained across lines included); a path-scoped use('/billing', …) / use(ADMIN_PREFIX, …) or a commented-out one doesn’t count. A NestJS class-level @Roles(Role.Admin) (decorators directly above the class) covers that class’s routes up to its closing brace, unless a method sets its own role metadata. this.router.use(...) in class-based controllers counts like router.use(...). A FastAPI role dependency on admin = APIRouter(...) covers only @admin.… routes (include_router(..., dependencies=…) covers routes in other files, so it doesn’t count for the file it’s in); A first use argument named like a path (ADMIN_PREFIX, adminPath) or a bare non-middleware name (apiV1) is path-scoped; middleware names (protectRoute, firebaseAuth, cors) and '/' are not. A router mounted on a parent (app.use('/admin', adminRouter)) after the parent’s role use, or with a role check in the mounting call (app.use('/admin', requireAdmin, adminRouter)), is covered. Decorator blocks are read by parenthesis balance, so multi-line decorators and blank lines don’t hide a method’s own @Roles, and code above a class isn’t read as its decorator. Python handler text is masked as Python. A use whose first argument isn’t plainly middleware (API_PREFIX + '/x', arrays, path.join(…), PATHS.ADMIN) is treated as path-scoped. A NestJS handler span stops before the next method’s decorators. FastAPI router dependencies=[Depends(check_roles('admin'))] are judged the same way.
    • BOLA follows the same role check: a privileged role list (@Roles(Role.Admin), restrictTo('admin')) exempts a route, while RolesGuard alone or a non-privileged role (@Roles(Role.Editor)) no longer does. NestJS metadata guards (RolesGuard, PermissionsGuard, PoliciesGuard) don’t count on their own, in guards or in source: they enforce @Roles(...) metadata and allow everyone without it, so only a privileged @Roles(...) list makes the route role-checked. role: guards count only with a privileged role name.
    • Token-scope checks (guard.check, require(d)Scopes) and authentication-only guards (requireAuth, JwtAuthGuard, ApiAuthGuard) don’t count.
  • Fewer false hard-coded secrets (#385). On the Bluesky repos almost every hardcoded-secret location was noise. It now skips four shapes:

    • IPLD/IPFS content identifiers. CIDv1 base32 (bafy…, bafk…, bafyrei…) and CIDv0 Qm… are public content hashes, so the high-entropy fallback ignores them.
    • Empty KEY= values. A config secret must have its value on the same line. Before, an empty PDS_JWT_SECRET= (or an action input token: followed by description:) took the next line as its value.
    • GitHub Actions permission levels. id-token: write, contents: read and the like are not secrets.
    • Random-looking literals in test and mock files. The high-entropy guess is skipped there. Provider-shaped credentials (AWS keys, PEM blocks, verified JWTs, …) in tests are still reported.

    Effect on hardcoded-secret locations: atproto 221 → 7, social-app 45 → 3, pds 2 → 0. Every removed location on ten corpus repos was hand-checked.

  • attackmap modules lists the omeka-s analyzer (#259). The remote module listing kept only repos named attackmap-analyzer-*, so attack-map-analyzer-omeka-s never appeared. Official repos from the plugin lock are now listed under their package name (omeka-s), the name that -m and the pinned installer already resolve (#347).

  • Sinks count request data only in their dangerous argument (#375). The legacy same-call token gate read the whole call, so request data in a harmless argument raised the sink. It now reads only the argument(s) the sink declares (args). getattr(request.user, 'ldap_username') is no longer code-injection, because only the attribute name is dangerous. re.split(r'…', request.GET['q']) is no longer regex-injection, because only the pattern is dangerous. open-redirect treats a target built from reverse("view"), reverse_lazy, url_for("view", …) or resolve_url as an internal path, including with request data in its kwargs or an appended query (reverse('home') + '?next=' + x, f-strings). Sinks declaring all, the receiver or the rest of the statement keep the old gate.

  • Webhook verification reaches framework guards, constructor middleware, PayPal and shared secrets (#366, part 2). Four kinds of verifying receivers came out absent; they are now reached:

    • NestJS @UseGuards(Guard) on the handler or its controller class is followed to the guard’s canActivate.

    • A Laravel 'Controller@method' route reaches the action and the handle of every middleware the constructor adds with $this->middleware(X::class). When that call sits under an if (Laravel Cashier adds it only when a secret is configured), a verified result becomes unknown with a note, since verification isn’t guaranteed.

    • PayPal’s /v1/notifications/verify-webhook-signature API with a verification_status check is recognised, but as unknown (provider paypal), never verified.

    • A header secret (Authorization: Bearer …, X-…-Token) whose value is compared in constant time is likewise unknown (provider shared_secret), never verified.

      For both, whether a failed check actually rejects the request can’t be established safely from keywords (review of #374), so they keep raising public-webhook. The notes say what was found.

    • Laravel ->only([...]) / ->except([...]) are honoured, and a non-statement $this->middleware (&&, ternary) counts as conditional.

    • A provider verifier inside try whose catch only logs or is empty is unknown (the failure is swallowed).

    Supporting changes:

    • Functions and classes imported from workspace packages are followed.
    • Stripe-php’s WebhookSignature::verifyHeader counts as a Stripe verifier.
    • authz._block_from ignores braces inside string and template literals, and ends a body that opens and closes on one line (canActivate() { return true; }).

    Real world: cal.com’s Vercel receiver is now verified; cal.com PayPal, dub r2 (shared secret) and cashier-stripe (conditional middleware) are unknown with notes.

  • A Python URLconf route’s taint walk starts at its own view (#372). urls.py imports every view module, so walking from it gave every route every view’s sinks at hop 1. On zulip, 95 routes reached cursor.execute in corporate/views/installation_activity.py, which fed bola-read. A registration that names its view now starts the walk from that view’s module, and at hop 0 only that view’s def/class counts. Recognized registrations: path, re_path, url, rest_path, DRF register, Flask add_url_rule, aiohttp add_get/add_route/…. Relative imports and dotted references (views.Export.as_view()) resolve. A third-party view (TemplateView.as_view()) runs no repo code, so only sinks in the URLconf itself count. An include() or a view defined in the URLconf keeps the old walk. zulip: 110 → 60 chains, bola-read 48 → 21 routes. JS/TS already starts from the handler’s module (#107).

  • Scan output no longer depends on the Python hash seed (#353, #369). On a large repo, findings and taint chains changed from run to run: on zulip, regex-injection appeared in some runs and not others, and scan.taint_chains went from 13 to 110. Two places let set iteration order decide the outcome. The taint import walk took neighbours from a set, and its 40-file visit cap then decided which files it reached. The lockfile resolver seeded its BFS from a set of roots, which decided the via path reported for a shared transitive dependency. Both now iterate in sorted order. Directory walks in the lockfile, SBOM, authz and suggest scanners are sorted too, as are database nodes in the architecture graph and the supply-chain own-scope check.

  • Webhook verification reaches further (#366, cheap parts). These fixes stop real receivers that verify their payload from coming out absent:

    • TypeScript ESM specifiers (from './webhook.js') resolve to the .ts source.
    • Curried middleware factories (({ secret }) => (req, res, next) => {…}) and multi-line parameter lists keep their bodies in the hop resolver (authz._block_from).
    • A bounded second hop follows the helper the middleware calls.
    • timingSafeCompare / secureCompare / constantTimeEquals-style helpers count as constant-time.
    • export { default } from "…" route files are assessed through the module they re-export, including workspace packages found by their package.json name.
    • A handler that only answers 404/410 is marked inert and no longer raises public-webhook.

    Real world: atproto ×2 and cal.com btcpayserver are now verified; cal.com alby goes from absent to unknown; cal.com’s two 404 stubs are inert.

  • Webhook management routes are no longer webhook receivers (#365). Any path containing webhook was a webhook surface, so CRUD for an app’s own outgoing webhooks got webhook_verification: absent and raised public-webhook. Only clear management shapes now leave the category; any doubt keeps a route a receiver. The shapes:

    • a stored webhook addressed by an id param that is read, deleted or acted on (GET/DELETE /webhooks/:webhookId, POST …/:id/test);
    • a PUT/PATCH on it only alongside such a sibling, a user-authenticated route, or a handler that persists a webhook URL with a secret or events;
    • a GET on the collection of such members;
    • a POST to that collection only when user-authenticated or persisting config;
    • settings paths only for CRUD shapes;
    • unambiguous fused words (webhooklogs, add_webhook), never on a POST;
    • a tRPC router;
    • a model CRUD DRF ViewSet on the webhook collection.

    Tenant params (:accountId), provider params, shared-secret/signature guards, hub.challenge/crc_token handshakes, and any candidate whose handler reaches signature verification all stay receivers.

  • BFLA follows config-declared routes to their controller, and reads ACL and Symfony role checks (#343). bfla-privileged-route read the route’s span in route.file as its handler. For routes declared in configuration that span is config, never the controller.

    • Symfony YAML/XML routes are now followed to the controller method through the repo’s composer PSR-4 map: controller: App\\Controller\\X::y, _controller under defaults, XML controller="..." and <default key="_controller">, or an invokable X. The usual role check then runs on the method plus the attributes on it and on its class.
    • Config routes whose controller can’t be resolved (Laminas / Omeka PHP config arrays, bundle resources) are not flagged on the strength of the config alone, unless the span holds an inline closure handler. Plugin Route.guards with role semantics are consulted first.
    • New role checks: Omeka’s ->userIsAllowed(, and isAllowed( on an ACL-like receiver ($acl, getAcl(), $permissions, $authorization, $auth). Rate limiters and validators don’t count.
    • Symfony grants are parsed rather than pattern-matched: IsGranted, isGranted, denyAccessUnlessGranted, is_granted and #[Security(...)] expressions. A grant counts unless its attribute is one every session has (IS_AUTHENTICATED*, PUBLIC_ACCESS, IS_ANONYMOUS, IS_REMEMBERED, IS_IMPERSONATOR, ROLE_USER, ROLE_ANONYMOUS, ROLE_PREVIOUS_ADMIN); whitespace and the named attribute: form are handled. A bare ROLE_* mention (setRoles(['ROLE_ADMIN']), the promote-a-user BFLA case) is not a check. Plugin access_control ^/x: ROLE_* guards count when they name such a role.
    • Real world: Omeka S (with the omeka-s plugin) drops 7 /admin/site/* false positives, and symfony/demo (with php-web) drops 7 /admin/post/* ones guarded by #[IsGranted(User::ROLE_ADMIN)].
  • A plugin route replaces core’s built-in twin of it (#337). Core’s built-in extractors read one call at a time. When a plugin joined a group prefix (api := r.Group("/api"), app.use('/api', router), a Blueprint url_prefix), the same registration landed twice, because the merge keys routes on (path, method, file). The twin had the wrong path and no auth. A guarded POST /api/users also appeared as an unguarded POST /users and was flagged unauth-state-change. Now a plugin route on the same file and line supersedes a built-in route there when they are plausibly one registration: the plugin path ends with the built-in path at a segment boundary (parameters compared as placeholders, so :id matches {id} and <int:id>), and the methods match or the built-in said ANY. A built-in / only matches a plugin /, or the plugin’s single route when the line has one route of each kind. A plugin ANY doesn’t replace a specific built-in method, and a plugin route on another line isn’t a twin. Only built-in routes are dropped, and “built-in” is decided by the analyzer that produced the result, not by a provenance string a plugin could set. Two plugin routes, two built-in routes, and different registrations on one line all survive, and so does every route of a Laravel Route::resource line. The plugin’s path, method, auth and provenance are what’s left, including for identical routes the built-in saw first. Matching indexes plugin routes per line by (method, suffix), so thousands of routes on one line stay linear. Real repos with the go#13 and php-web#12 branches: gitea 432 twins → 0 (1250 → 818 routes), woodpecker 85 → 0, koel 1 → 0, golang-gin-realworld 1 → 0.

  • The large-file linearity test no longer depends on runner speed, and a quadratic step it was hiding is fixed. test_many_matches_in_a_large_file_scan_in_linear_time had a fixed 10 s bound and flaked on macOS runners (10.7-11 s). It now compares 6000 handlers with 750 (8x) and fails above 20x, with a floor on the small run and a best-of-three re-check. That catches a quadratic step that dominates the scan (a Python-level per-match scan measured 55x), but not a cheap, diluted one: #218’s content.count line numbers reach only ~16x. So two per-call tests time 3000 lookups near the end of a file 4x larger than another and fail above 2x: one for line_number (measures ~1.1x; #218’s version ~4x) and one for taint flow’s _enclosing (~1.0x; the old loop ~4x). Profiling the flake found that _enclosing scanned every function in the file for each sink, which is quadratic: about 1 s of the 6000-handler scan. It now climbs the function nesting from a sorted index.

  • Test helper packages are test code (#345): unittest/, unittests/, unit_tests/, testdata/, testutil(s)/, test_utils/, testhelpers/ and test_helpers/ directories join is_test_file’s test-directory list, so the heuristic passes skip them like tests/. gitea’s models/unittest/mock_http.go (its unit-test mock HTTP server) no longer raises path-traversal and ssrf findings. mock/ and mocks/ stay in scope, because a real /api/mock route or a mock-server product can live there. ATTACKMAP_INCLUDE_TESTS=1 still scans them.

  • The scanner no longer hangs on a JS file that mounts the same name at two paths. _express_prefixes iterated to a fixpoint that never came when app.use('/a', mw); app.use('/b', mw) (Hono/Express middleware on several paths) or a mount cycle appeared, so attackmap analyze spun forever on documenso (apps/remix/server/router.ts, #363). The first mount now wins, self-mounts are skipped, and propagation is bounded by the number of mounts.

  • aiohttp @routes.route("PATCH", "/x") (verb first, then path) is read as PATCH /x instead of the Flask-style false route GET /PATCH (#342). Flask’s path-first @app.route("/x", methods=[...]) is unchanged.

  • Route auth signals count only JWT verification (#325). The scanner’s jwt auth hint fired on jwt.decode/jwt.sign/jwt.encode, on the bare words jsonwebtoken/PyJWT, and on the first jwt substring anywhere in a file (an import, a comment, jwtSecret). A route next to any of them reported auth_signals=['jwt'], and a decode-only POST /token/refresh dropped from high to medium risk. jwt now comes only from calls and middleware that verify: jwt.verify( (an unpinned one still checks the signature; jwt-alg-confusion reports the missing algorithms), jwtVerify( (jose, @fastify/jwt), firebase/php-jwt JWT::decode, golang-jwt jwt.Parse/ParseWithClaims, express-jwt/koa-jwt, flask-jwt-extended (@jwt_required, verify_jwt_in_request), Tuupola JwtAuthentication, NestJS JwtAuthGuard and passport-jwt JwtStrategy. The jwt_verified hint from #324 also counts as JWT auth for public-webhook and unauth-outbound-integration.

  • A hash or HMAC over the request body is no longer a NoSQL query or a mass assignment (#330). crypto.createHmac(…).update(req.body), the standard webhook signature check, raised nosql-injection and mass-assignment. Both now skip an .update( called on a Node crypto hash/HMAC/cipher/sign object (in a chain, across lines, or through a variable assigned from one), a hashlib/hmac.new object, CryptoJS or forge. Model.update(req.body) and collection.updateOne(req.body, …) are still reported. Sink entries gain an optional receiver_unless regex, checked against the receiver of the matched call. The express-webhooks-demo bench case hashes req.body directly again.

  • Admin login endpoints are authentication surfaces, not unguarded admin actions (#328). POST /admin/login raised unauth-state-change and exposed-admin-route (both high), asking for an auth control on the step that authenticates the caller. Login, logout, password-reset, auth/token and OAuth-callback routes under an admin prefix are now auth surfaces (brute force and lockout belong to weak-auth-route and the authentication attack path), when every path segment before the action is admin or auth vocabulary (/admin/api/v2/auth/login). /admin/users, /admin/login-as/:id and /admin/users/:id/logout are still admin actions. The admin_action_without_auth insight follows the same rule.

  • weak-auth-route precision (#340). A route is an auth surface only when a path segment starts with an auth term and the term ends there, is followed by a non-letter (login-form, oauth2, authCallback) or one of its own word forms (authenticate, authorize, sessions, tokens). Substring matching made /api/authorities, /authors, /ssh/authorized_keys and /signing-key.gpg auth routes. The finding is now evaluated per surface: it lists the auth routes without auth signals instead of disappearing when any one auth route is guarded, which hid jhipster’s POST /api/authenticate.

  • open-redirect no longer fires on a redirect to a fixed host (#327). A redirect target whose literal prefix pins the scheme and host and closes the authority with /, ? or # (`https://sso.example.com/authorize?${params}`, f"https://sso.example.com/?next={q}") only lets request data into the path, query or fragment, so it can’t send the user to another site. A tainted scheme or host, request data right after the host (https://site.example${x}, which can become .evil.com or @evil.com), a protocol-relative '//' + host, or a javascript:/vbscript:/data: scheme is still reported.

  • Sequelize field allow-lists clear mass-assignment (#242). Model.update(req.body, { fields: [...] }) and create(req.body, { fields: [...] }) were flagged as wholesale binds; the fields option is the allow-list, so they no longer are.

  • No redundant line: null finding locations. A file cited with a line in one evidence entry (sink at app.py:23) and without one in another (import path: app.py) produced an extra line-less location, which showed up in SARIF and changed the finding’s fingerprint set. The line-less citation is now dropped whenever the same file is cited with a line anywhere in the finding.

  • Workflow findings point at the right step. Line numbers came from the first matching line in the file, so identical steps in different jobs (two uses: actions/checkout@v4) all pointed at the first job. Lines are now looked up within the current job’s and step’s span, found from the YAML text. Flow-style YAML falls back to the old file-wide search.

  • defensive-review.schema.json now allows weaknesses[].locations[].fingerprint, which the report has emitted since #222.

0.5.1 - 2026-10-02

Security release. Upgrade if you scan untrusted repositories with --suppress-from-ref (v0.5.0).

Security

  • --suppress-from-ref could run commands from a scanned repo’s git config. v0.5.0 found lines added since the ref with an unhardened git diff <ref> -- <file>. That diff reads the working tree, so it runs any filter.<driver>.clean command that .gitattributes names. It also honoured core.fsmonitor, diff.external, the pager and textconv drivers, and system/global git config. A hostile .git/config, for example in a downloaded repo or tarball, could therefore execute arbitrary commands.
    • Every git call now forces those settings off and ignores system/global config. It also never prompts and never lazily fetches.
    • Old content is read with git cat-file blob, which applies no filters, and diffed against the working file in Python.
    • A test configures clean/smudge/textconv/diff-command/fsmonitor drivers and asserts that none of them run.
  • Redaction gap: a quoted secret in an evidence snippet that was cut at its length cap had no closing quote, so it escaped masking. It is now masked.

0.5.0 - 2026-10-02

Completes the v0.5.0 plan (#260): scanner robustness, an enforced plugin contract, false-positive fixes across 12 analyzer plugins, and macOS app and docs fixes. Install with brew install mlaify/tap/attackmap or the [all] extra.

Upgrading from 0.4.x — behavior changes:

  • Opt-in analyzers (#221). Plugins with enabled_by_default=False run only with -m <name>: node-service, php-web, php-laminas, omeka-s, atproto, c, cpp, swift. When one matches a repo, analyze says so on stderr.
  • Run order (#221). Analyzers run in (priority, name) order, and duplicate signals keep the lower-priority-number analyzer’s version.
  • Suppressions (#224, #238). Inline attackmap:ignore is line-scoped (use attackmap:ignore-file[rule] for file-wide). vendor/* no longer matches nested files (use vendor/**). With --suppress-from-ref, suppressions added in a PR are pending, not applied.
  • SARIF (#230). One result per finding instance, with new partialFingerprints. Code Scanning will re-key existing alerts once.
  • iac hint names. dockerfile_base_image_unpinned and compose_port_binding_all_interfaces now carry :<image> / :<binding> suffixes.

Added

  • attackmap capabilities prints a stable JSON description of the CLI: version, commands, every long option analyze accepts, whether analyze takes multiple repo paths, and the progress protocol version. GUIs and wrappers can feature-detect from it instead of scraping analyze --help (AttackMap-mac#4).
  • attackmap.sdk.fs: a shared repo walker for plugins (#253).
    • iter_repo_files(root, suffixes=, names=, skip_dirs=DEFAULT_SKIP_DIRS, max_bytes=, include_tests=, on_skip=) prunes skip dirs by repo-relative name, so a repo under /build/... is no longer silently empty. It never follows symlinks out of the repo, skips AttackMap output dirs, and caps file size.
    • read_source() decodes UTF-8, then cp1252, then latin-1, returns None for unreadable or binary files, and never raises.
    • Also added: rel, line_of, line_snippet. All are re-exported from attackmap.sdk.
    • All 15 official plugins now use it, and plugins_lock.py / the [all] extra pin those commits. That fixes a plugin silently reporting nothing for a repo checked out under a directory named build, out, vendor, target and so on. Plugin metadata also follows the #221 conventions: python 10, java-spring 60, php-laminas 70, atproto 90, omeka-s 160. The experimental c, cpp and swift analyzers are now opt-in (-m).

Changed

  • Analyzer priority and opt-in are honored (#221).
    • Run order is (metadata.priority, name) across built-ins and plugins, and merge is first-seen-wins in that order. A plugin with a lower priority than the built-in (e.g. the python plugin at 15 vs python-web at 20) now wins duplicate routes and hints, and its richer signal is kept. Before, built-ins always ran first.
    • enabled_by_default=False is opt-in. Such analyzers run only when selected with -m. When one matches a repo but isn’t selected, the CLI prints Opt-in analyzers match this repo but were not run: … Enable with -m …. Behavior change: node-service, php-web, php-laminas, omeka-s and atproto currently declare enabled_by_default=False and no longer run unless requested.
    • attackmap modules lists analyzers in run order with their priority and opt-in status. modules --json adds priority and experimental.
  • Suppression governance (#238).
    • Trusted suppressions only. With --suppress-from-ref <ref>, only suppressions that already exist at that git ref apply. Suppress-file entries, and inline directives on lines added since the ref, are reported as pending in the CLI output and in a new PR-comment “Suppressions” section, and are not applied. --allow-pr-suppressions applies them anyway, still listed. On pull_request runs the GitHub Action fetches the base branch and passes it, so a PR can no longer silence its own findings.
    • Expiry and ownership. Suppress entries gain expires:, owner: and ticket:, and inline directives accept until=YYYY-MM-DD. An expired suppression is no longer applied and prints a warning; --strict-suppressions makes it exit 2.
    • Globs: * now stays within one directory and ** crosses directories. Behavior change: vendor/* no longer covers nested files; use vendor/**. Globs that match everything (*, **, **/*, /) are rejected.
    • Path suppressions apply per instance, so a tests/fixtures/** entry can’t hide a secret in app/.
  • Inline attackmap:ignore is line-scoped (#224). A directive used to suppress every finding in its file, so a comment about an MD5 checksum hid a HIGH unauthenticated-route finding in the same file, along with any routes added there later.
    • A directive now covers only the instance on its own line or the line below. Partly covered findings stay active, with just that instance removed.
    • The new attackmap:ignore-file[rule] is the explicit file-wide form.
    • A bare attackmap:ignore with no rule list is warned about.
    • Behavior change: existing directives placed away from the flagged line, or on line-less plugin findings, need to move to the flagged line or switch to ignore-file.
  • Newly suppressed findings are not “resolved” (#224). In a baseline diff, a finding that was active in the baseline and is suppressed now is reported as newly suppressed, in the CLI summary, diff Markdown and PR comment. Before, a PR adding its own suppression made a HIGH finding look fixed. The new --fail-on-new-suppression flag gates on it.
  • SARIF identity and locations (#230).
    • One result per finding instance. Each result has a single primary location, and the sink comes first for taint findings.
    • Stable alert identity. partialFingerprints is now attackmapInstance/v1, a hash of the rule id and the #222 instance fingerprint. It used to be raw evidence text, so Code Scanning closed and reopened alerts, losing dismissals, whenever a sibling appeared or a snippet changed. Evidence moved to properties.evidence.
    • No fabricated line 1. Locations without a known line have no region, and attack-path steps no longer carry an empty URI.
    • URIs are forward-slashed, percent-encoded and relative to %SRCROOT%, declared in originalUriBaseIds without the local path.
    • Validated: output is checked against the official OASIS SARIF 2.1.0 schema in tests (jsonschema was added to the dev extra).
  • --fail-on-new-high catches new instances of existing findings (#222). A finding aggregates every site of one issue type, and its id hashes the title, so a PR that added a second command-injection route, or a second hard-coded secret in a new file, used to diff as “0 new” and pass the gate.
    • Every finding location now carries a fingerprint: rule id, file and the normalized source line, with whitespace removed and literals masked, and no line number.
    • The baseline diff reports the new instances of persisted findings, and a new instance of a HIGH finding fails the gate.
    • Line drift and reformatting don’t produce new instances.
    • The diff Markdown, PR comment and CLI summary list the new instances.
    • Baselines from before 0.4.32 have no fingerprints. They still diff at finding level, with a note asking you to regenerate them.
  • Every finding has a stable rule id (#223). Finding.rule_id is a short, explicit id such as hardcoded-secret, insecure-tls, bola-modify or script-injection. Spec-driven families use their kind. The id doesn’t change when a title is reworded.
    • It is now the SARIF ruleId and the value matched by suppress rule: and attackmap:ignore[...]. Code Scanning re-keys alerts once on upgrade: the old ruleId was the slugified title.
    • The old title-slug selectors keep working, with a deprecation warning that names the new id.
    • Suppress-file entries and inline directives that match no finding are now reported as Suppression warning: … matched no findings.
    • The new attackmap rules [--json] lists all 61 core rule ids.
    • The documented examples used hardcoded-secret-literals, which never matched anything; they now use the real id, and a test checks them.
    • Finding ids, and therefore baselines, are unchanged.

Fixed

  • Plugin false-positive fixes (Phase 5), now pinned in plugins_lock.py and the [all] extra.

    • go: Header.Get/Query.Get and similar calls are no longer routes. _test.go and testdata/ aren’t scanned.
    • java-spring: bare @GetMapping, path=, array values and class-level prefixes now work, through a proper annotation parser.
    • dotnet: fixes ASP.NET route templates, adds MapGroup prefixes, and adds per-route [Authorize]/[AllowAnonymous]/RequireAuthorization. GetConnectionString is no longer a secret.
    • terraform: open egress is no longer reported as ingress. aws_iam_policy_document wildcards, .tf.json files and secrets in .tfvars are now analyzed.
    • c / cpp: each .h file is owned by exactly one of the two plugins. C detect() requires a .c file. Drogon routes are now found.
    • node-service: only router receivers produce routes, so req.get, cache.get and axios.get don’t. HTTP clients become external calls.
    • php-web, php-laminas, omeka-s: Slim and config routes need a router or path, secret names are case-sensitive, jwt needs a JWT library, and navigation route names are no longer routes. php-web detect() needs PHP.
    • iac: handles multi-stage Dockerfiles and Dockerfile.* / compose.* variants. Finds ENV/ARG secrets, exposure from ports:, and docker.sock/cap_add/pid privileges.
    • atproto: only query, procedure and subscription lexicons become routes (GET/POST/WS). Stricter detect(), plus per-handler auth hints.
    • With all 19 analyzers enabled, the 26 plugin fixtures and the core and macOS app repos scan with no analyzer errors.
  • .env.example variables are no longer “hard-coded secret literals”. Any SecretHint whose kind wasn’t env_reference counted as a hard-coded literal, including the iac plugin’s env_template hints, which are variable names declared in .env.example-style templates. That produced a HIGH hardcoded-secret finding for every templated DATABASE_PASSWORD=. env_template now joins the env-reference finding.

  • Typed plugin signals and the auth_hints shim (#258).

    • The official plugins now emit service, edge, entrypoint, protocol and framework metadata as typed hints, with line and evidence_text. They no longer put it in auth_hints.
    • _auth_filtered_scan is now a compatibility shim. It still drops such metadata from older or third-party plugins and logs a one-time deprecation warning per analyzer. It also keeps each auth hint’s line and evidence_text, which it used to throw away.
    • Chain findings require evidence for every hop. service-trust-chain needs an actual inter-service edge. atproto-trust-chain, service-trust-chain and framework-sink-chain all need the sink in the chain’s own service or controller, not merely somewhere in the repo. Attack paths are unchanged. Without this, a single-service Express app got a HIGH “Inter-service trust chain” finding from a service name guessed from its path once that hint stopped being discarded.
  • Reports can be written under macOS /tmp and /var again when the cwd is /. The #228 guard refused every symlinked ancestor of the output directory inside the cwd. A process started from Finder or by XCTest runs with cwd /, so /var (→ /private/var) and /tmp counted, and writes to the system temp dir failed with refusing to write reports through a symlink: /var. Now only symlinks that lead outside the cwd are refused, so a committed reports -> /elsewhere link is still blocked. Found by the new AttackMap-mac live CLI contract test.

  • A failing analyzer no longer aborts the scan (#220). If a plugin’s analyze() raised, or returned None or a non-result, the whole run died with a traceback, built-in coverage included. Now the analyzer is skipped and logged. The console prints Analyzer '<name>' failed and was skipped: …, and attackmap-report.json lists it under scan.analyzer_errors (name, exception type, redacted message). Dict results are validated as a ScanResult. --strict-analyzers restores fail-fast for plugin development and CI.

  • Exploitability scoring fixes (#226).

    • Surfaces match by file. When two services exposed the same POST /run, both taint chains took the first service’s surface, so an unauthenticated RCE path was scored “auth present at entry” and ranked HIGH instead of CRITICAL. Chains now use the surface from their own route file, and fall back to a route- or file-only match only when it’s unambiguous.
    • Python imports are line-scoped. The import regex spanned newlines, so import os swallowed the next line and every later import was lost, which broke CVE-on-path fusion for nearly every Python file. as aliases are handled, and common import→PyPI name mismatches (yaml→PyYAML, PIL→Pillow, bs4→beautifulsoup4, …) are mapped.
  • Routes from .mjs, .cjs and .jsx files (#227). The JS route extractor only ran on .js/.ts/.tsx, so ES-module and CommonJS servers (server.mjs, index.cjs) had no routes, attack surfaces, auth analysis or taint seeds. One shared JS_TS_SUFFIXES constant now drives the scanner, analyzers, taint, authz (GraphQL) and fusion passes.

  • Whole-repo passes run once per scan (#219). Taint, SBOM, workflow, authz and anomaly passes used to run inside each built-in analyzer, so a mixed Python/JS repo ran them up to three times, reported duplicated dependencies and workflow issues, and emitted several done progress events. They now run once in analyze_repository, over every analyzer’s merged signals, so cross-language taint and authz see all routes. scanner.scan_repo keeps its behavior; the new scan_files and run_repo_passes split the per-file and whole-repo stages.

  • Large files with many matches no longer scan in quadratic time (#218).

    • Line numbers were computed with content.count("\n", 0, offset) once per match, across 6 detectors. They now come from a per-file line index with binary search (srcpaths.line_number). On a 1.9 MB file with 14k routes, scan_repo drops from 12.2 s to 3.1 s.
    • analyze_authz re-ran its query-parameter scan over the whole handler file once per route. It now runs once per file: 3,000 routes in one file went from 32.7 s to 0.5 s.
  • An unreadable file or a failing repo no longer aborts the run (#217).

    • One chmod 000 file used to crash scan_repo with PermissionError. It is now skipped and recorded in scan.limitations.
    • The console summary ends with a “Not analyzed: …” line covering unreadable, oversized and symlinked files.
    • In fleet mode, a repo whose analysis raises is reported, listed under failed in fleet-summary.json and fleet-summary.md, and skipped. The run continues with the other repos and exits 1 at the end. Usage errors still stop the run.
  • AttackMap no longer scans its own reports (#216). With the default -o reports inside the repo, a second attackmap analyze . re-ingested the first run’s JSON. Evidence such as DB URLs and secret names produced new findings, including a new HIGH hard-coded-secret.

    • Every report directory now gets a .attackmap-output marker, and walks never descend into a directory containing one, whatever it’s called (-o . is safe).
    • AttackMap’s report filenames are also excluded from config scanning, which covers report directories written by older versions.
  • Repos inside a build/ or dist/ directory are scanned (#215).

    • Skip directories were matched against the absolute path. A checkout under any build, dist, out or .venv directory, such as Bitbucket Pipelines’ /opt/atlassian/pipelines/agent/build, therefore scanned 0 files, and the JS analyzer didn’t even run.
    • Skip directories are now matched relative to the scanned root, using one shared set (srcpaths.SKIP_DIRS, which now includes venv, target and .tox). Skipped trees are pruned during the walk, so they aren’t walked at all.
    • The CLI warns when a non-empty directory produced no scanned files.
  • Malformed suppress files no longer crash the CLI (#225). paths: 5, a non-UTF-8 file or an int-typed id used to raise. Every problem is now a Suppression warning: naming the entry, and only that entry is skipped. The cases covered:

    • non-string paths
    • a mapping-shaped suppress:
    • ids YAML read as numbers (with a hint to quote them)
    • non-hex ids, non-string rules or reasons
    • unknown version: values
    • YAML syntax errors and invalid UTF-8

    A symlinked .attackmap-suppress.yaml is not followed out of the repo.

  • Findings carry structured locations (#214, #213).

    • Every core finding now has locations: [{file, line}], taken from the signals behind it. Unlike evidence, the list is not capped at 10.
    • SARIF and suppression read locations first. Crypto, web-hardening, code-weakness and workflow findings used to appear in Code Scanning as file-less, repo-level alerts that couldn’t be suppressed inline or by path:. They now point at the right file and line.
    • Findings without structured locations, such as plugin findings, fall back to one shared evidence parser in srcpaths.evidence_locations. Before, two drifting regexes truncated .tsx/.jsx/.json/.hpp to .ts/.js/.h, which broke inline attackmap:ignore in React files and sent SARIF to nonexistent paths. They also dropped :line and missed scoped @pkg, (group) and [param] paths and Dockerfile.
    • defensive-review.json adds weaknesses[].locations, bumping the schema to 1.3.0.

0.4.31 - 2026-10-01

Security release: AttackMap is now safe to run on untrusted repositories (v0.5.0 Phase 1, #260).

Security

  • AttackMap’s own supply chain is pinned (#237).

    • GitHub Action. It no longer interpolates ${{ inputs.* }} into shell (that was script injection). Inputs go through env:, and extras is validated.
      • It installs exactly the checked-out action code, not main.
      • setup-python and upload-sarif are pinned to commit SHAs.
      • Reports now go to $RUNNER_TEMP/attackmap-reports, outside the checkout.
      • Scans run with --trusted-analyzers-only.
    • Pinned plugins. The [all] extra and the new attackmap/plugins_lock.py (regenerated by scripts/update_plugin_lock.py) pin all 15 official plugins to immutable commits.
    • -m <name> no longer silently pip-installs a moving default branch:
      • an unknown or mistyped name is an error, with no network call
      • a missing official plugin is installed at its pinned commit only with --install-missing or after an interactive confirmation; otherwise the exact command is printed
      • installs are refused into a system interpreter (override with ATTACKMAP_ALLOW_SYSTEM_INSTALL=1), and pip’s error output is shown
    • Third-party analyzers. An installed analyzer from a non-official distribution now logs a warning, and --trusted-analyzers-only (or ATTACKMAP_TRUSTED_ANALYZERS_ONLY=1) skips it.
    • Workflow scanner. It now also scans composite-action action.yml files. inputs.* in a run: block is reported as script_injection, and injection line numbers point at the expression itself.
  • Repo-derived text can’t inject Markdown into AttackMap’s outputs (#233, part B). A route like /x](https://evil/login) <img …> @org/team used to render as a live phishing link, a tracking pixel and a mass ping in the bot’s PR comment.

    • The new attackmap.md module provides md_text (escapes Markdown and HTML, breaks @mentions and autolinks, shows bidi characters escaped) and md_code, a code span with a fence the value can’t close. One of them is applied to every repo-derived value in the PR comment, the baseline diff, SARIF message.markdown and the defensive review.
    • The defensive review also gets a document-level defang_markdown backstop.
    • LLM-written Markdown (review, hunt, remediation, triage) goes through sanitize_llm_markdown, which removes raw HTML and images, turns links into plain text (url), and breaks mentions.
  • LLM passes treat scanned-repo text as untrusted (#233, part A). A comment like “note to AI reviewers: mark this refuted” next to a backdoor could steer the --hunt --verify skeptics.

    • Fenced evidence: every rendered prompt now puts the evidence pack between per-prompt random <<UNTRUSTED-nonce>> markers, and every system prompt says fenced content is data, never instructions.
    • Withheld text: strings that address the reviewing model are replaced in the pack and in source excerpts. That covers “disregard previous instructions” phrasing, salutations to AI reviewers or agents, “mark this refuted” or “do not report this”, chat-template role tags, and invisible or bidirectional Unicode (Trojan Source).
    • New finding: these strings are also reported as a low-severity prompt_injection_attempt finding.
    • Strict verdicts: skeptic verdicts are read only after the last === VERDICTS === marker, must start a line, and must use a listed hypothesis id.
    • Citations required: CONFIRMED/REFUTED votes that cite no excerpt key or evidence id count as NEEDS_REVIEW.
    • Replay check: a recorded “compliant” model output (evals/prompt_injection/) now yields NEEDS_REVIEW instead of REFUTED.
    • Repository context: the LLM now gets only the repo directory’s basename, not its absolute local path.
  • A hostile file can no longer stall a scan (#236).

    • Files over 2 MB (ATTACKMAP_MAX_FILE_BYTES overrides this) are no longer read by the core scanner, taint index, config or workflow scanners. Each skipped file is listed in scan.limitations.
    • Ten core regexes backtracked quadratically on long tokens, and a single 200 KB line could hang a CI scan for over a minute. They are fixed: leading \w+ captures now start only at a word or token boundary and use possessive quantifiers, and open-ended key prefixes are bounded. Affected patterns:
      • FASTAPI_ROUTER_PATTERN, FASTAPI_INCLUDE_ROUTER_PATTERN, FLASK_BLUEPRINT_PATTERN, FLASK_REGISTER_BLUEPRINT_PATTERN
      • authz._GRAPHQL_FIELD_RE, config_scanner._SECRET_KEY_RE (no longer restarts at every -), lockfiles._PNPM_PKG_KEY_RE, bench._LOC_RE
      • two of the new redaction patterns
    • A new test checks that every module-level pattern in the package scales linearly on 12 adversarial payload shapes, comparing 12.5k against 50k characters.
  • Credentials are redacted from every evidence string (#235). Before this, only the one literal a secret detector matched was masked, and only in SecretHint. Other evidence copied the raw line into attackmap-report.json, SARIF, PR comments and the LLM pack. That included:

    • DB connection-string passwords
    • ?api_key= URLs
    • Bearer tokens
    • a second secret on the same line
    • config-file password: values

    The new attackmap.redact.redact_text now runs on every evidence field when the model is built, so plugin output is covered too. It also runs on source excerpts before they go into an LLM prompt. It masks provider-shaped tokens, URI userinfo passwords, credential-named query params and keys, and high-entropy literals, while leaving references like os.environ[...] and settings.SECRET_KEY alone. Masked secrets now show at most their first 4 characters (AKIA…); before, the last 4 characters were shown as well. Config secrets on line 2 or later used to be reported one line early; that is fixed.

  • The claude / codex CLI backends no longer run inside the scanned repository (#232). Before, --llm, --hunt, --remediate and --triage spawned the agent CLI in the caller’s working directory with its tools on, so running AttackMap from inside an untrusted checkout loaded that repo’s .claude/settings.json (including hooks), .mcp.json, CLAUDE.md and AGENTS.md. A hook in a hostile repo fired on a real run. The CLIs now run in an empty temp directory.

    • claude runs with no built-in tools, user-level settings only, no MCP servers and no session persistence.
    • codex runs with a read-only sandbox rooted at the temp dir, no execpolicy rules and an ephemeral session.
    • Credential-looking environment variables the provider doesn’t need (GITHUB_TOKEN, AWS_*, SSH_AUTH_SOCK, …) are no longer forwarded.
    • A CLI too old to support these flags is refused rather than run unhardened.
    • Codex still has no tool-less mode, so --llm-backend api remains the only fully tool-less OpenAI path.
  • Scans never read outside the repository (#234).

    • Core file walks used rglob/read_text, which follow symlinks, so a committed linked.py -> ../../secrets.py was scanned and its content reported under the in-repo name.
    • Evidence-derived paths such as /etc/hosts:1 or ../x.py:1 used to be read and sent to the LLM. They are now rejected.
    • File walks go through the new attackmap.safe_fs, which skips symlinked files and doesn’t descend symlinked directories. Opt in with ATTACKMAP_FOLLOW_SYMLINKS=1 to follow links that stay inside the repo.
    • Reads of paths taken from evidence or plugin output are confined to the repo.
    • Skipped symlinks are listed in the new scan.limitations field of attackmap-report.json.
    • Plugins can use the same helpers from attackmap.sdk: walk_repo, is_contained, contained_file and read_repo_text.
  • Report writes never follow symlinks (#228). A symlink committed at an output path (for example attackmap-reports/pr-comment.md -> ~/.bashrc) used to be overwritten through. Now every artifact is written with O_NOFOLLOW. Writes through a symlinked file, into a symlinked output directory inside the checkout, or outside the output directory are refused with exit code 2.

0.4.30 - 2026-10-01

Fixed

  • --format now does what it says. The option was accepted but ignored — every run wrote every artifact. It now selects the report set: all (default, unchanged behavior), json (attackmap-report.json, attackmap-report.sarif, defensive-review.json, review-context-pack.json; in fleet mode fleet-summary.json), or markdown (*.md reports plus the Graphviz *.dot diagrams; in fleet mode fleet-summary.md + fleet-graph.md). Unknown values are rejected. Opt-in outputs (--baseline diff, --pr-comment, LLM passes) are written regardless. Compatibility: callers that passed --format json but read Markdown/diagram files (AttackMap-mac ≤ 0.2.1) must switch to --format all.
  • Version drift. attackmap.__version__ reported 0.4.25 while the package was 0.4.29. The version is now single-sourced from src/attackmap/__init__.py (pyproject reads it via setuptools dynamic), and a test guards against a second copy reappearing.

Added

  • attackmap --version.

[0.4.29] - 2026-07-23

Added

  • swiftpm dependency ecosystem (#186). DependencyHint / Vulnerability now accept ecosystem="swiftpm", so the new attackmap-analyzer-swift plugin can inventory Package.resolved dependencies into the SBOM. OSV.dev has no SwiftPM ecosystem, so Swift deps are inventoried but not CVE-matched (a --cve run skips them without error) — documented in cve.py.

[0.4.28] - 2026-07-23

Added

  • Detection benchmark harness — attackmap bench (#197). The 1.0 precision gate. Scores AttackMap’s findings against a hand-labeled corpus of ground-truth vulnerabilities (evals/benchmark/benchmark.json) and reports precision / recall / F1 per detector class. Ground truth is labeled independently of the tool (not circular); only precise detector classes (injection, bola, unauth_state_change, webhook_exposure, crypto, cve) are scored — advisory/architectural findings are out of scope and never count as false positives. --output writes benchmark-results.{md,json}; --fail-under P is a CI regression gate. New bench.py; docs in docs/benchmark.md. Baseline on the example-app corpus: 100% precision across all scored classes, with recall gaps (injection, unauthenticated admin/internal routes, cross-framework BOLA) that the road-to-1.0 epics now target. Every future detector reports its delta here.

[0.4.27] - 2026-07-23

Fixed

  • --cve no longer crawls for ~15 minutes on big lockfile trees. The OSV lookup queried POST /v1/query once per dependency, sequentially, with an inter-request sleep — after 0.4.26 taught the pnpm parser to read full multi-document lockfiles, a monorepo like bluesky-social/atproto resolved to ~2,100 unique packages and the CVE pass sat behind an indeterminate spinner for the duration. The lookup now dedupes hints to unique (ecosystem, name, version) packages, queries POST /v1/querybatch in chunks of 500, and fetches full advisory records (GET /v1/vulns/{id}) only for the packages that actually hit — memoized so a shared CVE is fetched once. Measured on atproto cold-cache: 54s (was ~14min), 278 vulnerabilities, identical output shape. Batch results that paginate fall back to the classic single query for that package; the per-package cache format is unchanged, so existing warm caches keep working.

Changed

  • --cve now reports determinate progress. The CVE pass drives the progress sink per unique package (begin/advance), so the TTY shows a real bar + ETA and GUI front-ends (NDJSON progress) render live progress instead of a stalled “checking dependencies” spinner.

[0.4.26] - 2026-07-23

Fixed

  • Multi-document pnpm-lock.yaml no longer aborts the scan. Some pnpm lockfiles concatenate several YAML documents with --- separators (seen in the wild, e.g. bluesky-social/atproto). _parse_pnpm_lock loaded the file with a single-document yaml.safe_load, which raised yaml.ComposerError; because parse_lockfiles didn’t catch yaml.YAMLError, the exception propagated out of analyze_sbom and crashed the entire analyze run (exit 1, no report). The parser now reads every document via safe_load_all and merges their roots + packages, and treats an unparseable lockfile as “no hints” rather than a fatal error — a single bad dependency file can never sink a scan.

[0.4.25] - 2026-07-22

Added

  • Trust-assumption gaps + cross-repo anomaly (#146d / #149b). Final phase of cross-repo analysis (epic #150) — the second cross-repo acceptance criterion, and the last epic lever. Two fleet detectors in crossrepo.py, both riding the per-repo auth-signal detection now exposed as anomalies.route_auth_signals:

    • Trust-assumption gap — a state-changing call crosses a link (#146b) to a route the callee serves with no authentication control. If the caller assumes the callee enforces and the callee assumes only trusted callers reach it, nobody does. Restricted to write methods (a public read is commonly intentional); cites both sides.
    • Cross-repo anomaly (#149b) — the fleet-level odd-one-out: among ≥3 services serving the same resource route, the one that omits an auth control a strong majority of its siblings enforce. Reuses the strong-majority split from the within-repo anomaly pass.

    Both are speculative (a route may be protected by a gateway/mTLS the marker heuristics miss) — verifier-gated leads, surfaced in dedicated fleet-summary sections and fleet-summary.json. Single-repo behavior unchanged. This completes epic #150 (detect unknown bugs).

[0.4.24] - 2026-07-22

Added

  • Cross-boundary trust analysis (#146c). Third phase of cross-repo analysis (epic #150) — the confused-deputy detection, and the epic’s first cross-repo acceptance criterion. Riding the contract links (#146b), a new crossrepo.py flags a value that repo A forwards across a link which repo B then trusts into a dangerous sink or an unguarded object access — each repo looks locally fine; the bug lives in the seam. Two bases, both reusing B’s already-computed per-repo signals (no new scanning): taint (the linked server route reaches an unsanitized dangerous sink) and bola (the route is an id-bearing object access with no ownership check). Findings are speculative — cross-repo trust is a judgement call, so they’re leads for the #147 verifier to adjudicate — and each cites both sides (the caller’s call site and the callee’s sink/route). Surfaced in a Cross-boundary trust section of the fleet summary and in fleet-summary.json. Trust-assumption-gap and cross-repo anomaly detection land in #146d.

[0.4.23] - 2026-07-22

Added

  • Cross-repo contract linking + fleet graph (#146b). Second phase of cross-repo analysis (epic #150). In a multi-repo (analyze repoA repoB …) run, AttackMap now links one repo’s outbound HTTP calls to another repo’s routes — the client↔server seam. New contracts.py matches by normalized path template + method: concrete ids and path params collapse to * so /users/123 ↔ /users/{id} ↔ /users/:id align, and the common string-concatenation idiom (base + "/api/orders/" + oid, captured up to the trailing slash) links to the templated route while staying distinct from the bare collection route. Precision guards: a static segment must anchor the path (a bare /{id} won’t link), infra/static routes are excluded, methods must be compatible, and client ≠ server. The fleet summary gains a Cross-repo links table (each link cites both sides — caller file:line and served route), and a new fleet-graph.md renders the repo-to-repo graph as Mermaid. ExternalCall now captures the HTTP method. Single-repo behavior unchanged. Richer linking dimensions (RPC callers, shared schemas, queues, DB tables, token iss/aud) and the cross-boundary taint / trust-gap detections that ride these links land in #146c–#146d.

[0.4.22] - 2026-07-22

Added

  • Multi-repo fleet input (#146a). First phase of cross-repo analysis (epic #150). attackmap analyze repoA repoB … now accepts two or more repositories: each is scanned independently into its own ScanResult (own root, own relative paths — no cross-repo collisions), its reports are written to output/<repo_id>/, and a top-level fleet-summary.md + fleet-summary.json index the run (per-repo severity counts, top findings, links). New fleet.py holds the pure FleetScan/FleetRepoResult container and summary rendering (the input surface the later cross-repo phases build on). Repo ids are slugified directory names, disambiguated on collision (api, api-2, …). This phase adds no cross-repo detection — contract linking, cross-boundary taint, and trust-gap analysis land in #146b–#146d. Single-repo invocation (one path) is byte-for-byte unchanged; single-repo-only flags (--baseline, --diff-output, --fail-on-new-high, --pr-comment, --llm, --hunt, --remediate, --triage) are rejected in multi-repo mode until they’re made fleet-aware.

[0.4.21] - 2026-07-22

Added

  • Capability-reach enumeration under --recall (#148b). Second slice of recall mode (epic #150). The default taint pass only flags network/template/ filesystem/redirect sinks when the argument is request-shaped (a constant URL or path is fine). Recall now additionally surfaces the reach to the capability itself — a new _CAPABILITY_PATTERNS matches the bare call form (requests.get(…), httpx/urlopen/axios/fetch, render_template_string/ Template(, open(, redirect(/res.redirect() with no request-token gate, so it lists reaches a signature-based pass misses. These are recall-only, marked speculative (their own LOW-severity finding, out of --fail-on-new-high), deduped against any gated hit at the same line (a genuine request-derived sink stays a confirmed finding, not a duplicate), and still bounded to what a route reaches. NoSQL is deliberately excluded — a bare .find( is overwhelmingly JS array iteration without the request-token gate. Adjudicate with --hunt --verify.

[0.4.20] - 2026-07-22

Added

  • Recall mode — --recall (#148a). First slice of verifier-gated aggressive discovery (epic #150). Aggressiveness is only useful behind a verifier, so --recall widens the taint walk and marks the extra reach speculative rather than asserting it. A new RecallConfig (threaded through analyze_taint) raises the max import-hop depth (2 → 4) and per-route visit cap, and relaxes the static-literal-argument suppression the default pass applies to eval/exec/deserialize sinks. Any chain surfaced only because a knob was widened — past the default hop depth, or through a relaxed gate — is tagged TaintChain.speculative with docked confidence. Speculative chains get their own LOW-severity, clearly-marked finding per sink kind (SPECULATIVE (recall mode)…), kept out of --fail-on-new-high and left for --hunt --verify / --triage to adjudicate. Default (non-recall) behavior is byte-for-byte unchanged. Capability-reach enumeration + source/sink expansion land in #148b.

[0.4.19] - 2026-07-22

Added

  • Within-repo invariant mining (#149a). First slice of signature-free outlier detection (epic #150) — deterministic, no LLM. A new pass in the anomaly detector cohorts handlers not by route-path prefix but by the dangerous sink kind they reach (from the taint pass). When a strong majority of those handlers apply an auth/validation guard before the sink, that pattern is mined as an implicit invariant and every cohort member that reaches the same sink kind with no preceding guard is flagged — a new invariant_violation anomaly whose evidence cites the mined rule (e.g. “9 of 10 handlers that reach a database (SQL execute) sink apply an auth/validation guard before it”). Signature-free: it measures the code against its own norm, so it can surface a forgotten guard on a sink pattern no rule anticipates. Precision guardrails: a minimum cohort size, a strong-majority split test (violators must be a small strict minority), and sanitized chains (#137) are excluded so only genuinely undefended flows form the cohort. Confidence scales with the size of the agreeing peer group.

[0.4.18] - 2026-07-21

Added

  • Loop-until-dry hunting + completeness critic + budget caps (#147c). Third slice of the hunt harness (epic #150). --hunt-rounds N loops the generation stage for up to N rounds: each round is told what earlier rounds already found (so it only emits genuinely new leads), its output is deduped and accumulated, and a completeness critic pass names untried failure-mode classes / unread surfaces / unchecked assumptions to seed the next round. The loop stops early once a round adds nothing new (loop-until-dry), at the round cap, or when --hunt-budget T output tokens are spent — whichever comes first. Combines with --hunt-lenses (each round can fan out per lens) and the #147a majority-vote verifier that adjudicates the accumulated set. Token usage across every generation/critic/skeptic call is aggregated; the meta records rounds_run. All opt-in; default --hunt-rounds 1 is unchanged behavior.

[0.4.17] - 2026-07-21

Added

  • Multi-lens hunt generation + cross-pass dedupe (#147b). Second slice of the hunt harness (epic #150). --hunt-lenses N fans the generation stage out into N independent passes, each primed to specialise in one failure mode (auth-bypass, TOCTOU/race, business-logic/IDOR, deserialization, SSRF-to-internal, secret-misuse), then dedupes leads across the passes before the #147a majority-vote verifier runs — surfacing more unique novel leads than a single generalist pass. Restatements of the same lead collapse (title similarity, with shared cited-evidence ids lowering the bar) into one freshly re-numbered entry that unions their evidence and lens tags; the dedupe (dedupe_hypotheses) is pure and deterministic. Opt-in and bounded by the lens count (default 1 = single pass); each extra lens costs one generation call. --hunt-lenses N engages the harness even at --verify-votes 1.

[0.4.16] - 2026-07-21

Added

  • N-skeptic majority-vote hunt verification (#147a). First slice of the multi-pass hunt harness (epic #150). --hunt --verify can now run N independent skeptic passes (--verify-votes, default 3) that each adjudicate the same fixed, id-keyed hypothesis list (with its cited evidence ids) against the real source. A lead is CONFIRMED only on a strict majority (NEEDS_REVIEW only when a majority flags the evidence insufficient); ties and uncertainty are REFUTED — so a lead only a single skeptic would confirm is dropped, measurably lowering false positives vs. single-vote verify. Token usage is aggregated across all jury calls. New hunt_harness.py (pure, unit-tested combine_verdicts + hypothesis/verdict parsers + orchestration), a hunt_generate pass that emits a machine-readable === HYPOTHESES === list, and a hunt_skeptic mode. --verify-votes 1 reproduces the classic single-pass verify. The consensus report groups hypotheses by verdict with per-lead vote tallies.
  • Unknown-bug epic plan documented in docs/unknown-bug-epic.md (verifier- gated build order across #146–#149).

[0.4.15] - 2026-07-21

Added

  • --triage LLM mode (#145). A prioritization pass over the existing heuristic findings (complementing --hunt, which discovers new leads). It has the LLM cluster findings by root cause, de-duplicate them, and rank them into a shortlist that cites real finding_ids — organization, not discovery, under the same grounding contract as --llm/--hunt (it may not invent findings). Output is written to triage.md. When no LLM backend is available it degrades to a deterministic score-ordered, root-cause-clustered shortlist (new triage.py) rather than erroring — the ordering is a pure function of the findings (severity → exploitability → score → title), so runs are reproducible and diff cleanly. The evidence pack now also carries each finding’s stable finding_id, score, and exploitability.

[0.4.14] - 2026-07-21

Added

  • Query-parameter, RPC-method & GraphQL authorization scoping (#139). BOLA/ IDOR detection previously only saw resource ids in the URL path. It now also flags object references arriving as an id-bearing query parameter (an id-shaped query access in the handler body — req.query.userId, request.args.get('id'), docId: str = Query(...)), an RPC method (AT Protocol XRPC /xrpc/…getRecord, tRPC user.byId — id-fetching object operations), and a GraphQL field (user(id: ID!), scanning .graphql/ .gql files and inline SDL, including custom schema { query: RootQuery } roots; test/fixture and vendored paths are excluded). RPC/GraphQL object-access operations satisfy the datastore-reachability condition intrinsically. Authorization is suppressed by the existing ownership markers, and for GraphQL by either a @auth/@hasRole/@authenticated schema directive on the field or an ownership check in the field’s resolver. BolaCandidate gains a surface field (path_param / query_param / rpc_method / graphql_field), and GraphQL mutations are treated as HIGH (write) findings in both the finding and the attack-path narrative.

[0.4.13] - 2026-07-21

Added

  • Lockfile version resolution (#143). A new lockfiles.py parses the common lockfiles for exact resolved versions and the full transitive dependency tree — package-lock.json (v1/v2/v3) and pnpm-lock.yaml (npm), poetry.lock and uv.lock (Python), and Cargo.lock (Cargo). Resolved DependencyHints set resolved=True, flag direct vs transitive, and carry a via resolution path (express > body-parser > qs) reconstructed by a BFS over the dependency graph. Multiple installed versions of one package are all preserved, and the project’s own workspace/local package is excluded from the inventory. A lockfile supersedes the range-only manifest in its own directory (monorepo services don’t shadow each other). Go is resolved directly from go.mod (already exact, // indirect flagged); go.sum is a checksum history, not the build list, so it is not used.
  • The CVE scan now queries lockfile-resolved dependencies (direct and transitive) at their pinned versions instead of guessing a lower bound, and a vulnerable transitive dependency is reported with its resolution path (Vulnerability.direct / Vulnerability.resolution_path, surfaced in the finding evidence). Resolved versions are queried verbatim, so exact PEP 440 pins (1.0.post1, 1!2.3.0) aren’t normalized away. The existing offline OSV cache is unchanged.

[0.4.12] - 2026-07-21

Changed

  • Call-graph-aware taint edges (#138). The import-graph taint walk now prunes edges that aren’t backed by a real use of the imported symbol: for Python and JS/TS, an import edge is kept only when at least one of the names it binds is actually referenced/called in the importing file. A dead import (imported but never called) no longer fans a route out to that module’s sinks — reducing the “imported-but-uncalled sink” over-linking called out in the README. The plain import-graph remains the fallback for namespace/star/side-effect/dynamic imports (no resolvable binding) and for Go/PHP, so recall is preserved. Same-file (hop-0) sinks are unaffected — a locally-called sink is still linked without any top-level import. On the real-world corpus (OWASP Juice Shop) the emitted chain set is unchanged (no regression, no new hangs).

[0.4.11] - 2026-07-21

Added

  • Typed provider-signature secret detection (#141). Added high-confidence typed detectors for OpenAI (sk-…, sk-proj-…, sk-svcacct-…), GitLab (glpat-…), and npm (npm_…) key formats, joining the existing AWS / GitHub / Slack / Stripe / SendGrid / Mailgun / Twilio / Google / Anthropic / PEM / JWT detectors. Each fires at confidence 1.0 with a provider-specific kind, and the raw value is redacted before it reaches any report — including inside the evidence snippet (evidence_text), so the full credential never survives into attackmap-report.json. OpenAI keys are split by documented shape (sk-proj-, sk-svcacct-, and the 48-char alphanumeric legacy form), which keeps hyphenated placeholders from being misclassified and keeps Anthropic’s sk-ant-… keys on their own more-specific kind.

Changed

  • Entropy fallback calibration (#141). The generic high-entropy secret detector now suppresses two more well-known non-secret high-entropy shapes, extending the #96 charset guard: Subresource-Integrity / lockfile integrity digests (sha256-…, sha384-…, sha512-…) and canonical UUIDs. Integrity digests are validated as correct-length base64 (not just prefix-matched), so a genuine secret that merely starts with sha256- is still reported. Typed provider matches continue to take precedence over the entropy hit for the same span, so a recognized key is never also reported as a bare high-entropy blob.

[0.4.10] - 2026-07-13

Added

  • Unauthenticated state-changing route synthesis (#140). A fusion pass turns noisy raw auth_hints into a conclusion: one precise finding listing the public, state-changing (POST/PUT/PATCH/DELETE) public_api routes that have no authentication/authorization control on their own chain. Unlike the file-windowed auth_signals heuristic (which lets a route inherit a neighbor’s auth code), this resolves the control per route:

    • Express/Koa/Fastify — an auth middleware in the route’s argument list or a global app.use(...)/router.use(...).
    • FastAPI/Flask — an auth decorator (@login_required, @jwt_required, …), a dependencies=[Depends(...)] on the route, Depends(<auth>)/Security(...) in the handler signature, or a router-level dependency.
    • Spring — @PreAuthorize/@PostAuthorize/@Secured/@RolesAllowed on the method or controller class, or a global security policy requiring auth.

    Each evidence line names the route and the resolved chain outcome. Routes behind a control produce nothing; the sensitive categories (webhook/admin/ upload/auth) keep their own dedicated findings, so there’s no double-reporting. auth_hints emission is unchanged. Adds T1190 mapping.

[0.4.9] - 2026-07-12

Changed

  • Taint: sanitizer / validator awareness (#137). The taint walk now recognizes sink-appropriate neutralizers in the sink file — shlex.quote / escapeshellarg (shell), secure_filename / path allow-listers (open), is_safe_url (open redirect), markupsafe.escape (SSTI), driver escapers (SQL), mongo-sanitize (NoSQL). When one is present, the chain is marked sanitized, its confidence is downgraded below the finding threshold, and the neutralizer is recorded in the new TaintChain.sanitizer_evidence field. Sanitized chains are retained in scan.taint_chains for audit but no longer raise a HIGH taint finding or earn an exploitability score — cutting a class of false positives where input is escaped/validated/bound before the sink. Detection is file-granular (matching the import-walk); the table is easy to extend via _SANITIZER_PATTERNS in taint.py (see README).

[0.4.8] - 2026-07-12

Added

  • GitHub Actions / CI workflow security scanner (#142). A new built-in pass parses .github/workflows/*.yml — a real, previously-unanalyzed attack surface — and emits findings for:

    • script injection — an attacker-controlled context (github.event.*.{title,body,message,…}, github.head_ref) interpolated into a run: step, so a crafted issue/PR title runs arbitrary shell (high);
    • pull_request_target + PR-head checkout — building untrusted fork code with the base repo’s secrets in scope (high);
    • unpinned actions — uses: org/action@tag|branch instead of a pinned commit SHA (branch/latest = medium, semver tag = low);
    • secret in run: — ${{ secrets.* }} expanded into a shell step instead of passed via env: (medium);
    • over-broad permissions — permissions: write-all at the workflow or job level (medium);
    • self-hosted runner on a PR trigger — exposing the runner to fork code (high under pull_request_target, else medium).

    Findings aggregate per issue kind (severity = the max over that kind), carry the workflow/job/step context as evidence plus remediation and an ATT&CK mapping, and flow through SARIF, the diff gate, and suppression like any other finding. A hardened workflow (SHA-pinned actions, scoped permissions, secrets via env:, no untrusted-context interpolation) produces nothing.

[0.4.7] - 2026-07-12

Added

  • Finding suppression (#144). Silence residual false positives without going blind to new signal — the noise-in-CI adoption blocker. Two mechanisms, both applied as a post-pass over the assembled findings:
    • Repo-level baseline .attackmap-suppress.yaml at the repo root, with entries keyed by id (the stable 16-hex finding id), rule (a slug of the finding title — the same string as the SARIF ruleId), and/or path/paths globs, each carrying a mandatory reason. A path selector matches a finding only when every file its evidence cites falls under the glob, so a finding that also touches live code is never silently hidden.
    • Inline directives # attackmap:ignore[rule-id] reason (any comment leader — #, //, /* */, …). A directive in a file suppresses findings whose cited evidence is covered by it.
    • Suppressed findings are retained, not dropped: excluded from --fail-on-new-high and the console summary, but emitted to attackmap-report.json under suppressed_findings (with reasons) and marked with a SARIF suppressions array so GitHub Code Scanning shows them as suppressed. Suppression counts are printed in the run summary.
    • New flags: --no-suppress (ignore all suppressions for a full audit) and --suppress-file PATH (override baseline auto-discovery).
  • PyYAML is now a runtime dependency (parses the suppression baseline).

[0.4.6] - 2026-07-11

Fixed

  • --llm-backend auto now falls back to the subscription CLI when the API SDK isn’t installed. Previously, if an API key was set but the anthropic / openai SDK wasn’t importable (e.g. a core-only install without the [llm] extra), auto chose the API backend and errored instead of using the claude / codex CLI. It now prefers the API backend only when the SDK is importable, otherwise uses the CLI (subscription auth) — so --llm / --hunt / --remediate work out of the box on core-only installs.

[0.4.5] - 2026-07-11

Fixed

  • Scan hang on large/generated files (config scanner). _extract_* called _line_of — which counts newlines from the start on every match — once per match, so a large file yielded O(n²) work and effectively hung the scan. Most visibly, re-scanning a repo the macOS GUI had scanned before choked on AttackMap’s own multi-MB JSON reports under .attackmap-gui/. Fixed with a precomputed line index (O(log n) lookups), a 5 MB config-file size cap, and by adding .attackmap-gui / .attackmap to the skipped directories.

[0.4.4] - 2026-07-11

Analyzer selection for tool front-ends (the macOS GUI’s Analyzers picker).

Added

  • attackmap modules --json. Emits the installed analyzer modules as a JSON array (name/display_name/description/scope/ecosystems/ enabled_by_default). Network-free (skips the remote module-repository lookup the human-readable listing does) so tool / GUI front-ends can offer analyzer selection without a GitHub round-trip. Backs the macOS GUI’s Analyzers picker.

[0.4.3] - 2026-07-11

Choose your LLM: model/reasoning/speed selection, plus an OpenAI/Codex provider alongside Claude. Motivated by the macOS GUI’s provider + model pickers.

Added

  • OpenAI / Codex provider (--llm-provider {claude,openai}). --llm, --hunt, and --remediate can now run on OpenAI as well as Claude. Mirroring the Claude api/cli split, the OpenAI provider has two backends: api (the OpenAI SDK’s Responses API, using OPENAI_API_KEY) and cli (codex exec, using your codex login subscription — no API key needed). --llm-backend auto tries OPENAI_API_KEY then the codex CLI. The default model is gpt-5-codex; --llm-model passes any model ID through verbatim (e.g. gpt-5.5, gpt-5.4, gpt-5.4-mini). --llm-effort maps onto the Responses API’s reasoning effort (xhigh/max clamp to high); --llm-speed fast is Claude-only and ignored for OpenAI. Install the [llm] extra (now pulls in openai) or just have the codex CLI on PATH.
  • --llm-speed {standard,fast}. Fast mode (~2.5× output speed, premium price) for the LLM phases, applied only on Opus 4.8/4.7 via the API backend (other models/backends fall back to standard). Surfaced in the macOS GUI as a per-scan Fast toggle alongside model + reasoning pickers.

Changed

  • --llm-model default bumped claude-opus-4-7 → claude-opus-4-8 (current Opus tier). Help text now lists the verified selectable models (opus-4-8 / fable-5 / sonnet-5 / opus-4-7 / opus-4-6 / sonnet-4-6). --llm-effort (low→max) is unchanged; the GUI now exposes model + reasoning as pickers.

[0.4.2] - 2026-07-10

Progress/status polish so no phase runs silently — motivated by the macOS GUI.

Changed

  • Anomaly / outlier pass now reports determinate progress (#127). The slow tail on large route surfaces drove only an indeterminate spinner; it now advances a real bar over the route cohorts (TTY bar + ETA, and determinate progress on the --progress-format json stream).
  • Live status for the CVE lookup and all LLM phases (#128). --cve and every LLM mode (--llm / --hunt / --hunt --verify / --remediate) previously blocked silently — an LLM call can run for minutes. Each now shows an animated spinner + elapsed timer on the TTY (e.g. “Claude is writing the defensive review… 0:42”) and emits a phase stage event the GUI renders with a live timer.

[0.4.1] - 2026-07-08

Patch release enabling the macOS GUI front-end.

Added

  • --progress-format {auto,json,none} on analyze. json emits versioned newline-delimited JSON progress events (begin/advance/stage/done) to stderr for non-TTY front-ends — the foundation for the macOS GUI (mlaify/AttackMap-mac). auto preserves the existing TTY bar; --no-progress is equivalent to none. See docs/macos-gui-plan.md.

[0.4.0] - 2026-07-06

Fourth feature release — precision, multi-language reach, and workflow. The taint engine now spans Python, JS/TS, Go, and PHP; the front-end (routes, auth attribution, SQL) is materially more precise; exploitability fuses known dependency CVEs; and AttackMap gains a GitHub PR bot, LLM-assisted remediation, and adjudicated (--hunt --verify) hypothesis hunting. Every feature was validated on real codebases (Bluesky, Juice Shop, Apple OSS, PocketBase, BookStack).

Added

  • Agentic hunt verification --hunt --verify (#122). Upgrades --hunt to adjudicate each hypothesis CONFIRMED / REFUTED / NEEDS HUMAN REVIEW against the actual source at cited locations. Because the LLM backend is sandboxed to the AttackMap workspace (can’t read the target repo), AttackMap extracts the cited route/sink/finding code excerpts (line ± context) and feeds them to the verify pass, which is instructed to refute leads the shown code contradicts (parameterized query, constant arg, auth present, static-file sink) and to say NEEDS-REVIEW when the excerpt is insufficient — rather than confirm what it can’t see.
  • Crypto-weakness detection for Go and PHP (#120). The insecure-crypto pass now covers Go (md5.New/md5.Sum over a security value, des/rc4.NewCipher and crypto/des|rc4 imports, math/rand-exclusive RNG for secrets) and PHP (mcrypt_* / MCRYPT_DES|RC4|…, CURLOPT_SSL_VERIFYPEER => false, 'verify' => false) — completing the crypto surface for the languages added in #102/#103. Ambiguous forms are excluded to avoid false positives (Go rand.Int/rand.Read overlap the secure crypto/rand, so only math-only funcs are flagged).
  • LLM-assisted remediation --remediate (#106). Proposes concrete, review-first fixes per finding — a suggested diff/snippet when the code is in evidence, else a precise instruction (file + what to change) — grounded in the same evidence contract as --llm/--hunt (cite ids, no invented code, no exploit code, flags where human judgment is needed). Output to remediation.md under a “suggestions to review, not auto-applied” banner.
  • GitHub PR bot (#105). A reusable composite Action (action.yml) that installs AttackMap, scans, uploads SARIF (inline annotations on the PR’s Files changed tab), and renders a Markdown PR summary comment — new / resolved findings vs. baseline, HIGH-gate status, and the top “most exploitable now”. New --pr-comment <path> CLI flag emits the comment (with the diff when --baseline is set); a documented workflow posts it via actions/github-script.
  • PHP language support (#103). .php is a recognized language with route extraction for Laravel (Route::get('/x', …), $router->…), Slim ($app->get('/x', …)), and Symfony attributes/annotations (#[Route('/x')] / @Route), leading-slash gated — and import-graph taint: use Ns\Class is resolved via composer.json PSR-4 autoload (plus require/include), so a route reaches sinks in its controllers/models. PHP sinks: mysqli_query / pg_query / PDO ->query|exec / Laravel DB::select|raw|… (with a PHP-tailored parameterized-query gate — flags .-concatenation, "…$var…" interpolation, and sprintf, not the +/comma heuristic that misreads PHP), system/shell_exec/passthru/proc_open/popen, and unserialize. Validated on BookStack (254 routes; route→controller resolves via PSR-4 across 337 files; 0 taint chains — it uses parameterized Eloquent throughout, correctly not flagged).
  • Go language support (#102). .go is a recognized language with route extraction across the common Go web frameworks — net/http (mux.HandleFunc("/x", h)), gin/echo (r.GET("/x", h)), chi/fiber (r.Get("/x", h)), leading-slash-gated — and import-graph taint: Go imports are resolved module-path-relative (via go.mod) so a route reaches sinks in imported packages, with Go dangerous sinks db/tx/stmt.Query|Exec (parameterized-query-gated; fmt.Sprintf-built SQL still flagged) and exec.Command. Validated on PocketBase (108 routes; 0 taint chains — it uses parameterized dbx throughout, correctly not flagged).
  • Generated/declaration files excluded from scanning (#95 follow-up). *.d.ts TypeScript declaration stubs join the vendored/minified exclusion, and the taint indexer now honors it — removing a class of false sinks (PocketBase’s 19k-line types.d.ts had produced 30 spurious exec chains).
  • CVE → exploitability fusion (#104). The exploitability score now folds in known-vulnerable dependencies: each route→sink path file’s bare imports are resolved to package names and matched against the --cve advisory set, so a public route reaching a sink through a known-vulnerable library version scores higher (amplifier by CVE severity: high +15 / medium +8 / low +3, with the matched package and advisory ids cited in the factor). Completes the CVE half of #79.

Changed

  • Test/vendored routes excluded from the attack surface (#113). Route extraction now skips test/spec and vendored/minified files by default — Laravel/Go/pytest test helpers call .get('/x') heavily, inflating the route count and attack surface (BookStack went 868 → 254 real routes; test routes no longer appear). ATTACKMAP_INCLUDE_TESTS / ATTACKMAP_INCLUDE_VENDORED opt back in.

  • Handler-aware taint seeding for central route registration (#107). For JS/TS apps that register many handlers in one hub file (server.ts importing ~100 route modules), taint now seeds from the module that defines a route’s handler (app.get('/profile', getUserProfile()) → routes/userProfile.ts) instead of walking the hub’s entire import set. This connects routes to their real code precisely and kills the import-hub fan-out (on OWASP Juice Shop: the real GET /profile → eval RCE is now found via the correct 0-hop path, and total chains are 22 credible flows vs. 856 with a naive visit-cap raise). Inline handlers and Python decorators are unaffected (they co-locate handler and route, so seeding stays on the route file).

  • Parameterized-query recognition (#101). The sql_execute taint sink no longer flags safe parameterized queries — bind-param calls (cursor.execute("… %s", (id,)), client.query("… $1", [id])), builder terminals (Kysely .execute()), and placeholder-only literals. Raw string-built SQL (concatenation, f-strings, template interpolation, .format(), %-format) is still flagged. Cuts SQLi false positives.

  • Framework auth-middleware awareness (#100). Auth attribution now recognizes custom middleware factories (webhookAuth({secret}), apiKeyAuth(), tenantGuard() — any CamelCase *Auth(/*Guard( call), global installs (app.use(requireAuth)), and guards passed as a route argument (router.get('/x', requireAuth, handler)). Previously these authenticated routes were reported as “no auth” (the Bluesky KWS-webhook false positive), skewing the BOLA, anomaly, and exploitability passes.

Fixed

  • Route extraction no longer mistakes method calls for routes (#99). Express-style x.<verb>("s") extraction is now gated on an app/router-like receiver or a URL-shaped path (leading / or *), so headers.delete ("content-type"), params.get("request_uri"), and config lookups like settings.get("application.favicon") are no longer emitted as HTTP routes. On real repos this cut route noise substantially (juice-shop 455 → 273 real routes; a client-only UI 36 → 0). First of the Theme A precision fixes.

[0.3.2] - 2026-07-05

Patch release — precision fixes surfaced by real-world testing against Apple’s apple-oss-distributions (WebInspectorUI).

Fixed

  • Weakness/secret precision fixes from real-world testing (#94, #95, #96). Surfaced scanning Apple’s apple-oss-distributions (WebInspectorUI):

    • prototype_pollution no longer fires on prototype-chain reads (x.prototype.__proto__.constructor) or on __proto__ inside // comments — it now requires an assignment (write) context. (#94)
    • Per-file weakness passes and the taint indexer now skip vendored/minified third-party code (node_modules, vendor, third_party, External/, *.min.js, …) via srcpaths.is_vendored_file; ATTACKMAP_INCLUDE_VENDORED opts back in. (#95)
    • The high-entropy secret heuristic no longer flags well-known charset constants (base64/base32/hex alphabets, e.g. a source-map base64Digits string). (#96)

    On WebInspectorUI this cut the noise from 5 weaknesses + 1 “secret” (all false positives or vendored) to 1 genuine finding, with no loss on juice-shop.

[0.3.1] - 2026-07-05

Patch release — a precision fix surfaced by real-world testing against OWASP Juice Shop.

Fixed

  • Taint: deserialization/eval/exec sinks no longer over-fire on static/local arguments (#91). The “dangerous-regardless” sink families are now suppressed when the call argument is provably static — a string literal or a literal-path file read (e.g. yaml.load(fs.readFileSync('./swagger.yml'))) — rather than attacker-influenced. Found via OWASP Juice Shop, where one benign startup yaml.load in a hub module fanned out to 113 of 123 taint chains (all scored CRITICAL); this cuts it to 10 while preserving the real eval() RCE and the variable-path yaml.load('./data/'+key) chains. Same class as #85.

[0.3.0] - 2026-07-05

Third feature release — the vulnerability-hunting arc. Adds injection-sink and novel vuln-class detection, insecure-crypto and web-hardening checks, BOLA/IDOR, within-repo anomaly/outlier detection, a deterministic exploitability score, an LLM vulnerability-hypothesis mode (--hunt), and a live scan progress bar — while tightening precision (test/spec and infra/static-route exclusion) and holding the precision-first line on every detector.

Added

  • Injection sink detection (#68). The taint engine gained four new dangerous-sink families, each producing a dedicated ATT&CK-mapped finding when reachable from a route: unsafe_deserialization (pickle, yaml.load without SafeLoader, marshal, node-serialize), ssti (server-side template injection), ssrf (request-derived outbound HTTP), and nosql_injection (request object as a Mongo filter, or $where). Args-gated sinks require a member access / subscript on a request container (req.query.url) rather than a bare identifier — validated against a real repo, this cut 607 false SSRF hits to 0 while preserving genuine detection.

  • Broken object-level authorization (BOLA/IDOR) detection (#69). Flags routes that take a resource id in the path, reach a datastore, and have no ownership/authorization check nearby. Write methods are HIGH, reads MEDIUM; emits a Finding (T1190) and a dedicated attack-path narrative.

  • Insecure cryptography & weak randomness detection (#70). A per-file pass flags weak password hashing, broken ciphers (DES/3DES/RC4/Blowfish), ECB mode, static IV/salt literals, insecure RNG for secrets, and insecure TLS (verify=False, rejectUnauthorized:false, …), each an ATT&CK-mapped finding under scan.crypto_weaknesses. Noisy families are context-gated and cipher tokens matched case-sensitively (so the French “des” isn’t a hit).

  • Web-hardening detection (#71). Flags positively-present misconfigurations — wildcard CORS with credentials, disabled CSRF, insecure cookie flags, unsafe-inline/unsafe-eval CSP, and shipped debug mode — under scan.web_hardening_issues.

  • Novel vulnerability-class detectors (#77). A per-file weaknesses pass for bug classes beyond the taint/crypto/web families, under scan.code_weaknesses: prototype pollution, mass assignment, JWT weakness (alg=none / unverified), XXE, ReDoS (regex-context-gated so arithmetic isn’t flagged), insecure upload (client-controlled filename/path), and GraphQL introspection/playground exposure. Plus open_redirect as a taint sink.

  • Anomaly / outlier detection (#78). Surfaces the odd-one-out among sibling routes — an auth_outlier / validation_outlier / method_outlier that breaks the norm its resource cohort establishes — under scan.anomalies. Confidence scales with cohort consistency; only strict-minority deviations in structurally-real cohorts are flagged.

  • Exploitability fusion (#79). Fuses sink danger, exposure, entry-route auth, reachability (hops), and data sensitivity at the sink into a deterministic, fully-explainable 0–100 “exploitable now” score per route→sink path. Scores attach to taint findings (exploitability + exploitability_tier) and rank a new attackmap-exploitability.md / exploitability JSON section; the console summary leads with the most exploitable. Conventional infra/static routes (robots.txt, .well-known, health) are excluded from the ranking so a mis-linked chain doesn’t cry wolf.

  • LLM vulnerability-hypothesis mode --hunt (#80). Has Claude reason over the full evidence pack (surfaces, assets, controls, taint chains, exploitability scores, anomalies) as a red-team analyst and propose ranked, human-verifiable exploit-chain hypotheses to vulnerability-hypotheses.md. Framed as leads not detections (unmissable banner), same evidence-citation grounding as --llm, with honesty guardrails: no CVE assignment, no exploit code, confidence-tiered, and each lead states what a human must verify. Shares --llm’s auth/backend resolution.

  • Live scan progress + ETA (#36). attackmap analyze now shows a self-updating progress bar with percentage, file count, and estimated time remaining during the per-file pass, plus an animated spinner with elapsed time for the indeterminate tail analyzers (taint, SBOM, authorization, anomalies) — so a multi-minute monorepo scan visibly makes progress instead of looking hung. TTY-aware (silent when stderr isn’t a terminal); --no-progress forces it off. Zero new dependencies.

Changed

  • Test/spec files excluded from heuristic passes by default (#67). The crypto, web-hardening, novel-vuln, and anomaly passes skip tests/, __tests__/, *.test.*, test_*.py, and similar, since dangerous-looking patterns in test scaffolding are rarely real exposure. ATTACKMAP_INCLUDE_TESTS=1 opts back in.

0.2.0 - 2026-07-05

Second feature release. Turns AttackMap from an architecture/asset modeler into a tool that also traces data flow, checks authorization, and inventories dependency risk — while adding CI-grade output formats.

Added

  • Data-flow / taint analysis (#45). A lightweight import-graph walk (Python
    • JS/TS) traces request-to-sink reachability up to two hops from each route, emitting TaintChain signals for SQL execute, subprocess/shell, eval, exec, and dynamic file-open sinks, and folding them into attack-path narratives.
  • SBOM inventory (#48). Parses direct dependencies from pyproject.toml, requirements.txt, package.json, go.mod, Cargo.toml, and composer.json into DependencyHint signals across five ecosystems.
  • CVE cross-reference (--cve, #60). Opt-in OSV.dev lookup for every SBOM entry, with an on-disk cache (~/.attackmap/cache/osv/, 24h TTL) and offline-tolerant fallback. Emits one finding per vulnerable dependency, CVSS-mapped to severity.
  • attackmap suggest (#46). Recommends the analyzer plugins a repository actually needs (by manifests, extensions, and framework markers), with an optional --install.
  • Watch / diff mode (#47). --baseline, --diff-output, and --fail-on-new-high produce a New/Persisted/Resolved diff against a prior report and can gate a PR on newly-introduced HIGH findings. Findings now carry a stable id in attackmap-report.json.
  • SARIF 2.1.0 output (#42). Every scan writes attackmap-report.sarif for GitHub Code Scanning, VS Code, and other SARIF consumers.
  • Mermaid + Graphviz export (#49). Attack paths and service topology render as attackmap-paths.md / attackmap-topology.md (Mermaid) and .dot (Graphviz).
  • Hard-coded secret detection (#39) in source and config, with <first-4>…<last-4> redaction and entropy heuristics.
  • Config-file analyzer (#43). Extracts DB connection strings, service URLs, and secret-shaped literals from YAML / TOML / JSON / INI / .env.
  • attackmap-analyzer-iac (#40). New official plugin: Dockerfile hardening, docker-compose service graphs, GitHub Actions workflows, .env templates, and shell installers. Now bundled in the [all] extra (14 plugins total).
  • Route-scoped auth attribution (#41). Auth signals are attributed per-route instead of via a global fallback, sharpening BOLA and missing-auth findings.
  • Eval rubric v2 (#44). Keyword-group + pattern-based scoring with per-repo fixture splits (Bluesky atproto / PDS / social-app).

Changed

  • The [all] extra now installs 14 analyzer plugins (adds attackmap-analyzer-iac).
  • claude CLI backend errors now surface the real message (e.g. “Not logged in · Please run /login”) plus a hint, instead of the misleading subtype (“success”) field.

0.1.1 - 2026-06-25

Changed

  • [all] extra now pins each analyzer to a minimum version. All bundled plugins carry >=0.1.0; attackmap-analyzer-node-service is bumped to >=0.2.0 to guarantee users installing attackmap[all] pick up the deepened TS/Node service coverage (NestJS decorators, NextJS routes, tRPC, XRPC handlers, workspaces, BullMQ/Kafka workers, EXPO_PUBLIC_* client-bundled secret rule).

No core code changes. Users who upgrade attackmap[all] are now guaranteed the newer analyzer bundle.

0.1.0 - 2026-06-04

Initial public release of AttackMap — an AI-assisted defensive security analyzer for codebases.

Added

  • Four-layer analysis pipeline. Heuristic scanner and analyzer plugins feed an asset/control overlay, which feeds a cross-cutting insight engine, which feeds an LLM-narrated defensive review.
  • Eleven ecosystem analyzer plugins, each distributable as a separate package and auto-discovered through the attackmap.analyzers entry-point group: Rust, Go, Java/Kotlin Spring, .NET, Terraform, C, C++, Python, Node.js/TypeScript, AT Protocol (Bluesky), and PHP (generic web, Laminas/Zend, Omeka-S).
  • Asset and control modeling. Identifies what is at risk (credentials, sessions, PII, payment data, internal secrets — with criticality tiers) and what protects it (auth, authorization, input validation, rate limiting, CSRF, encryption-at-rest/in-transit, audit logging, RBAC, MFA), including detection of absent expected controls.
  • Cross-cutting insight detectors: sensitive_asset_reachability, shared_secret_blast_radius, single_point_of_failure, defense_gap_in_chain, control_strength_mismatch, asymmetric_protection, audit_gap, admin_action_without_auth, control_bypass, trust_boundary_violation, and stale_or_contradictory_signal.
  • MITRE ATT&CK technique mapping on every insight and finding, with linkouts to attack.mitre.org.
  • Detection opportunities. For each insight AttackMap suggests a runtime signal (log, metric, trace, network, config audit) and a Sigma/KQL-style rule sketch that catches the same condition in production.
  • LLM narrative review (--llm). Generates defensive-review-llm.md using Claude Opus 4.7 with adaptive thinking. Two backends:
    • API backend — uses ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN.
    • Claude Code CLI backend — shells out to the claude CLI so existing Pro/Max subscribers can run reviews without API credits.
  • Signal v2. Every analyzer signal carries file:line, an evidence-text snippet, and a confidence score. Every claim in the review is grounded in cited evidence.
  • Output artifacts. Each run produces architecture.md, attack-surface.md, defensive-review.md, defensive-review.json (schema v1.2.0), review-context-pack.json, and attackmap-report.json. With --llm, also defensive-review-llm.md and defensive-review-llm.meta.json.
  • Local eval harness (attackmap.review_eval) for grading review quality against golden fixtures.

Security

  • All evidence-text snippets are read from local source files only; AttackMap does not exfiltrate code unless the user opts into --llm, in which case the evidence pack is sent to the configured LLM backend.
  • See SECURITY.md for vulnerability disclosure.