Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
660
cmd/tarakan/report.go
Normal file
660
cmd/tarakan/report.go
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue