Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
660 lines
22 KiB
Go
660 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/atomine-elektrine/tarakan-client/internal/agent"
|
|
"github.com/atomine-elektrine/tarakan-client/internal/api"
|
|
repoctx "github.com/atomine-elektrine/tarakan-client/internal/context"
|
|
"github.com/atomine-elektrine/tarakan-client/internal/reviewdoc"
|
|
"github.com/atomine-elektrine/tarakan-client/internal/snapshot"
|
|
"github.com/atomine-elektrine/tarakan-client/internal/updatecheck"
|
|
)
|
|
|
|
// runReport is the mass-facing path: run a local agent, produce Review Format
|
|
// findings, and publish a Report (optionally completing a Job).
|
|
//
|
|
// tarakan report --agent grok
|
|
// tarakan report --agent grok --job 42
|
|
// tarakan report --document-file findings.json # publish only
|
|
func runReport(ctx context.Context, arguments []string, stdin io.Reader, stdout, stderr io.Writer, cfg api.Config) int {
|
|
flags := flag.NewFlagSet("report", flag.ContinueOnError)
|
|
flags.SetOutput(stderr)
|
|
var (
|
|
agentName string
|
|
model string
|
|
jobID int64
|
|
documentFile string
|
|
kind string
|
|
promptVersion string
|
|
yes bool
|
|
interactive bool
|
|
outputFile string
|
|
urlFlag string
|
|
hostFlag string
|
|
tokenFlag string
|
|
)
|
|
flags.StringVar(&agentName, "agent", "", "local agent: kimi, claude, codex, or grok (required unless --document-file)")
|
|
flags.StringVar(&model, "model", "", "model label stored on the report (defaults to --agent)")
|
|
flags.Int64Var(&jobID, "job", 0, "optional Job/Request ID to claim and complete")
|
|
flags.StringVar(&documentFile, "document-file", "", "publish an existing Review Format JSON file (skip agent run)")
|
|
flags.StringVar(&kind, "kind", "code_review", "report kind: code_review, threat_model, privacy_review, business_logic")
|
|
flags.StringVar(&promptVersion, "prompt-version", "tarakan-report/v2", "prompt version label")
|
|
flags.StringVar(&outputFile, "output", "", "also write findings JSON to this path")
|
|
var pickup bool
|
|
var minStars int
|
|
var languageFilter string
|
|
flags.BoolVar(&yes, "yes", false, "publish without an interactive confirmation prompt")
|
|
flags.BoolVar(&interactive, "interactive", false, "open the TUI (with --job, or next open job if omitted)")
|
|
flags.BoolVar(&pickup, "pickup", false, "open the TUI, claim next open job from the global queue, run agent")
|
|
flags.IntVar(&minStars, "min-stars", 0, "only pick up jobs on repos with at least this many stars")
|
|
flags.StringVar(&languageFilter, "language", "", "only pick up jobs on repos with this primary language")
|
|
flags.StringVar(&languageFilter, "lang", "", "alias for --language")
|
|
addAPIFlags(flags, &urlFlag, &hostFlag, &tokenFlag)
|
|
flags.Usage = func() {
|
|
fmt.Fprintln(stderr, "Usage: tarakan report --token TOKEN --agent grok --pickup")
|
|
fmt.Fprintln(stderr, " tarakan report --agent grok --pickup --min-stars 500 --language Rust")
|
|
fmt.Fprintln(stderr, " tarakan report [--url URL] [--token TOKEN] [--agent …] [--job ID] [--yes]")
|
|
fmt.Fprintln(stderr, " tarakan report --document-file findings.json [--job ID] [--yes]")
|
|
fmt.Fprintln(stderr, "")
|
|
fmt.Fprintln(stderr, "Mass path: run a local AI agent, produce findings, publish a Report.")
|
|
fmt.Fprintln(stderr, "Auth: --url/--host and --token (or TARAKAN_URL / TARAKAN_API_TOKEN).")
|
|
fmt.Fprintln(stderr, "With --job, claims and completes that Job so it links to the Report.")
|
|
fmt.Fprintln(stderr, "With --pickup (or --interactive without --job), opens the TUI and")
|
|
fmt.Fprintln(stderr, "auto-claims the next open report job from the global queue.")
|
|
flags.PrintDefaults()
|
|
}
|
|
if err := flags.Parse(arguments); err != nil {
|
|
return 2
|
|
}
|
|
if flags.NArg() != 0 {
|
|
flags.Usage()
|
|
return 2
|
|
}
|
|
var err error
|
|
cfg, err = mergeFlagConfig(cfg, urlFlag, hostFlag, tokenFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
updatecheck.MaybeNotify(stderr, version)
|
|
if interactive || pickup {
|
|
if yes {
|
|
fmt.Fprintln(stderr, "use either --interactive/--pickup or --yes, not both")
|
|
return 2
|
|
}
|
|
if documentFile != "" {
|
|
fmt.Fprintln(stderr, "--interactive/--pickup cannot be combined with --document-file")
|
|
return 2
|
|
}
|
|
// --interactive with no --job means auto-pickup; --pickup always does.
|
|
autoPickup := pickup || jobID <= 0
|
|
if interactive && jobID > 0 {
|
|
autoPickup = false
|
|
}
|
|
return runInteractiveJob(agentName, model, jobID, autoPickup, cfg, api.QueueFilter{
|
|
MinStars: minStars,
|
|
Language: languageFilter,
|
|
}, stderr)
|
|
}
|
|
|
|
client, err := cfg.Client()
|
|
if err != nil {
|
|
return printAPIConfigurationError(stderr, err)
|
|
}
|
|
|
|
repository, err := repoctx.Current()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "discover repository: %v\n", err)
|
|
return 1
|
|
}
|
|
owner, name, ok := repository.RemoteSlug()
|
|
if !ok {
|
|
owner, name = repository.GitHubOwner, repository.GitHubName
|
|
ok = owner != "" && name != ""
|
|
}
|
|
if !ok {
|
|
if o, n, err := repositoryFromFlagOrContext(""); err == nil {
|
|
owner, name = o, n
|
|
} else {
|
|
fmt.Fprintln(stderr, "current directory has no git remote origin; run inside a registered repo clone, or use --pickup / --job with the TUI to auto-clone")
|
|
return 1
|
|
}
|
|
}
|
|
|
|
var doc api.ScanDocument
|
|
var commitSHA string
|
|
var usedAgent string
|
|
repositoryHost := repository.Host
|
|
|
|
if documentFile != "" {
|
|
raw, err := readEvidence(documentFile, stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "read document: %v\n", err)
|
|
return 1
|
|
}
|
|
doc, err = reviewdoc.Parse(raw)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "parse Review Format: %v\n", err)
|
|
return 2
|
|
}
|
|
commitSHA = repository.CommitSHA
|
|
if jobID != 0 {
|
|
task, err := client.GetTask(ctx, jobID)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "get job: %v\n", err)
|
|
return 1
|
|
}
|
|
commitSHA = task.CommitSHA
|
|
repositoryHost = task.Repository.Host
|
|
}
|
|
if commitSHA == "" || len(commitSHA) < 40 {
|
|
fmt.Fprintln(stderr, "need a full 40-character commit SHA (git HEAD or job pin)")
|
|
return 1
|
|
}
|
|
usedAgent = strings.TrimSpace(model)
|
|
if usedAgent == "" {
|
|
usedAgent = "manual"
|
|
}
|
|
} else {
|
|
if agentName == "" {
|
|
fmt.Fprintln(stderr, "--agent is required unless --document-file is set")
|
|
flags.Usage()
|
|
return 2
|
|
}
|
|
registry := agent.Detect()
|
|
provider, found := registry.Find(agentName)
|
|
if !found {
|
|
fmt.Fprintf(stderr, "agent %q is not installed; try tarakan --agents\n", agentName)
|
|
return 1
|
|
}
|
|
usedAgent = strings.TrimSpace(model)
|
|
if usedAgent == "" {
|
|
usedAgent = provider.Name
|
|
}
|
|
|
|
var workDir string
|
|
if jobID != 0 {
|
|
task, err := client.GetTask(ctx, jobID)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "get job: %v\n", err)
|
|
return 1
|
|
}
|
|
if err := validateTaskRepository(task, repository); err != nil {
|
|
fmt.Fprintf(stderr, "refusing job: %v\n", err)
|
|
return 1
|
|
}
|
|
if !reviewdoc.FindingKinds[task.Kind] && task.Kind != "" {
|
|
fmt.Fprintf(stderr, "job %d kind %q is not a Report job (use tarakan check for verify_findings)\n", jobID, task.Kind)
|
|
return 1
|
|
}
|
|
commitSHA = task.CommitSHA
|
|
claimWasInactive := task.Lease == nil || !task.Lease.Active
|
|
if _, err := client.ClaimTask(ctx, jobID); err != nil {
|
|
fmt.Fprintf(stderr, "claim job: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Claimed job %d. Preparing isolated snapshot of %s…\n", jobID, shortSHA(commitSHA))
|
|
pinned, err := snapshot.Create(repository.Root, commitSHA)
|
|
if err != nil {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "snapshot failed (absolute symlinks or missing commit?): %v\n", err)
|
|
fmt.Fprintln(stderr, "Tip: fix external symlinks, or run: tarakan report --document-file FILE --job ID")
|
|
return 1
|
|
}
|
|
defer pinned.Close()
|
|
workDir = pinned.Root
|
|
prompt := reviewdoc.TaskFormatPromptForKind(task.Kind, task.Title, task.Description)
|
|
output, err := agent.Run(ctx, provider, agent.Request{Prompt: prompt, Directory: workDir})
|
|
if err != nil {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "agent failed: %v\n", err)
|
|
return 1
|
|
}
|
|
if changed, cerr := pinned.Changed(); cerr != nil || changed {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintln(stderr, "refusing output: agent modified the read-only snapshot")
|
|
return 1
|
|
}
|
|
doc, err = reviewdoc.Parse(sanitizeTerminalOutput(output))
|
|
if err != nil {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "agent did not return Review Format JSON: %v\n", err)
|
|
return 1
|
|
}
|
|
doc, err = reconcileReport(ctx, client, provider, repositoryHost, owner, name, commitSHA, workDir, doc, stderr)
|
|
if err != nil {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "reconcile repository memory: %v\n", err)
|
|
return 1
|
|
}
|
|
if changed, cerr := pinned.Changed(); cerr != nil || changed {
|
|
if claimWasInactive {
|
|
releaseClaimAfterFailure(client, jobID, stderr)
|
|
}
|
|
fmt.Fprintln(stderr, "refusing reconciled output: agent modified the read-only snapshot")
|
|
return 1
|
|
}
|
|
} else {
|
|
commitSHA = repository.CommitSHA
|
|
if len(commitSHA) < 40 {
|
|
fmt.Fprintln(stderr, "need a full commit SHA at HEAD")
|
|
return 1
|
|
}
|
|
pinned, err := snapshot.Create(repository.Root, commitSHA)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "prepare isolated snapshot: %v\n", err)
|
|
return 1
|
|
}
|
|
defer pinned.Close()
|
|
workDir = pinned.Root
|
|
fmt.Fprintf(stderr, "Running %s on %s @ %s (isolated snapshot)…\n", provider.Description, owner+"/"+name, shortSHA(commitSHA))
|
|
output, err := agent.Run(ctx, provider, agent.Request{
|
|
Prompt: reviewdoc.FormatPrompt,
|
|
Directory: workDir,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "agent failed: %v\n", err)
|
|
return 1
|
|
}
|
|
doc, err = reviewdoc.Parse(sanitizeTerminalOutput(output))
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "agent did not return Review Format JSON: %v\n", err)
|
|
return 1
|
|
}
|
|
doc, err = reconcileReport(ctx, client, provider, repositoryHost, owner, name, commitSHA, workDir, doc, stderr)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "reconcile repository memory: %v\n", err)
|
|
return 1
|
|
}
|
|
if changed, changeErr := pinned.Changed(); changeErr != nil {
|
|
fmt.Fprintf(stderr, "verify snapshot: %v\n", changeErr)
|
|
return 1
|
|
} else if changed {
|
|
fmt.Fprintln(stderr, "refusing output: agent modified the read-only snapshot")
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
|
|
if outputFile != "" {
|
|
encoded, _ := json.MarshalIndent(doc, "", " ")
|
|
if err := os.WriteFile(outputFile, append(encoded, '\n'), 0o600); err != nil {
|
|
fmt.Fprintf(stderr, "write --output: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Wrote findings to %s\n", outputFile)
|
|
}
|
|
|
|
fmt.Fprintf(stderr, "Report preview: %d finding(s)\n", len(doc.Findings))
|
|
for i, f := range doc.Findings {
|
|
if i >= 5 {
|
|
fmt.Fprintf(stderr, " … and %d more\n", len(doc.Findings)-5)
|
|
break
|
|
}
|
|
fmt.Fprintf(stderr, " [%s] %s: %s\n", f.Severity, f.File, f.Title)
|
|
}
|
|
|
|
if !yes {
|
|
fmt.Fprint(stderr, "Publish this Report to Tarakan? [y/N] ")
|
|
var answer string
|
|
fmt.Fscanln(stdin, &answer)
|
|
if strings.ToLower(strings.TrimSpace(answer)) != "y" && strings.ToLower(strings.TrimSpace(answer)) != "yes" {
|
|
fmt.Fprintln(stderr, "Aborted. Nothing published.")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
summary := reviewdoc.SummaryFromDocument(doc, 2_000)
|
|
if jobID != 0 {
|
|
task, err := client.SubmitTask(ctx, jobID, api.Submission{
|
|
Provenance: "agent",
|
|
Summary: summary,
|
|
Model: usedAgent,
|
|
PromptVersion: promptVersion,
|
|
Document: &doc,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "publish via job: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Published Report")
|
|
if task.LinkedReview != nil {
|
|
fmt.Fprintf(stderr, " #%d (%d findings, %s)", task.LinkedReview.ID, task.LinkedReview.FindingsCount, task.LinkedReview.ReviewStatus)
|
|
}
|
|
fmt.Fprintf(stderr, " via Job %d.\n", jobID)
|
|
return writeJSON(stdout, stderr, task)
|
|
}
|
|
|
|
// Ad-hoc report (no job)
|
|
if len(commitSHA) > 40 {
|
|
commitSHA = commitSHA[:40]
|
|
}
|
|
// Ensure full sha if short
|
|
if len(commitSHA) < 40 {
|
|
fmt.Fprintln(stderr, "commit SHA must be 40 characters for ad-hoc publish")
|
|
return 1
|
|
}
|
|
|
|
runID, err := api.NewRunID()
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 1
|
|
}
|
|
scan, err := client.SubmitScanForHost(ctx, repositoryHost, owner, name, api.ScanSubmission{
|
|
CommitSHA: strings.ToLower(commitSHA),
|
|
Provenance: "agent",
|
|
ReviewKind: kind,
|
|
Model: usedAgent,
|
|
PromptVersion: promptVersion,
|
|
RunID: runID,
|
|
Document: doc,
|
|
// Notes via document path - ScanSubmission may not have Notes; check type
|
|
})
|
|
if err != nil {
|
|
// Try with notes if API supports embedding in document only
|
|
fmt.Fprintf(stderr, "publish report: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Published Report #%d with %d finding(s) (status %s).\n", scan.ID, scan.FindingsCount, scan.ReviewStatus)
|
|
return writeJSON(stdout, stderr, scan)
|
|
}
|
|
|
|
func reconcileReport(
|
|
ctx context.Context,
|
|
client *api.Client,
|
|
provider agent.Provider,
|
|
host, owner, name, commitSHA, directory string,
|
|
discovery api.ScanDocument,
|
|
stderr io.Writer,
|
|
) (api.ScanDocument, error) {
|
|
memory, err := client.GetRepositoryMemoryForHost(ctx, host, owner, name, commitSHA)
|
|
if err != nil {
|
|
return api.ScanDocument{}, err
|
|
}
|
|
if len(memory.Findings) == 0 || len(discovery.Findings) == 0 {
|
|
return discovery, nil
|
|
}
|
|
|
|
fmt.Fprintf(stderr, "Reconciling %d independent finding(s) against %d canonical finding(s)…\n",
|
|
len(discovery.Findings), len(memory.Findings))
|
|
output, err := agent.Run(ctx, provider, agent.Request{
|
|
Prompt: reviewdoc.ReconciliationPrompt(memory, discovery),
|
|
Directory: directory,
|
|
})
|
|
if err != nil {
|
|
return api.ScanDocument{}, err
|
|
}
|
|
return reviewdoc.Parse(sanitizeTerminalOutput(output))
|
|
}
|
|
|
|
// runCheck records an independent Check (confirm/dispute) on a Report.
|
|
//
|
|
// tarakan check 17 --verdict confirmed --notes "…"
|
|
// tarakan check 17 --job 5 --verdict disputed --notes "…"
|
|
func runCheck(ctx context.Context, arguments []string, stdin io.Reader, stdout, stderr io.Writer, cfg api.Config) int {
|
|
flags := flag.NewFlagSet("check", flag.ContinueOnError)
|
|
flags.SetOutput(stderr)
|
|
var verdict, notes, provenance, evidenceFile string
|
|
var jobID int64
|
|
var urlFlag, hostFlag, tokenFlag string
|
|
flags.StringVar(&verdict, "verdict", "", "confirmed or disputed (required)")
|
|
flags.StringVar(¬es, "notes", "", "rationale, ≥20 characters (required)")
|
|
flags.StringVar(&provenance, "provenance", "human", "human, agent, or hybrid")
|
|
flags.StringVar(&evidenceFile, "evidence-file", "", "optional PoC / evidence file")
|
|
flags.Int64Var(&jobID, "job", 0, "optional Check Job ID to complete (verify_findings)")
|
|
addAPIFlags(flags, &urlFlag, &hostFlag, &tokenFlag)
|
|
flags.Usage = func() {
|
|
fmt.Fprintln(stderr, "Usage: tarakan check REPORT_ID --verdict confirmed|disputed --notes TEXT [--token TOKEN]")
|
|
fmt.Fprintln(stderr, " tarakan check REPORT_ID --job JOB_ID --verdict confirmed --notes TEXT")
|
|
fmt.Fprintln(stderr, "")
|
|
fmt.Fprintln(stderr, "Mass path: independently confirm or dispute a published Report.")
|
|
flags.PrintDefaults()
|
|
}
|
|
// The documented mass-facing form puts REPORT_ID first. Go's flag package
|
|
// stops at the first positional argument, so temporarily remove that ID and
|
|
// parse the remaining flags before restoring it.
|
|
var leadingReportID string
|
|
if len(arguments) > 0 && !strings.HasPrefix(arguments[0], "-") {
|
|
leadingReportID = arguments[0]
|
|
arguments = arguments[1:]
|
|
}
|
|
if err := flags.Parse(arguments); err != nil {
|
|
return 2
|
|
}
|
|
positionals := flags.Args()
|
|
if leadingReportID != "" {
|
|
positionals = append([]string{leadingReportID}, positionals...)
|
|
}
|
|
if len(positionals) != 1 {
|
|
flags.Usage()
|
|
return 2
|
|
}
|
|
reportID, err := strconv.ParseInt(positionals[0], 10, 64)
|
|
if err != nil || reportID <= 0 {
|
|
fmt.Fprintln(stderr, "REPORT_ID must be a positive integer")
|
|
return 2
|
|
}
|
|
verdict = strings.ToLower(strings.TrimSpace(verdict))
|
|
if verdict != "confirmed" && verdict != "disputed" {
|
|
fmt.Fprintln(stderr, "--verdict must be confirmed or disputed")
|
|
return 2
|
|
}
|
|
notes = strings.TrimSpace(notes)
|
|
if utf8.RuneCountInString(notes) < 20 {
|
|
fmt.Fprintln(stderr, "--notes must be at least 20 characters")
|
|
return 2
|
|
}
|
|
provenance = strings.ToLower(strings.TrimSpace(provenance))
|
|
if provenance != "human" && provenance != "agent" && provenance != "hybrid" {
|
|
fmt.Fprintln(stderr, "--provenance must be human, agent, or hybrid")
|
|
return 2
|
|
}
|
|
|
|
var evidence string
|
|
if evidenceFile != "" {
|
|
evidence, err = readEvidence(evidenceFile, stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "read evidence: %v\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
cfg, err = mergeFlagConfig(cfg, urlFlag, hostFlag, tokenFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
client, err := cfg.Client()
|
|
if err != nil {
|
|
return printAPIConfigurationError(stderr, err)
|
|
}
|
|
|
|
if jobID != 0 {
|
|
if _, err := client.ClaimTask(ctx, jobID); err != nil {
|
|
// may already be claimed by us
|
|
fmt.Fprintf(stderr, "note: claim: %v\n", err)
|
|
}
|
|
task, err := client.SubmitTask(ctx, jobID, api.Submission{
|
|
Provenance: provenance,
|
|
Verdict: verdict,
|
|
Notes: notes,
|
|
Summary: notes,
|
|
Evidence: evidence,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "check via job: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Recorded Check on Report via Job %d (verdict=%s).\n", jobID, verdict)
|
|
return writeJSON(stdout, stderr, task)
|
|
}
|
|
|
|
repository, err := repoctx.Current()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "discover repository: %v\n", err)
|
|
return 1
|
|
}
|
|
owner, name, ok := repository.RemoteSlug()
|
|
if !ok {
|
|
owner, name = repository.GitHubOwner, repository.GitHubName
|
|
ok = owner != "" && name != ""
|
|
}
|
|
if !ok {
|
|
fmt.Fprintln(stderr, "current directory has no git remote origin (owner/name)")
|
|
return 1
|
|
}
|
|
|
|
scan, err := client.SubmitVerdictForHost(ctx, repository.Host, owner, name, reportID, api.Verdict{
|
|
Verdict: verdict,
|
|
Provenance: provenance,
|
|
Notes: notes,
|
|
Evidence: evidence,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "check report: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Recorded Check on Report #%d (verdict=%s).\n", reportID, verdict)
|
|
return writeJSON(stdout, stderr, scan)
|
|
}
|
|
|
|
// runCheckFinding records an independent check on one canonical finding UUID.
|
|
//
|
|
// tarakan check-finding UUID --verdict confirmed|disputed|fixed --notes "…"
|
|
func runCheckFinding(ctx context.Context, arguments []string, stdin io.Reader, stdout, stderr io.Writer, cfg api.Config) int {
|
|
flags := flag.NewFlagSet("check-finding", flag.ContinueOnError)
|
|
flags.SetOutput(stderr)
|
|
var verdict, notes, provenance, evidenceFile, commitSHA string
|
|
var urlFlag, hostFlag, tokenFlag string
|
|
flags.StringVar(&verdict, "verdict", "", "confirmed, disputed, or fixed (required)")
|
|
flags.StringVar(¬es, "notes", "", "rationale, ≥20 characters (required)")
|
|
flags.StringVar(&provenance, "provenance", "agent", "human, agent, or hybrid")
|
|
flags.StringVar(&evidenceFile, "evidence-file", "", "optional PoC / evidence file")
|
|
flags.StringVar(&commitSHA, "commit", "", "full commit SHA (defaults to git HEAD)")
|
|
addAPIFlags(flags, &urlFlag, &hostFlag, &tokenFlag)
|
|
flags.Usage = func() {
|
|
fmt.Fprintln(stderr, "Usage: tarakan check-finding FINDING_UUID --verdict confirmed|disputed|fixed --notes TEXT")
|
|
fmt.Fprintln(stderr, "")
|
|
fmt.Fprintln(stderr, "Independently check one canonical finding (matches the web Checks form).")
|
|
flags.PrintDefaults()
|
|
}
|
|
var leadingID string
|
|
if len(arguments) > 0 && !strings.HasPrefix(arguments[0], "-") {
|
|
leadingID = arguments[0]
|
|
arguments = arguments[1:]
|
|
}
|
|
if err := flags.Parse(arguments); err != nil {
|
|
return 2
|
|
}
|
|
positionals := flags.Args()
|
|
if leadingID != "" {
|
|
positionals = append([]string{leadingID}, positionals...)
|
|
}
|
|
if len(positionals) != 1 {
|
|
flags.Usage()
|
|
return 2
|
|
}
|
|
findingID := strings.TrimSpace(positionals[0])
|
|
if findingID == "" {
|
|
fmt.Fprintln(stderr, "FINDING_UUID is required")
|
|
return 2
|
|
}
|
|
verdict = strings.ToLower(strings.TrimSpace(verdict))
|
|
if verdict != "confirmed" && verdict != "disputed" && verdict != "fixed" {
|
|
fmt.Fprintln(stderr, "--verdict must be confirmed, disputed, or fixed")
|
|
return 2
|
|
}
|
|
notes = strings.TrimSpace(notes)
|
|
if utf8.RuneCountInString(notes) < 20 {
|
|
fmt.Fprintln(stderr, "--notes must be at least 20 characters")
|
|
return 2
|
|
}
|
|
provenance = strings.ToLower(strings.TrimSpace(provenance))
|
|
if provenance != "human" && provenance != "agent" && provenance != "hybrid" {
|
|
fmt.Fprintln(stderr, "--provenance must be human, agent, or hybrid")
|
|
return 2
|
|
}
|
|
|
|
var evidence string
|
|
var err error
|
|
if evidenceFile != "" {
|
|
evidence, err = readEvidence(evidenceFile, stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "read evidence: %v\n", err)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
cfg, err = mergeFlagConfig(cfg, urlFlag, hostFlag, tokenFlag)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err)
|
|
return 2
|
|
}
|
|
client, err := cfg.Client()
|
|
if err != nil {
|
|
return printAPIConfigurationError(stderr, err)
|
|
}
|
|
|
|
repository, err := repoctx.Current()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "discover repository: %v\n", err)
|
|
return 1
|
|
}
|
|
owner, name, ok := repository.RemoteSlug()
|
|
if !ok {
|
|
owner, name = repository.GitHubOwner, repository.GitHubName
|
|
ok = owner != "" && name != ""
|
|
}
|
|
if !ok {
|
|
fmt.Fprintln(stderr, "current directory has no git remote origin (owner/name)")
|
|
return 1
|
|
}
|
|
commitSHA = strings.ToLower(strings.TrimSpace(commitSHA))
|
|
if commitSHA == "" {
|
|
commitSHA = strings.ToLower(strings.TrimSpace(repository.CommitSHA))
|
|
}
|
|
if len(commitSHA) != 40 {
|
|
fmt.Fprintln(stderr, "need a full 40-char commit SHA (--commit or git HEAD)")
|
|
return 2
|
|
}
|
|
|
|
err = client.SubmitFindingVerdictForHost(ctx, repository.Host, owner, name, findingID, api.FindingVerdict{
|
|
CommitSHA: commitSHA,
|
|
Verdict: verdict,
|
|
Provenance: provenance,
|
|
Notes: notes,
|
|
Evidence: evidence,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "check finding: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintf(stderr, "Recorded check on finding %s (verdict=%s) at %s.\n", findingID, verdict, shortSHA(commitSHA))
|
|
return 0
|
|
}
|
|
|
|
func shortSHA(sha string) string {
|
|
if len(sha) >= 12 {
|
|
return sha[:12]
|
|
}
|
|
return sha
|
|
}
|