Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,46 @@ All notable changes to OpenAnt are documented in this file.

## [Unreleased]

This release syncs a large body of work from internal development. Highlights:
### Fixed

- **Disclosure code is now byte-faithful to source.** The disclosure
renderer pulls the actual file slice from the repo instead of rerunning
an LLM rewrite, so every finding's `Vulnerable Code` block matches the
real source.
- **No more silent 401s.** `openant set-api-key` validates the key on save
and fails loudly on bad input. `openant scan` prints a blocking warning
and exits non-zero when zero API calls succeed, so an all-401 run can no
longer masquerade as a clean repo.
- **CWE tagging is now systematic.** `pipeline_output.json` carries
non-null `cwe`, `cwe_id`, and `vulnerability_type` for every finding.
Stage 1 prompt asks for them directly rather than relying on the
renderer LLM to infer them from prose.
- **`[NOT PROVIDED]` placeholders eliminated.** Repo name, commit SHA, and
file count are threaded into every phase report envelope
(`parse.report.json`, `scan.report.json`) instead of being lost between
stages.
- **`Verified` column reflects the highest evidence tier.** `dynamic` >
`verified` > `static`, so dynamically reproduced findings show as
`dynamic` and the disclosure footer reads "Confirmed via dynamic test"
where applicable.
- **Call-graph-aware deduplication.** When two findings share a
sink/vector and the call graph records an edge between them, they
collapse into a single finding.
- **Dynamic test scaffolding fixed.** `openant dynamic-test` pre-stages
the vulnerable source file into the Docker build context end-to-end
through the dynamic-test chain — first-try Docker builds no longer fail
because the source isn't in context.
- **Concurrency-safe Docker resources.** Docker image and network names
get a UUID prefix so parallel dynamic-test workers can't collide.
- **Agreement filter checks the final verdict** instead of the
intermediate `agree` flag, so high-confidence dynamic results aren't
dropped by a stale agreement signal.
- **Dedup matches on CWE** instead of `attack_vector` text, so small
wording differences no longer split what's logically the same finding.

## [2026-04-14] — Initial public release

This release synced a large body of work from internal development. Highlights:

### Added

Expand Down
6 changes: 6 additions & 0 deletions apps/openant-cli/cmd/dynamictest.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ func runDynamicTest(cmd *cobra.Command, args []string) {
pyArgs = append(pyArgs, "--max-retries", fmt.Sprintf("%d", dynamicTestMaxRetries))
}

// Pass repo path so the dynamic tester can pre-stage source files into
// the Docker build context.
if ctx != nil && ctx.Project != nil && ctx.RepoPath != "" {
pyArgs = append(pyArgs, "--repo-path", ctx.RepoPath)
}

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey())
if err != nil {
output.PrintError(err.Error())
Expand Down
14 changes: 14 additions & 0 deletions apps/openant-cli/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ func runScan(cmd *cobra.Command, args []string) {
pyArgs = append(pyArgs, "--backoff", fmt.Sprintf("%d", scanBackoff))
}

// Pass repository metadata from project context so reports don't show
// [NOT PROVIDED] placeholders.
if ctx != nil && ctx.Project != nil {
if ctx.Project.Name != "" {
pyArgs = append(pyArgs, "--repo-name", ctx.Project.Name)
}
if ctx.Project.RepoURL != "" {
pyArgs = append(pyArgs, "--repo-url", ctx.Project.RepoURL)
}
if ctx.Project.CommitSHA != "" {
pyArgs = append(pyArgs, "--commit-sha", ctx.Project.CommitSHA)
}
}

result, err := python.Invoke(rt.Path, pyArgs, "", quiet, requireAPIKey())
if err != nil {
output.PrintError(err.Error())
Expand Down
39 changes: 39 additions & 0 deletions apps/openant-cli/cmd/setapikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,42 @@ package cmd

import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

"github.com/knostic/open-ant-cli/internal/config"
"github.com/knostic/open-ant-cli/internal/output"
"github.com/spf13/cobra"
)

var anthropicAPIURL = "https://api.anthropic.com/v1/messages"

func validateAPIKey(key string) error {
body := strings.NewReader(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`)
req, err := http.NewRequest("POST", anthropicAPIURL, body)
if err != nil {
return fmt.Errorf("failed to build validation request: %w", err)
}
req.Header.Set("x-api-key", key)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")

client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("could not reach Anthropic API: %w", err)
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); resp.Body.Close() }()

if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("Anthropic rejected the key (HTTP 401). Double-check it at https://console.anthropic.com/settings/keys")
}
return nil
}

var setAPIKeyCmd = &cobra.Command{
Use: "set-api-key <key>",
Short: "Save your Anthropic API key",
Expand All @@ -34,6 +62,17 @@ func runSetAPIKey(cmd *cobra.Command, args []string) {
os.Exit(1)
}

// Validate against Anthropic BEFORE saving — a bad key should never
// be persisted, otherwise `openant scan` silently produces zero results
// that look like a clean repo.
fmt.Fprintf(os.Stderr, "Validating API key with Anthropic... ")
if err := validateAPIKey(key); err != nil {
fmt.Fprintf(os.Stderr, "\n")
output.PrintError(err.Error())
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "OK\n")

cfg, err := config.Load()
if err != nil {
output.PrintError(err.Error())
Expand Down
85 changes: 85 additions & 0 deletions apps/openant-cli/cmd/setapikey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package cmd

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestValidateAPIKey_Rejects401(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()

// Override the API URL for this test.
orig := anthropicAPIURL
defer func() { anthropicAPIURL = orig }()
anthropicAPIURL = server.URL

err := validateAPIKey("sk-bad-key")
if err == nil {
t.Fatal("expected error for 401 response, got nil")
}
if got := err.Error(); !contains(got, "401") {
t.Errorf("error should mention 401, got: %s", got)
}
}

func TestValidateAPIKey_AcceptsValid(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"msg_test","type":"message","role":"assistant","content":[{"type":"text","text":"h"}],"model":"claude-haiku-4-5-20251001","usage":{"input_tokens":1,"output_tokens":1}}`))
}))
defer server.Close()

orig := anthropicAPIURL
defer func() { anthropicAPIURL = orig }()
anthropicAPIURL = server.URL

if err := validateAPIKey("sk-good-key"); err != nil {
t.Fatalf("expected nil error for 200 response, got: %v", err)
}
}

func TestValidateAPIKey_SendsCorrectHeaders(t *testing.T) {
var gotKey, gotVersion, gotContentType string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("x-api-key")
gotVersion = r.Header.Get("anthropic-version")
gotContentType = r.Header.Get("content-type")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

orig := anthropicAPIURL
defer func() { anthropicAPIURL = orig }()
anthropicAPIURL = server.URL

_ = validateAPIKey("sk-test-123")

if gotKey != "sk-test-123" {
t.Errorf("x-api-key = %q, want %q", gotKey, "sk-test-123")
}
if gotVersion != "2023-06-01" {
t.Errorf("anthropic-version = %q, want %q", gotVersion, "2023-06-01")
}
if gotContentType != "application/json" {
t.Errorf("content-type = %q, want %q", gotContentType, "application/json")
}
}

func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}

func containsHelper(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
2 changes: 2 additions & 0 deletions libs/openant-core/core/dynamic_tester.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def run_tests(
pipeline_output_path: str,
output_dir: str,
max_retries: int = 3,
repo_path: str | None = None,
) -> DynamicTestStepResult:
"""Run dynamic exploit tests on confirmed vulnerabilities.
Expand Down Expand Up @@ -83,6 +84,7 @@ def run_tests(
pipeline_output_path,
output_dir,
max_retries=max_retries,
repo_path=repo_path,
)

# Count outcomes
Expand Down
Loading
Loading