Codeward Scanner is a lightweight, self-contained security, governance, and compliance tool for software projects. From a single binary, it scans dependencies for vulnerabilities and licenses, validates configuration files and pull requests, and enforces custom policies. Vulnerability intelligence comes from Codeward Intel — queried live over the API, or from downloadable per-ecosystem snapshots for offline and air-gapped use — with no scanner engine or database to bundle. It ships with built-in parsers for 19 lockfile formats across 12 ecosystems — Node.js, Python, Go, Rust, Ruby, PHP, .NET, Java, Swift, Dart, Elixir and C/C++.
- Vulnerability scanning — CVE detection with severity levels, CVSS scoring, fix availability, and dependency tree tracking
- License detection — Multi-source license resolution with SPDX classification and category-based policies
- Policy engine — Flexible rules for vulnerabilities, licenses, packages, files, and pull requests
- File validation — Validate JSON, YAML, TOML, text,
.env, and.propertiesfiles against policies - PR validation — Enforce PR conventions (title format, branch naming, size limits, required labels)
- Diff detection — Compare scan results over time, categorize findings as new, removed, existing, or changed
- GitHub integration — PR comments, issues (with smart reopen/close), and Code Scanning via SARIF upload
- Custom webhooks — Send findings to Slack, JIRA, PagerDuty, or any HTTP endpoint
- Multiple output formats — Markdown, HTML, JSON, SARIF 2.1.0 to files, GitHub, or webhooks
- SBOM export — CycloneDX 1.6 JSON Software Bill of Materials
- Exit code control — Policies with
blockaction cause non-zero exit for CI/CD gating - YAML & JSON config — Write policies in
.codeward.yaml,.codeward.yml, or.codeward.json
- uses: codeward-io/scan@v0.0.4
with:
token: ${{ secrets.GITHUB_TOKEN }}All inputs have sensible defaults from the GitHub Actions context. Available inputs:
| Input | Default | Description |
|---|---|---|
event |
${{ github.event_name }} |
Event name (e.g. pull_request, push) |
repository |
${{ github.repository }} |
Repository (owner/name) |
current_branch |
${{ github.ref }} |
Current branch ref |
pr_number |
${{ github.event.number }} |
Pull request number |
token |
${{ github.token }} |
GitHub token |
webhook_secrets |
— | Multiline KEY=VALUE pairs for webhook templates |
The action automatically checks out the base and branch code, pulls the scanner Docker image, and runs in diff mode for pull requests or main mode for pushes. Policy configuration is discovered from .codeward.json, .codeward.yaml, or .codeward.yml in your repository root.
docker pull ghcr.io/codeward-io/scan:latest
docker run --rm -v $(pwd):/workspace ghcr.io/codeward-io/scan:latestPre-built binaries are available for each release:
| OS | Architecture | Binary |
|---|---|---|
| Linux | amd64, arm64 | codeward-scan-linux-amd64 / codeward-scan-linux-arm64 |
| macOS | amd64, arm64 | codeward-scan-darwin-amd64 / codeward-scan-darwin-arm64 |
| Windows | amd64, arm64 | codeward-scan-windows-amd64.exe / codeward-scan-windows-arm64.exe |
Download from the Releases page.
- Create a
.codeward.yamlin your repository root:
vulnerability:
- name: "block-critical"
actions:
new: block
existing: warn
rules:
- field: Severity
type: eq
value: CRITICAL
outputs:
- destination: "github:pr"
template: table
format: markdown- Run the scanner:
scanIf you installed a release binary named codeward-scan, use codeward-scan instead of scan.
The scanner automatically discovers config in this order: .codeward.json, .codeward.yaml, .codeward.yml.
The scanner uses Codeward Intel for vulnerability intelligence.
apimode (default) — Query Intel API in real timedownloadmode — Download per-ecosystem snapshot segments and query locallylocalmode — Use previously cached snapshots only (air-gapped)disabledmode — Skip Intel vulnerability data (scan continues without vulnerability detection)
Configure mode with --intel-mode or CODEWARD_INTEL_MODE.
- CVE-based scanning with severity levels: CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
- CVSS scoring:
CVSSScore(float, best available V4 > V3 > V2) andCVSSVectorfor fine-grained risk filtering - Fix availability: Shows fixed version when available, including closest fixed version
- Dependency tree: Tracks vulnerability propagation through dependency chains
- Multi-source scanning: Scans multiple lockfiles and tracks results per source path
Multi-source license resolution fills in missing license data:
| Priority | Source | Description |
|---|---|---|
| 1 | Lockfile | Parsed directly from lockfile metadata |
| 2 | Local filesystem | Scans installed dependency directories for LICENSE files |
| 3 | Upstream registries | Fetches from npm, PyPI, RubyGems, crates.io, NuGet, Hex.pm, pub.dev, Go proxy |
| 4 | GitHub API | Last resort — fetches LICENSE file from GitHub repos |
Results are cached to disk for 30 days. License findings include SPDX identifiers, a category classification, severity levels, and confidence scores.
Every license is classified into exactly one category, which sets its default severity. These are the only values a Category rule can match:
| Category | Severity | Examples |
|---|---|---|
forbidden |
CRITICAL | AGPL-1.0, AGPL-3.0 — network copyleft |
restricted |
HIGH | GPL-2.0, GPL-3.0, LGPL-3.0 |
reciprocal |
MEDIUM | MPL-2.0, EPL-2.0, CDDL-1.0 |
notice |
LOW | MIT, Apache-2.0, BSD-3-Clause, Zlib |
permissive |
LOW | MIT-0, BlueOak-1.0.0, Unicode-3.0 |
unencumbered |
LOW | CC0-1.0, Unlicense — public domain |
unknown |
UNKNOWN | Unrecognised or non-SPDX license text |
Note:
Categoryvalues are lowercase and exact, and there is noCopyleftcategory. A rule such as{"field": "Category", "type": "eq", "value": "Copyleft"}parses cleanly but matches nothing — userestrictedorreciprocalinstead. By default the GPL and LGPL families are bothrestricted;reciprocalcovers MPL-, EPL- and CDDL-style licenses. (WithCODEWARD_INTEL_CLASSIFICATION=true, LGPL moves toreciprocal— see below.)
A license is classified by the scanner's built-in table, by Codeward Intel, or
by both. Which one wins depends on CODEWARD_INTEL_CLASSIFICATION
(--intel-classification), default false.
Gaps are always filled from Intel, in both modes. A license the built-in
table does not recognise takes Intel's classification rather than staying
unknown — that costs nothing, because nothing was matching an unclassified
license anyway. Where Intel publishes a resolved category it is used directly;
otherwise its copyleft strength fills in, with strong mapping to restricted
(or forbidden for AGPL-family identifiers, whose obligation extends over a
network), weak to reciprocal, and none to notice.
Only overruling is gated by the flag.
CODEWARD_INTEL_CLASSIFICATION |
Behaviour |
|---|---|
false (default) |
The built-in table wins wherever it has a verdict. Differences are not applied, but they are logged once per scan so you can see what would change. |
true |
Intel's category and severity are authoritative. The built-in table becomes a fallback for licenses Intel has no verdict for. |
Turning it on is a behaviour change for existing policies, which is why it is opt-in. Some licenses are classified differently by the two, and a rule filtering on the old category stops matching them:
| License | Built-in | Intel | Effect |
|---|---|---|---|
| CC-BY-SA family (23 identifiers) | restricted |
reciprocal |
the largest group — a restricted rule stops matching them |
| LGPL family | restricted |
reciprocal |
|
| Artistic family | notice |
reciprocal |
|
| WTFPL | forbidden |
unencumbered |
fixes a false positive: WTFPL is public-domain equivalent, and a policy blocking forbidden currently fails builds over it |
| 0BSD | unencumbered |
permissive |
aligns with MIT-0, which has identical no-attribution semantics |
Read the logged differences from a real scan before enabling it, and update any policy that filters on a category in the table above.
19 lockfile formats across 12 ecosystems, with no external parser dependencies:
| Ecosystem | Lockfiles |
|---|---|
| Node.js | package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock |
| Python | Pipfile.lock, poetry.lock, requirements.txt, uv.lock |
| Ruby | Gemfile.lock |
| Go | go.mod |
| Rust | Cargo.lock |
| PHP | composer.lock |
| .NET | packages.lock.json |
| Dart/Flutter | pubspec.lock |
| Swift | Package.resolved, Podfile.lock |
| Java | gradle.lockfile |
| Elixir | mix.lock |
| C/C++ | conan.lock |
Use Intel local snapshot mode for air-gapped environments:
scan --intel-mode localOr via environment variable:
CODEWARD_INTEL_MODE=localUse CODEWARD_CACHE_DIR (or CODEWARD_INTEL_CACHE) to control snapshot cache location.
The policy engine evaluates scan results and file/PR content against user-defined rules.
| Type | Description | Target |
|---|---|---|
vulnerability |
Rules for security vulnerabilities | CVE findings |
license |
Rules for software licenses | License detections |
package |
Rules for package dependencies | Package metadata |
file |
Rules for file content validation | Config files, manifests |
pr |
Rules for pull request validation | PR metadata and files |
| Action | Description | Exit Code |
|---|---|---|
info |
Informational only, no impact | 0 |
warn |
Warning, logged but not blocking | 0 |
block |
Blocking, causes non-zero exit | 1 |
ignore |
Suppress from output | 0 |
Actions are triggered based on change type: new, existing, removed, changed.
| Type | Description | Example |
|---|---|---|
eq |
Equals | {"field": "Severity", "type": "eq", "value": "CRITICAL"} |
ne |
Not equals | {"field": "Severity", "type": "ne", "value": "LOW"} |
lt, gt, le, ge |
Numeric comparison | {"field": "CVSSScore", "type": "ge", "value": "8.5"} |
contains |
Contains substring | {"field": "Title", "type": "contains", "value": "injection"} |
not_contains |
Does not contain | {"field": "PkgName", "type": "not_contains", "value": "test"} |
hasPrefix |
Starts with | {"field": "VulnerabilityID", "type": "hasPrefix", "value": "CVE-2024"} |
hasSuffix |
Ends with | {"field": "PkgName", "type": "hasSuffix", "value": "-dev"} |
regex |
Regex match | {"field": "Description", "type": "regex", "value": "remote.*code"} |
not_regex |
Regex non-match | {"field": "Title", "type": "not_regex", "value": "^(feat\|fix):"} |
in |
In set | {"field": "Severity", "type": "in", "value": "CRITICAL,HIGH"} |
not_in |
Not in set | {"field": "Category", "type": "not_in", "value": "test,mock"} |
exists |
Field/path exists | {"type": "exists", "key": "engines.node"} |
not_exists |
Field/path doesn't exist | {"type": "not_exists", "path": ".env.local"} |
last_match |
Last line matching filter matches value | {"type": "last_match", "value": "root", "line_filter": "^USER\\s+"} |
not_last_match |
Last line matching filter does NOT match | {"type": "not_last_match", "value": "root", "line_filter": "^USER\\s+"} |
Note: Regex uses Go's RE2 engine, which guarantees linear-time matching. See RE2 Regex Reference for supported features and workarounds.
and— All rules must matchor— Any rule must match (default)implies— Conditional logic: first rule is the trigger, remaining rules are conditions. If the trigger fails, the policy passes silently.
Suppress specific items within a policy:
vulnerability:
- name: "block-critical"
ignore:
- name: "ignore-test-packages"
rules:
- field: PkgName
type: contains
value: testTime-limited suppressions with an expires date (ISO 8601):
ignore:
- name: "CVE-2024-1234 waiting vendor patch"
expires: "2026-06-30"
author: "security-team"
description: "Vendor confirmed fix in next release"
rules:
- field: VulnerabilityID
type: eq
value: CVE-2024-1234Expired rules are automatically skipped. Rules without expires never expire.
Source/path-based ignores that apply across all policies:
global:
ignore:
- name: "ignore-test-files"
paths:
- "**/test/**"
- "**/*_test.go"The scanner compares scan results over time and categorizes each finding:
| Category | Description |
|---|---|
| New | Items in current scan but not in previous |
| Removed | Items in previous scan but not in current |
| Existing | Items unchanged between scans |
| Changed | Items present in both with different values |
This enables targeted actions — for example, block new critical vulnerabilities while only warn on existing ones.
Validate configuration files and filesystem state with policy rules.
| Type | Description |
|---|---|
json |
JSON file validation |
yaml / yml |
YAML file validation |
toml |
TOML file validation |
text |
Plain text validation |
env |
Environment variable files (.env, .env.example) |
properties |
Java properties files (application.properties, gradle.properties) |
filesystem |
File/directory existence checks |
| (empty) | Auto-detect from file extension |
File paths support glob patterns for multi-file scanning:
file:
- name: "all-k8s-manifests"
path: "k8s/**/*.yaml"
type: yaml
actions:
existing: block
rules:
- type: exists
key: apiVersionComma-separated patterns are also supported: **/*.yaml,**/*.yml.
Use * to match all elements in arrays and numeric indices for specific elements:
file:
- name: "no-latest-images"
path: "k8s/**/*.yaml"
type: yaml
actions:
existing: block
rules:
- type: not_contains
key: "spec.containers.*.image"
value: ":latest"The match parameter controls wildcard evaluation:
| Match Value | Description |
|---|---|
"all" (default) |
Report finding if ANY value matches the condition |
"any" |
Report finding only if ALL values match the condition |
"none" |
Same as "all" |
Use scan: "lines" to process each line individually with line numbers in output:
file:
- name: "detect-aws-keys"
path: "**/*.py"
type: text
scan: lines
actions:
existing: block
rules:
- type: regex
value: "AKIA[0-9A-Z]{16}"
output_reason: "AWS access key detected"
outputs:
- destination: "github:pr"
fields: [FilePath, LineNumber, MatchedContent, Reason]Features:
- Line numbers in output (
LineNumberfield) - Backslash continuation joining — multi-line constructs (Dockerfile
RUNblocks, Makefile recipes) are joined into logical lines before evaluation - Matched content showing the actual matched substring
- Per-line implies — when combined with
operator: "implies", trigger/condition logic is applied per line
Use operator: "implies" with an exists trigger to validate file content only when the file is present:
file:
- name: "dockerfile-node-version"
path: "Dockerfile"
type: text
operator: implies
actions:
existing: warn
rules:
- type: exists
- type: regex
value: "FROM node:(18|20|22)"
output_reason: "Dockerfile should use a supported Node.js version"Compare values between files using ref_path and ref_key:
file:
- name: "version-consistency"
path: "package.json"
type: json
actions:
existing: block
rules:
- type: eq
key: "version"
ref_path: "Chart.yaml"
ref_type: yaml
output_reason: "package.json version must match Chart.yaml version"Override auto-generated messages with output_reason:
rules:
- type: ge
key: "engines.node"
value: "18"
output_reason: "Node.js 18+ is required for this project"Validate pull request metadata and file changes.
| Key | Description |
|---|---|
title |
PR title |
body |
PR description |
draft |
Whether PR is a draft |
changed_files |
Number of changed files |
total_added / total_removed |
Total lines added/removed |
commits |
Number of commits |
head.ref / base.ref |
Source/target branch name |
user.login |
PR author username |
labels.*.name |
Label names (wildcard) |
assignees.*.login |
Assignee usernames (wildcard) |
pr:
- name: "PR conventions"
operator: implies
rules:
- key: changed_files
type: ge
value: 10
action: info
output_reason: "Large PR detected (10+ files changed)"
- type: exists
file_pattern: ".*\\.md$"
file_status: changed
action: warn
output_reason: "Large PRs should include documentation updates"
outputs:
- destination: "github:pr"
fields: [RuleRole, Key, Value, Reason]| Destination | Description |
|---|---|
file:path |
Write to file |
github:pr |
GitHub PR comment |
github:issue |
GitHub issue |
github:code-scanning |
GitHub Code Scanning (SARIF upload) |
log:stdout / log:stderr |
Log to console |
url:endpoint |
POST to webhook |
| Format | Description |
|---|---|
markdown / md |
Markdown formatting |
html |
HTML formatting |
json |
JSON data export |
sarif |
SARIF 2.1.0 |
| Template | Description |
|---|---|
table |
Tabular data display |
text |
Prose-style text |
combined |
Multiple sections combined |
Load custom templates from the filesystem:
export CODEWARD_TEMPLATES_PATH=/path/to/templates/Template files: table.markdown.tmpl, table.html.tmpl, text.markdown.tmpl, text.html.tmpl, combined.markdown.tmpl, combined.html.tmpl.
Send findings to any HTTP endpoint with full control over the request:
vulnerability:
- name: "critical-vulns"
actions:
new: block
outputs:
- destination: "url:https://hooks.slack.com/services/xxx"
template: text
format: markdown
webhook:
method: POST
headers:
Content-Type: "application/json"
Authorization: "Bearer ${SLACK_TOKEN}"
body:
text: "${result}"
channel: "#security-alerts"Headers and body values support ${variable} placeholders:
| Variable | Description |
|---|---|
${result} |
Rendered template output |
${title} |
Output title |
${repository} |
Repository name |
${branch} |
Branch name |
${pr_number} |
PR number |
${owner} |
Repository owner |
${mode} |
Scan mode |
${timestamp} |
ISO 8601 UTC timestamp |
Unknown variable names fall back to environment variable lookup, enabling secrets passthrough.
Never hardcode tokens in config files. Use the GitHub Action's webhook_secrets input:
- uses: codeward-io/scan@v0.0.4
with:
webhook_secrets: |
SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
JIRA_API_KEY=${{ secrets.JIRA_API_KEY }}Webhook requests retry up to 3 times with 1-second backoff on 5xx and network errors.
SARIF 2.1.0 output integrates with GitHub Security tab, VS Code Problems panel, and Azure DevOps.
vulnerability:
- name: "all-vulns"
outputs:
- destination: "file:results.sarif"
format: sarifvulnerability:
- name: "all-vulns"
outputs:
- destination: "github:code-scanning"
format: sarifCODEWARD_SARIF_OUTPUT=results.sarif # Write all findings as SARIF to a file
CODEWARD_SARIF_UPLOAD=true # Upload all findings to GitHub Code ScanningExport a CycloneDX 1.6 JSON Software Bill of Materials:
sbom:
destination: "file:sbom.cdx.json"
format: cyclonedx
include_dev: falseOr via environment variable:
CODEWARD_SBOM_OUTPUT=sbom.cdx.jsonFeatures: de-duplication across lockfiles, PURL-based component identification, license attachment, dependency graph export, and optional dev dependency filtering.
- Create, update, and delete comments matched by
## Titleheader - Collapsible content support
- Create and update issues matched by title
- Reopen closed issues when violations recur
- Close issues when violations are resolved
- Upload SARIF findings to the Security tab
- Inline PR annotations on pull requests
- Reads
GITHUB_SHAandGITHUB_REFautomatically in GitHub Actions
- Automatic retry on 429 and 403 rate limit responses
- Exponential backoff with
Retry-After/X-RateLimit-Resetheader parsing
Set CODEWARD_GITHUB_TOKEN with the required scopes:
repofor private repos,public_repofor publicsecurity_events(orCode scanning alerts: writefor fine-grained tokens) for Code Scanning
# Show help
scan --help
# Scan with default config
scan
# Scan with custom config
scan --config /path/to/config.yaml
# Scan in diff mode
scan --mode diff
# Run with Intel snapshot download mode
scan --intel-mode download
# Enable debug logging
scan --log-level DEBUG
# Export raw diff data
scan --export-diff=output.jsonIf you installed a release binary named codeward-scan, replace scan with codeward-scan.
| Flag | Short | Description |
|---|---|---|
--config |
-c |
Path to config file (.yaml, .yml, or .json) |
--private-config |
— | Path to private config file |
--mode |
-m |
Scan mode: main or diff |
--intel-mode |
— | Intel mode: api, download, local, disabled |
--intel-required |
— | Fail the scan if no vulnerability data was obtained (default true) |
--intel-classification |
— | Use Intel's license category/severity instead of the built-in table (default false) |
--log-level |
— | Log level: DEBUG, INFO, WARN, ERROR |
--log-format |
— | Log format: text or json |
--export-diff |
— | Export raw diff results to JSON file |
--version |
-v |
Show version |
--help |
-h |
Show help |
- CLI arguments (highest priority)
- Environment variables
- Config files
- Default values
The scanner looks for config files in this order (first found wins):
.codeward.json → .codeward.yaml → .codeward.yml
Use --config / -c to specify a file explicitly. YAML and JSON configs are fully equivalent — every feature works in both formats.
| Variable | Default | Description |
|---|---|---|
CODEWARD_MODE |
main |
Scan mode (diff, main) |
CODEWARD_CONFIG_PATH |
auto-discovery | Main config path override |
CODEWARD_PRIVATE_CONFIG_PATH |
mode-dependent default | Private config path override |
CODEWARD_CACHE_DIR |
— | Base cache directory |
CODEWARD_TEMPLATES_PATH |
./internal/templates/ |
Custom templates directory |
CODEWARD_LOG_LEVEL |
INFO |
Log level (DEBUG, INFO, WARN, ERROR) |
CODEWARD_LOG_FORMAT |
text |
Log format (text, json) |
CODEWARD_LOG_OUTPUT |
stderr |
Log output (stderr, stdout) |
CODEWARD_LOG_TIMESTAMP |
false |
Enable log timestamps (true, false) |
CODEWARD_LOG_SUMMARY |
— | Summary level (none, minimal, standard, detailed) |
CODEWARD_SARIF_OUTPUT |
— | Write all findings as SARIF to file |
CODEWARD_SARIF_UPLOAD |
false |
Upload SARIF to GitHub Code Scanning |
CODEWARD_SBOM_OUTPUT |
— | Write CycloneDX SBOM to file |
CODEWARD_GITHUB_TOKEN |
— | GitHub API token |
CODEWARD_GITHUB_OWNER |
— | GitHub repository owner |
CODEWARD_GITHUB_REPOSITORY |
— | GitHub repository name |
CODEWARD_GITHUB_PR_NUMBER |
— | PR number for commenting |
CODEWARD_GITHUB_BRANCH |
— | Branch name |
CODEWARD_INTEL_API |
https://intel.codeward.io |
Intel API base URL |
CODEWARD_INTEL_TOKEN |
— | Intel API auth token |
CODEWARD_INTEL_MODE |
api |
Intel mode (api, download, local, disabled) |
CODEWARD_INTEL_CACHE |
— | Intel snapshot cache directory override |
CODEWARD_INTEL_SEGMENTS |
— | Extra snapshot segments (cwes, enrichments, rules, all) |
CODEWARD_INTEL_REQUIRED |
true |
Fail the scan if no vulnerability data was obtained |
CODEWARD_INTEL_CLASSIFICATION |
false |
Use Intel's resolved license category/severity instead of the built-in table |
CODEWARD_INTEL_PUBKEY |
— | Override the built-in key used to verify snapshot manifests (self-hosted Intel only) |
CODEWARD_API |
https://api.codeward.io |
Codeward API endpoint |
CODEWARD_TOKEN |
— | Codeward service token |
CODEWARD_AI_TOKEN |
— | Codeward AI token |
CODEWARD_WEBHOOK_SECRETS |
— | Webhook secrets (KEY=VALUE per line) |
CODEWARD_EXTRA_ENV |
— | Extra environment variables (KEY=VALUE per line) |
The scanner validates all policies before running:
- Rule types: Validates against allowed types
- Output fields: Validates
fieldsandgroup_byagainst valid fields per policy type - Template requirements: Requires template for markdown/html formats
- Destination format: Validates output destination prefixes
- Webhook validation: Checks HTTP methods, unclosed variables, and hardcoded secrets
Invalid policies are skipped with warnings while valid policies continue to run. The scan only fails completely if the config file has invalid syntax or ALL policies have validation errors.
The scanner uses Go's RE2 engine, which guarantees linear-time matching. Some PCRE features are unavailable, but the scanner provides built-in workarounds:
Supported:
| Feature | Syntax | Example |
|---|---|---|
| Case-insensitive | (?i) |
(?i)password |
| Multiline | (?m) |
(?m)^FROM |
| Dot matches newline | (?s) |
(?s)start.*end |
| Word boundary | \b |
\broot\b |
| Character classes | [...], \d, \w, \s |
[A-Z]{3,} |
| Alternation | | |
error|warning|fatal |
Unsupported PCRE features and workarounds:
| PCRE Feature | Workaround |
|---|---|
Negative lookahead (?!...) |
Use not_regex rule type |
Positive lookahead (?=...) |
Split into two regex rules with and operator |
Lookbehind (?<=...) |
Use last_match/not_last_match with line_filter |
Apache License 2.0 — see LICENSE.