devguard

CLI and API

Use the devguard CLI to upload the output of your checks into evidence records on a schedule, or call the Evidence API directly from your own scripts.

Most evidence is a file that someone exported once. A year later the file is old, and nobody notices until an auditor asks. The devguard CLI runs the same export on a schedule. It uploads the output into the evidence record you name.

Each collector (one command in your configuration) replaces its own previous file on every push. The old file stays under Previous versions. If a file is not refreshed in time, it appears on Deadlines under Automated Evidence, and the owner role gets a notice.

This page has two parts:

  • The CLI setup, from an empty repository to a scheduled job (steps 1 to 7).
  • The Evidence API, for your own scripts that list, create or upload records.

Every command, flag and configuration field is in the CLI reference.

Who does what

Two people share the work: the owner of the evidence and the person who runs the build pipeline. An owner or an admin creates the token. Do the steps in this order.

StepWhereWho
Create the evidence record and link its controlsdevguardThe owner of the evidence
Create an API token for the pipelinedevguardAn owner or an admin
Install the tool and sign inA terminalThe person who runs the pipeline
Check the connectiondevguard and the terminalThe same person
Commit the configuration and push onceThe repository, a terminalThe person who runs the pipeline
Schedule the jobThe pipelineThe person who runs the pipeline

The setup is one file in your repository and one scheduled job. If the person who built it leaves and the job stops, the file expires and appears on Deadlines.

1. Create the evidence record

Create the record in Evidence and link it to its controls. The configuration uses the record's number, such as EV-42. The CLI only writes into records that exist. It never creates a record. If the number does not exist, the push fails before any command runs.

2. Create an API token for the pipeline

A pipeline cannot open a browser, so it needs a token. On your own computer you can skip this step, because signing in opens a browser.

Create an organization token under Settings, then Tokens. Give it read and write access to the Evidence area. An organization token belongs to the organization, so it keeps working when people leave. A personal token from Account, then Tokens, also works, but it stops when its creator leaves.

Store the token as a masked secret in your pipeline. Never put it in the repository. Replace it if anyone else sees it.

Watch out: a new token expires after 90 days unless you choose another date. An expired token stops the job without a warning, so choose a long expiry or never. A member's token does not work either: only owners, admins and organization tokens can push.

3. Install and sign in

The npm package needs Node 20 or newer. A runner without Node can use the Linux binaries (x64 and arm64) on each GitHub release. Check them against the SHA256SUMS file next to them.

npm install -g @devguardch/cli
devguard login

Signing in opens your browser. Choose the organization this machine may use and allow the connection. You do not paste anything into the terminal.

The login works for that one organization and stores no permanent secret. It stops after seven days without use. An admin can end it under Settings, Integrations, Connected apps. devguard logout ends it on this machine.

On a computer without a browser, run devguard login --key. It asks for an API token and hides what you type. You can also pass the token as devguard login --key dvgo_... or in the DEVGUARD_API_KEY variable.

Note the organization id that the login prints. It is not the name in the address bar. Your pipeline needs this id.

4. Check the connection

Before you set up the pipeline, you can check that the tool reaches devguard. The check on the CLI page in the app watches only your personal API tokens from Account, then Tokens. An organization token or a browser login never turns it green. Listen for a signal stays gray until you have a personal token.

  1. If you have no personal token, create one under Account, then Tokens.
  2. In the app, open Evidence, then CLI. Under Try it, choose Listen for a signal. The page waits for five minutes.
  3. On the machine that will push, run devguard login --key and paste the personal token.
  4. The card shows Signal received and the token name. Nothing is uploaded and no record changes.

To check an organization token instead, run devguard evidence validate (step 6). Then look at its Last used date under Settings, then Tokens.

The CLI page after a successful check: the How it works card next to the Try it card, which shows Signal received and the token name Pipeline check.
The personal token "Pipeline check" signed in, so the machine can reach devguard.

5. Describe what to collect

Run devguard init on your own computer. It asks a few questions and writes a first devguard.yml. You can also write the file by hand:

devguard.yml
# CI runners have no stored login, so name the organization here.
# devguard login prints the id; it is not the slug from the URL.
organization: <your organization id>

collectors:
  # The name is the file's identity. Every push replaces this
  # collector's previous file, so the record holds one current file.
  - name: npm-audit
    command: npm audit --json
    evidence: 42
    # npm audit exits 1 as soon as it finds anything, so accept it.
    allowExitCodes: [0, 1]
    expiresAt: 7d

  # The recipe form picks an installed scanner and fills in the
  # command, the output file and the exit codes for you.
  - name: vulnerability-scan
    uses: scan
    evidence: 57
    expiresAt: 7d
FieldRequiredDefaultNotes
nameyesLowercase slug, unique in the file. It identifies the file. A new name starts a new series.
commandyesThe command to run. Leave it out with uses: scan.
evidenceyes42, EV-42 or ev-042. The record must already exist.
outputnostdoutA file path when the command writes to disk. An empty output fails the collector.
expiresAtno30dHow long the file stays current, or never.
timeoutno10mAt most 24d.
allowExitCodesno[0]Set [0, 1] for scanners that exit 1 on findings.
allowSecretsnofalseLets this collector upload output that matches the secret scan.

The CLI rejects unknown fields, so a typo fails at once. Durations use seconds, minutes, hours, days and weeks. Use 12w for a quarter, because the CLI does not know months or years. Add the .devguard folder to your gitignore. It holds the files before upload. The other fields and the scan recipe are in the configuration reference.

6. Push once by hand

devguard evidence validate        # config, credentials and every EV number
devguard evidence push --dry-run  # runs everything, uploads nothing
devguard evidence push

validate reads the configuration, checks your login and checks that every evidence number exists. It runs nothing. A dry run runs the collectors and the secret scan, but uploads nothing.

After the push, the record's Attachments show one file per collector, with an Automated badge and the collector name.

7. Run it on a schedule

Any tool that runs a command on a schedule works: a CI service, a cron job on a server or a scheduled task. GitHub Actions is only the shortest example.

.github/workflows/evidence.yml
name: evidence
on:
  schedule: [{ cron: '17 3 * * *' }]
  workflow_dispatch:
jobs:
  push-evidence:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npx @devguardch/cli evidence push
        env:
          DEVGUARD_API_KEY: ${{ secrets.DEVGUARD_API_KEY }}

Make the expiry longer than the time between two runs. Otherwise the file expires between two good pushes. The runner must reach devguard and its file storage, because the file goes directly to storage.

What leaves your machine

The command runs on your machine or your runner, never on devguard's servers. Only the output file you name is uploaded. Your repository, cloud accounts and passwords stay where they are.

Before upload, the CLI checks each file for secrets, such as cloud keys, private keys, tokens and long random strings. If it finds one, it stops the push and prints a masked report. If a collector's output must contain such a string, set allowSecrets: true on that collector. devguard evidence push --allow-secrets turns the check off for one whole run.

What a push changes, and what it does not

  • The collector name identifies the file. A push replaces only that collector's previous file. Files uploaded by hand and files from other collectors stay.
  • The replaced file stays under Previous versions. If you rename a collector, a new series starts, and nothing refreshes the old file.
  • Each file has an expiry date. When it passes, the file appears on Deadlines under Automated Evidence, and the owner role gets a notice.
  • A push does not change the record's status, control coverage or review date. A record on To do stays on To do. The owner sets the status and the approver reviews.
  • If a collector fails, it uploads nothing and its previous file stays. The other collectors still push, and the run ends with exit code 1.

Evidence API

A runner without Node, or your own script, can call the REST API directly. The base URL, authentication and all evidence endpoints are in the API section of the Evidence page. This section lists the request fields and shows examples.

List records

ParameterTypeNotes
pagenumberFrom 1. Default 1.
limitnumber5 to 100. Default 100.
filterByNamestringTitle, case-insensitive, or an EV-n code.
statusenumTODO, IN_PROGRESS, DONE, FAILED or ARCHIVED. Without it, all statuses are returned.
filterByArchivedenumactive or archived. Ignored when status is set.
categoryIdstringOne category.
filterByLabelsstring, repeatableRecords with any of these label ids: filterByLabels=a&filterByLabels=b.
filtersJSONThe list toolbar filters. See the example on the Evidence page.
sortJSON[{"id":"title","desc":false}], at most 5 entries. Ids: shortId, title, description, status, nextReviewAt, owner, approver, category.
sortColumn, sortDirectionstring, asc or descOne sort id and its direction. Ignored when sort is set; sortColumn alone is ignored.

Create and update records

FieldCreateUpdateType and limits
titlerequiredrequiredstring, 2 to 255
ownerIdrequiredoptionalbusiness role id, or null on update
approverIdrequiredoptionalbusiness role id, or null on update
descriptionoptionaloptionalstring or null
statusoptional, TODOoptionalTODO, IN_PROGRESS, DONE, FAILED or ARCHIVED
categoryIdoptionaloptionalcategory id or null
reviewScheduleIdoptionaloptionalschedule id or null
continuousReviewoptional, falseoptionalboolean; not allowed together with a schedule
labelsoptionaloptionallabel ids; if you leave them out on update, they stay; if you send them, they replace the old ones

devguard sets the review dates and the approval fields itself. See the API section of the Evidence page.

Create a record
DG_URL=https://app.devguard.ch
API="$DG_URL/api"
AUTH="Authorization: Bearer $DG_TOKEN"

curl -sS -X POST "$API/evidence/" \
  -H "$AUTH" -H 'Content-Type: application/json' \
  -d "$(jq -n --arg org "$DG_ORG" \
        --arg o "$OWNER_ROLE" --arg a "$APPROVER_ROLE" '{
          organizationId: $org,
          title: "Quarterly access review",
          ownerId: $o, approverId: $a }')" \
  | jq '{id, shortId, status}'

Upload a file

A file arrives in three calls, the same ones the CLI uses: prepare the upload, send the file to storage, then finish.

FieldRequiredType and limits
organizationIdyesthe organization id
evidenceIdyesthe record's id, from GET /evidence/short/{shortId}/. The endpoint also accepts assessmentId instead, for vendor assessments, but then not sourceKey
originalNameyesfile name
mimeTypeyescontent type; storage checks it
sizeyesbytes, 1 to 209,715,200
sha256no64 lowercase hex characters; storage checks the bytes against it
sourceKeynolowercase slug, at most 100; replaces the previous file with the same key, like a collector name
issuedAt, expiresAtnoISO date or null; expiresAt is the expiry date
documentKindnoSOC2, ISO27001, PENTEST, DPA, AOC, BAA or OTHER
title, descriptionnostring
  1. POST /evidence/files/prepare-upload/ creates a hidden draft. It returns a fileId, a storage url and fields.
  2. Within ten minutes, send a multipart POST to that url. Put all entries of fields first, in the given order, and the file part last. Do not send the bearer token.
  3. Call POST /evidence/files/finalize-upload/ with organizationId and fileId. devguard checks that the file exists and is not empty, saves its real size and lists it. With a sourceKey, the previous file with that key moves to Previous versions.

If step 2 fails, DELETE /evidence/files/{fileId}/?organizationId=… deletes the draft.

The script below follows the CLI's own steps. Unlike the CLI, sourceKey is optional, so a one-off upload adds a file instead of replacing one.

upload.sh
#!/usr/bin/env bash
# DG_URL=https://app.devguard.ch DG_TOKEN=dvg_... DG_ORG=<org id>
# ./upload.sh <number, e.g. 42> <file> [sourceKey]
set -euo pipefail
ev=$1 f=$2 key=${3:-}
api="$DG_URL/api" org=$DG_ORG
c=(curl --fail-with-body -sS)
auth=(-H "authorization: Bearer $DG_TOKEN")
json=(-H 'content-type: application/json')
name=$(basename "$f")
ext=$(tr '[:upper:]' '[:lower:]' <<<"${name##*.}")
case $ext in
  json) mime=application/json ;;
  sarif) mime=application/sarif+json ;;
  xml) mime=application/xml ;;
  txt) mime=text/plain ;;
  csv) mime=text/csv ;;
  html) mime=text/html ;;
  *) mime=application/octet-stream ;;
esac

# 0. record number to evidence id
id=$("${c[@]}" "${auth[@]}" \
  "$api/evidence/short/$ev/?organizationId=$org" | jq -r .id)

# 1. prepare: draft row and presigned POST
body=$(jq -n --arg org "$org" --arg ev "$id" \
  --arg name "$name" --arg mime "$mime" \
  --argjson size "$(wc -c <"$f" | tr -d ' ')" \
  --arg sha "$(shasum -a 256 "$f" | cut -d' ' -f1)" \
  --arg key "$key" --arg now "$(date -u +%FT%TZ)" \
  '{organizationId: $org, evidenceId: $ev,
    originalName: $name, mimeType: $mime, size: $size,
    sha256: $sha, issuedAt: $now, expiresAt: null}
   + if $key == "" then {} else {sourceKey: $key} end')
prep=$("${c[@]}" "${auth[@]}" "${json[@]}" -d "$body" \
  "$api/evidence/files/prepare-upload/")
fid=$(jq -r .fileId <<<"$prep")

# 2. storage: every field in order, the file last, no bearer
form=()
while IFS= read -r kv; do form+=(--form-string "$kv"); done \
  < <(jq -r '.fields | to_entries[] | "\(.key)=\(.value)"' \
      <<<"$prep")
"${c[@]}" "${form[@]}" \
  -F "file=@$f;type=$mime;filename=$name" \
  "$(jq -r .url <<<"$prep")" || {
  "${c[@]}" -X DELETE "${auth[@]}" \
    "$api/evidence/files/$fid/?organizationId=$org"
  exit 1
}

# 3. finalize: devguard checks the object and lists the file
"${c[@]}" "${auth[@]}" "${json[@]}" \
  -d "$(jq -n --arg o "$org" --arg f "$fid" \
        '{organizationId: $o, fileId: $f}')" \
  "$api/evidence/files/finalize-upload/"

Use --form-string, not -F, for the fields. With -F, curl reads a value that starts with @ or < as a file name.

Commands

CommandWhat it does
devguard loginOpens your browser to sign in and stores the login for the target address. --key verifies and stores an API token instead.
devguard logoutEnds the login on this machine and deletes it.
devguard initAsks questions and writes a first devguard.yml.
devguard evidence validateChecks only. Nothing runs and nothing is uploaded.
devguard evidence pushRuns collectors, scans for secrets, uploads and replaces. --dry-run runs everything but uploads nothing.
devguard scan --evidence 42Runs an installed scanner and pushes its report, without a configuration file.

Connection settings are read in this order: the --url and --key flags, then DEVGUARD_API_URL and DEVGUARD_API_KEY, then the stored login, then https://app.devguard.ch.

The organization comes from the --org flag, then organization: in the configuration, then the default from the login. For push, validate and scan, this must be the organization id. Only login and init accept the name from the address bar. A browser login works for one organization, so a configuration with another one is rejected before anything is sent. All flags are in the commands reference.

When a push fails

MessageCause
Authentication failedThe API token is wrong or expired. Fix DEVGUARD_API_KEY in the pipeline, or run devguard login --key.
Not logged inNo credential at all: run devguard login, set DEVGUARD_API_KEY, or pass --key.
This login for ... has expired or was revokedThe browser login was disconnected, or went seven days without use. Run devguard login again.
EV-42 does not exist in this organizationCreate the record in devguard first.
This login has no membership in organizationUsually the slug was used where the organization id is required.
Access deniedThe account behind the credentials is a member. Only owners, admins and organization tokens can push.
Your role can read this organization but not change itThe account is an auditor, which can only read.
command produced no output on stdoutThe command wrote nothing. Empty artifacts are refused.
command exited 1 (allowed: 0)The scanner exits 1 on findings. Add allowExitCodes: [0, 1] to that collector.
not attempted: the login is no longer usableThe browser login expired or was revoked mid-run, so the remaining collectors were skipped. Run devguard login and push again.

How is this guide?

On this page