Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Release PR

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-latest

steps:
- name: Create or update release PR
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
TAG_NAME: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then
echo "::notice::Skipping major tag update for non-semver tag $TAG_NAME"
exit 0
fi
major="${TAG_NAME%%.*}"
git tag -f "$major" "$TAG_NAME"
git push origin "refs/tags/$major" --force
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.0"
}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ Guidance for coding agents working in this repository.

## Contracts

- The action is fast-mode only and uses Hacktron's public API proxy:
`https://api.hacktron.ai/v1/hfw`.
- The action is fast-mode only and uses Hacktron's staging public API proxy:
`https://api-staging.hacktron.ai/v1/hfw`.
- Do not add Cloudflare Access inputs or require repository secrets for the
default public dependency scan.
- Keep `action.yml` thin. Put parsing, validation, and JSON handling in Python
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

Release notes are maintained by release-please.
71 changes: 41 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

Diff-aware supply-chain scanning for pull requests. The action checks npm and
PyPI dependencies added or changed by a PR against Hacktron's public malware
feed, posts one sticky PR comment, and fails the workflow when malicious
packages are introduced.
feed, posts a summary of dependency changes, and fails the workflow when [malicious
packages](https://unit42.paloaltonetworks.com/monitoring-npm-supply-chain-attacks/)
are introduced.

The default scan is free to run, fast-mode only, and does not require an API
key or Cloudflare Access credentials.
key.

## Usage

Expand All @@ -18,13 +19,13 @@ name: Hacktron Dependency Scan
on:
pull_request:
paths:
- '**/package-lock.json'
- '**/pnpm-lock.yaml'
- '**/npm-shrinkwrap.json'
- '**/pyproject.toml'
- '**/uv.lock'
- '**/requirements*.txt'
- '**/requirements/*.txt'
- "**/package-lock.json"
- "**/pnpm-lock.yaml"
- "**/npm-shrinkwrap.json"
- "**/pyproject.toml"
- "**/uv.lock"
- "**/requirements*.txt"
- "**/requirements/*.txt"

permissions:
contents: read
Expand All @@ -46,13 +47,13 @@ jobs:
The action compares the base branch manifest to the PR manifest and scans only
packages that were added or changed by the PR. Supported manifests:

| Ecosystem | Files |
| --- | --- |
| npm | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml` |
| PyPI | `pyproject.toml`, `uv.lock`, `requirements*.txt`, `requirements/*.txt` |
| Ecosystem | Files |
| --------- | ---------------------------------------------------------------------- |
| npm | `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml` |
| PyPI | `pyproject.toml`, `uv.lock`, `requirements*.txt`, `requirements/*.txt` |

If no `lockfile` or `lockfiles` input is set, supported manifests are
auto-discovered from tracked files using `git ls-files`.
auto-discovered from tracked files.

## Outcomes

Expand All @@ -67,20 +68,20 @@ packages fail the workflow by default.

## Inputs

| Input | Default | Description |
| --- | --- | --- |
| `lockfile` | empty | Single manifest to scan. Mutually exclusive in practice with `lockfiles`. |
| `lockfiles` | empty | Newline-separated manifest paths or bash-style path patterns. |
| `ignore-file` | `.hfwignore` | Optional allowlist file, one `name@version` per line. |
| `fail-on-malicious` | `true` | Fail the check when malicious packages are introduced. Allowed: `true`, `false`. |
| Input | Default | Description |
| ------------------- | ------------ | -------------------------------------------------------------------------------- |
| `lockfile` | empty | Single manifest to scan. Mutually exclusive in practice with `lockfiles`. |
| `lockfiles` | empty | Newline-separated manifest paths or bash-style path patterns. |
| `ignore-file` | `.hfwignore` | Optional allowlist file, one `name@version` per line. |
| `fail-on-malicious` | `true` | Fail the check when malicious packages are introduced. Allowed: `true`, `false`. |

## Outputs

| Output | Description |
| --- | --- |
| `malicious_count` | Number of malicious packages introduced by the PR. |
| `suspicious_count` | Number of suspicious packages introduced by the PR. |
| `diff_count` | Total added or changed packages considered by the action. |
| Output | Description |
| ------------------ | --------------------------------------------------------- |
| `malicious_count` | Number of malicious packages introduced by the PR. |
| `suspicious_count` | Number of suspicious packages introduced by the PR. |
| `diff_count` | Total added or changed packages considered by the action. |

## Selecting Manifests

Expand Down Expand Up @@ -161,12 +162,22 @@ uv run --locked pre-commit run --all-files

## Release

Merges to `main` run the `Release PR` workflow. It uses release-please to
create or update a release pull request from Conventional Commit history. Use
`fix:`, `feat:`, and `BREAKING CHANGE:` when merging user-facing changes so the
next version is calculated correctly.

1. Update the README if inputs, outputs, or behavior changed.
2. Run the full local check suite.
3. Merge to `main`.
4. Create a semver tag such as `v1.0.0`.
5. Move or create the major tag, such as `v1`, to the same commit.
6. Draft a GitHub release from the semver tag.
3. Merge the change to `main`.
4. Review the release-please pull request that updates `CHANGELOG.md` and the
release manifest.
5. Merge the release pull request to publish the semver tag and GitHub release.

The `Release` workflow runs after a GitHub release is published and moves the
major tag, such as `v1`, to the same commit as the semver tag. Use the
`Release PR` workflow dispatch button if the release PR needs to be recreated
or refreshed.

Marketplace users should pin to a major tag (`@v1`) or exact version
(`@v1.0.0`) depending on their update policy.
11 changes: 11 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"package-name": "dependency-scan",
"release-type": "simple",
"changelog-path": "CHANGELOG.md",
"include-component-in-tag": false
}
}
}
2 changes: 1 addition & 1 deletion scripts/github_action/extract_manifests.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def main() -> int:
projects: list[dict[str, str]] = []

for raw in os.environ.get("TARGETS", "").splitlines():
path = "".join(raw.split())
path = raw.strip()
if not path:
continue
projects.append(extract_manifest(path))
Expand Down
2 changes: 1 addition & 1 deletion scripts/github_action/resolve_manifests.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def main() -> int:
targets = lockfiles or lockfile
tracked = None
for raw in targets.splitlines():
path = "".join(raw.split())
path = raw.strip()
if not path:
continue
if any(char in path for char in "*?["):
Expand Down
6 changes: 4 additions & 2 deletions scripts/hfw_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
- malicious -> block the check; suspicious -> advisory only

Inputs (env):
HFW_SERVER default https://api.hacktron.ai/v1/hfw
HFW_SERVER default https://api-staging.hacktron.ai/v1/hfw
HFW_PUBLIC_BASE_URL default https://hfw.hacktron.ai

Multi-project mode (preferred):
Expand Down Expand Up @@ -51,7 +51,9 @@
import urllib.request
from pathlib import Path

HFW_SERVER = os.environ.get("HFW_SERVER", "https://api.hacktron.ai/v1/hfw").rstrip("/")
HFW_SERVER = os.environ.get(
"HFW_SERVER", "https://api-staging.hacktron.ai/v1/hfw"
).rstrip("/")
HFW_PUBLIC_BASE_URL = os.environ.get(
"HFW_PUBLIC_BASE_URL", "https://hfw.hacktron.ai"
).rstrip("/")
Expand Down
72 changes: 67 additions & 5 deletions tests/test_github_action_validate_inputs.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,82 @@
from __future__ import annotations

import importlib.util
import json
from pathlib import Path


def load_validator():
path = (
Path(__file__).resolve().parents[1] / "scripts/github_action/validate_inputs.py"
)
spec = importlib.util.spec_from_file_location("validate_inputs", path)
def load_script(relative_path: str, module_name: str):
path = Path(__file__).resolve().parents[1] / relative_path
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def load_validator():
return load_script("scripts/github_action/validate_inputs.py", "validate_inputs")


def load_resolver():
return load_script(
"scripts/github_action/resolve_manifests.py",
"resolve_manifests",
)


def load_extractor():
return load_script(
"scripts/github_action/extract_manifests.py",
"extract_manifests",
)


def test_resolve_manifest_input_trims_edges_without_stripping_inner_spaces(
monkeypatch,
tmp_path,
):
resolver = load_resolver()
manifest = tmp_path / "service api" / "pnpm-lock.yaml"
manifest.parent.mkdir()
manifest.write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
output = tmp_path / "github_output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(output))
monkeypatch.setenv("LOCKFILE", " service api/pnpm-lock.yaml ")
monkeypatch.delenv("LOCKFILES", raising=False)

assert resolver.main() == 0

assert "service api/pnpm-lock.yaml" in output.read_text(encoding="utf-8")


def test_extract_manifest_targets_trim_edges_without_stripping_inner_spaces(
monkeypatch,
tmp_path,
):
extractor = load_extractor()
manifest = tmp_path / "service api" / "pnpm-lock.yaml"
manifest.parent.mkdir()
manifest.write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TARGETS", " service api/pnpm-lock.yaml ")
monkeypatch.delenv("BASE_REF", raising=False)

assert extractor.main() == 0

projects_file = tmp_path / ".hfw-tmp/projects.json"
projects = json.loads(projects_file.read_text(encoding="utf-8"))
assert projects == [
{
"label": "service api",
"base": ".hfw-tmp/base/service api_pnpm-lock_yaml/pnpm-lock.yaml",
"head": ".hfw-tmp/head/service api_pnpm-lock_yaml/pnpm-lock.yaml",
"ecosystem": "npm",
}
]


def test_validate_inputs_normalizes_fail_on_malicious(monkeypatch, tmp_path):
validator = load_validator()
output = tmp_path / "github_output.txt"
Expand Down
9 changes: 7 additions & 2 deletions tests/test_hfw_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,9 @@ def fake_get(url: str, timeout: float):

def test_verdict_url_keeps_scoped_slash_literal():
url = hfw._verdict_url("npm", "@babel/runtime", "7.24.0", 8)
assert url.startswith(
"https://api-staging.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?"
)
assert "/verdict/npm/@babel/runtime?" in url
assert "%2F" not in url
assert "scan_mode" not in url
Expand All @@ -835,11 +838,13 @@ def test_verdict_url_preserves_direct_hfw_v1_contract(monkeypatch):


def test_verdict_url_does_not_double_v1_for_public_api_proxy(monkeypatch):
monkeypatch.setattr(hfw, "HFW_SERVER", "https://api.hacktron.ai/v1/hfw")
monkeypatch.setattr(hfw, "HFW_SERVER", "https://api-staging.hacktron.ai/v1/hfw")

url = hfw._verdict_url("npm", "@babel/runtime", "7.24.0", 8)

assert url.startswith("https://api.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?")
assert url.startswith(
"https://api-staging.hacktron.ai/v1/hfw/verdict/npm/@babel/runtime?"
)
assert "/v1/hfw/v1/" not in url


Expand Down
Loading
Loading