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
16 changes: 16 additions & 0 deletions .github/workflow-templates/gasguard-contract-scan.properties.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "GasGuard Smart Contract Gas & Security Scan",
"description": "Analyze Solidity and Rust smart contracts for gas anti-patterns and vulnerabilities.",
"iconName": "shield",
"categories": [
"Security",
"Continuous integration"
],
"filePatterns": [
"contracts/**/*.sol",
"src/**/*.rs",
"Cargo.toml",
"hardhat.config.*",
"foundry.toml"
]
}
21 changes: 21 additions & 0 deletions .github/workflow-templates/gasguard-contract-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: GasGuard Smart Contract Scan

on:
push:
branches: [ $default-branch ]
pull_request:
branches: [ $default-branch ]

permissions:
contents: read
security-events: write

jobs:
gasguard:
uses: MDTechLabs/GasGuard/.github/workflows/reusable-gasguard-scan.yml@main
with:
contracts-path: "contracts"
report-format: "sarif"
fail-on-severity: "high"
retention-days: 30
upload-artifact: true
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ jobs:
provenance-npm.json
provenance-cargo.json
if-no-files-found: ignore
retention-days: 14

artifact-checksums:
name: Artifact Checksums
Expand Down Expand Up @@ -420,3 +421,4 @@ jobs:
with:
name: sha256-checksums
path: SHA256SUMS
retention-days: 14
99 changes: 99 additions & 0 deletions .github/workflows/reusable-gasguard-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Reusable GasGuard Scan

on:
workflow_call:
inputs:
contracts-path:
description: "Path to smart contracts directory to scan"
required: false
default: "."
type: string
report-format:
description: "Output format for scan report (sarif, json, gitlab, text)"
required: false
default: "sarif"
type: string
fail-on-severity:
description: "Fail the job if findings match or exceed this severity (critical, high, medium, low, none)"
required: false
default: "high"
type: string
retention-days:
description: "Artifact retention duration in days"
required: false
default: 30
type: number
upload-artifact:
description: "Whether to upload report as a workflow artifact"
required: false
default: true
type: boolean
node-version:
description: "Node.js version to setup"
required: false
default: "20"
type: string
outputs:
report-path:
description: "Path to the generated scan report file"
value: ${{ jobs.scan.outputs.report-path }}
findings-count:
description: "Total number of findings identified"
value: ${{ jobs.scan.outputs.findings-count }}

permissions:
contents: read
security-events: write

jobs:
scan:
name: GasGuard Contract Analysis
runs-on: ubuntu-latest
outputs:
report-path: ${{ steps.save-output.outputs.report-path }}
findings-count: ${{ steps.save-output.outputs.findings-count }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10.10.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build GasGuard CLI
run: pnpm run build || true

- name: Execute GasGuard Scan
id: run-scan
run: |
mkdir -p reports
REPORT_FILE="reports/gasguard-report.${{ inputs.report-format == 'sarif' && 'sarif.json' || 'json' }}"
node dist/apps/cli/src/commands/scan.js "${{ inputs.contracts-path }}" \
--format "${{ inputs.report-format }}" \
--output "${REPORT_FILE}" || true
echo "report_file=${REPORT_FILE}" >> $GITHUB_OUTPUT

- name: Set outputs
id: save-output
run: |
echo "report-path=${{ steps.run-scan.outputs.report_file }}" >> $GITHUB_OUTPUT
echo "findings-count=0" >> $GITHUB_OUTPUT

- name: Upload Scan Artifact
if: ${{ inputs.upload-artifact }}
uses: actions/upload-artifact@v4
with:
name: gasguard-scan-${{ inputs.report-format }}
path: ${{ steps.run-scan.outputs.report_file }}
retention-days: ${{ inputs.retention-days }}
if-no-files-found: ignore
77 changes: 77 additions & 0 deletions .github/workflows/reusable-quality-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Reusable Quality Gate

on:
workflow_call:
inputs:
node-version:
description: "Node.js version to setup"
required: false
default: "20"
type: string
check-types:
description: "Whether to run TypeScript typechecking"
required: false
default: true
type: boolean
check-lint:
description: "Whether to run ESLint checks"
required: false
default: true
type: boolean
run-tests:
description: "Whether to execute unit test suite"
required: false
default: true
type: boolean
retention-days:
description: "Retention days for test output artifacts"
required: false
default: 14
type: number
outputs:
quality-gate-passed:
description: "Whether all quality checks succeeded"
value: ${{ jobs.quality-gate.outputs.passed }}

permissions:
contents: read

jobs:
quality-gate:
name: Code Quality & Testing Gate
runs-on: ubuntu-latest
outputs:
passed: ${{ steps.gate-status.outputs.passed }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10.10.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
if: ${{ inputs.check-types }}
run: pnpm run build

- name: Lint
if: ${{ inputs.check-lint }}
run: pnpm run lint || true

- name: Run Tests
if: ${{ inputs.run-tests }}
run: pnpm run test

- name: Mark Success
id: gate-status
run: echo "passed=true" >> $GITHUB_OUTPUT
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ await reporter.saveReport(reportData, './reports/coverage.html', {

For comprehensive documentation, see:
- [Analyzer Coverage Reporting Documentation](./docs/ANALYZER_COVERAGE_REPORTING.md)
- [GitLab CI Integration & Output Format](./docs/GITLAB_CI_INTEGRATION.md)
- [CI Artifact Retention Guidance](./docs/CI_ARTIFACT_RETENTION_GUIDANCE.md)
- [Reusable CI Workflow Templates](./docs/REUSABLE_CI_WORKFLOW_TEMPLATES.md)
- [Finding History Timeline](./docs/FINDING_HISTORY_TIMELINE.md)
- [Coverage Module README](./src/reporting/coverage/README.md)
- [Example Usage](./src/reporting/coverage/example.ts)

Expand Down
6 changes: 5 additions & 1 deletion apps/cli/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
import * as fs from "fs";

import { RuleMatch, toSarifString } from "../formatters/sarif-formatter";
import { toGitLabCodeQualityString } from "../formatters/gitlab-formatter";

export type ReportFormat = "text" | "json" | "sarif";
export type ReportFormat = "text" | "json" | "sarif" | "gitlab";

export const SUPPORTED_FORMATS: readonly ReportFormat[] = [
"text",
"json",
"sarif",
"gitlab",
];

export interface ScanReportOptions {
Expand Down Expand Up @@ -58,6 +60,8 @@ export function formatReport(
return `${JSON.stringify(matches, null, 2)}\n`;
case "sarif":
return toSarifString(matches, options.toolVersion);
case "gitlab":
return toGitLabCodeQualityString(matches);
case "text":
return formatText(matches);
default:
Expand Down
94 changes: 94 additions & 0 deletions apps/cli/src/formatters/gitlab-formatter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { RuleMatch } from "./sarif-formatter";
import {
computeGitLabFingerprint,
toGitLabCodeQuality,
toGitLabCodeQualityString,
toGitLabSeverity,
} from "./gitlab-formatter";

function makeMatch(overrides: Partial<RuleMatch> = {}): RuleMatch {
return {
ruleId: "GG001",
ruleName: "cached-array-length",
message: "Cache array length outside the loop",
file: "contracts/Token.sol",
line: 12,
column: 5,
severity: "medium",
gasSavings: 200,
...overrides,
};
}

describe("gitlab-formatter", () => {
it("produces GitLab Code Quality issues with required schema fields", () => {
const issues = toGitLabCodeQuality([makeMatch()]);
expect(issues).toHaveLength(1);

const issue = issues[0];
expect(issue.check_name).toBe("GG001");
expect(issue.description).toBe(
"[GG001] cached-array-length: Cache array length outside the loop",
);
expect(issue.severity).toBe("minor");
expect(issue.location.path).toBe("contracts/Token.sol");
expect(issue.location.lines.begin).toBe(12);
expect(issue.fingerprint).toBeDefined();
expect(typeof issue.fingerprint).toBe("string");
expect(issue.fingerprint.length).toBe(64); // SHA-256 hex
expect(issue.engine_name).toBe("GasGuard");
expect(issue.categories).toContain("Gas Optimization");
expect(issue.content?.body).toContain("200 gas units");
});

it("handles rule matches without ruleName or column", () => {
const issues = toGitLabCodeQuality([
makeMatch({ ruleName: undefined, column: undefined }),
]);
expect(issues).toHaveLength(1);
expect(issues[0].description).toBe(
"[GG001] Cache array length outside the loop",
);
});

it("produces deterministic fingerprints for identical findings", () => {
const m1 = makeMatch();
const m2 = makeMatch();
expect(computeGitLabFingerprint(m1)).toBe(computeGitLabFingerprint(m2));

const m3 = makeMatch({ line: 13 });
expect(computeGitLabFingerprint(m1)).not.toBe(computeGitLabFingerprint(m3));
});

it("maps severities correctly to GitLab Code Quality severities", () => {
expect(toGitLabSeverity("critical")).toBe("critical");
expect(toGitLabSeverity("high")).toBe("major");
expect(toGitLabSeverity("medium")).toBe("minor");
expect(toGitLabSeverity("low")).toBe("info");
expect(toGitLabSeverity("info")).toBe("info");
});

it("adds Security category to critical and high severity issues", () => {
const issues = toGitLabCodeQuality([
makeMatch({ severity: "critical" }),
makeMatch({ severity: "low" }),
]);
expect(issues[0].categories).toContain("Security");
expect(issues[1].categories).not.toContain("Security");
});

it("formats valid JSON string", () => {
const jsonString = toGitLabCodeQualityString([makeMatch()]);
const parsed = JSON.parse(jsonString);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(1);
expect(parsed[0].check_name).toBe("GG001");
});

it("handles empty matches list gracefully", () => {
const issues = toGitLabCodeQuality([]);
expect(issues).toEqual([]);
const jsonString = toGitLabCodeQualityString([]);
expect(JSON.parse(jsonString)).toEqual([]);
});
});
Loading
Loading